diff --git a/cli/src/components/waiting-room-screen.tsx b/cli/src/components/waiting-room-screen.tsx index 13646776a9..8608c4e35f 100644 --- a/cli/src/components/waiting-room-screen.tsx +++ b/cli/src/components/waiting-room-screen.tsx @@ -30,6 +30,7 @@ import { FREEBUFF_PREMIUM_SESSION_LIMIT, } from '@codebuff/common/constants/freebuff-models' import { getRateLimitsByModel } from '@codebuff/common/types/freebuff-session' +import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy' import type { FreebuffSessionResponse } from '../types/freebuff-session' import type { FreebuffIpPrivacySignal } from '@codebuff/common/types/freebuff-session' @@ -642,7 +643,10 @@ export const WaitingRoomScreen: React.FC = ({ {session.countryBlockReason === 'anonymous_network' ? ( <> We detected{' '} - {formatPrivacySignalList(session.ipPrivacySignals)} traffic + {formatFreebuffHardBlockedPrivacySignals( + session.ipPrivacySignals, + )}{' '} + traffic {session.countryCode === 'UNKNOWN' ? ( '' ) : ( @@ -652,8 +656,8 @@ export const WaitingRoomScreen: React.FC = ({ {session.countryCode} )} - . Freebuff can't be used from anonymized networks. Press - Ctrl+C to exit. + . Freebuff can't be used from VPN, proxy, or Tor traffic. + Disable it and restart Freebuff to try again. ) : session.countryCode === 'UNKNOWN' ? ( <> diff --git a/cli/src/hooks/helpers/send-message.ts b/cli/src/hooks/helpers/send-message.ts index d9e680316d..e8ceb9421a 100644 --- a/cli/src/hooks/helpers/send-message.ts +++ b/cli/src/hooks/helpers/send-message.ts @@ -13,12 +13,12 @@ import { processBashContext } from '../../utils/bash-context-processor' import { markRunningAgentsAsCancelled } from '../../utils/block-operations' import { getCountryBlockFromFreeModeError, + getFreeModeUnavailableErrorMessage, getFreebuffGateErrorKind, getFreebuffRateLimitErrorMessage, isOutOfCreditsError, isFreeModeUnavailableError, OUT_OF_CREDITS_MESSAGE, - FREE_MODE_UNAVAILABLE_MESSAGE, } from '../../utils/error-handling' import { formatElapsedTime } from '../../utils/format-elapsed-time' import { processImagesForMessage } from '../../utils/image-processor' @@ -399,7 +399,7 @@ export const handleRunCompletion = (params: { } if (isFreeModeUnavailableError(output)) { - updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE) + updater.setError(getFreeModeUnavailableErrorMessage(output)) if (IS_FREEBUFF) { markFreebuffSessionCountryBlocked( getCountryBlockFromFreeModeError(output) ?? { @@ -510,7 +510,7 @@ export const handleRunError = (params: { } if (isFreeModeUnavailableError(error)) { - updater.setError(FREE_MODE_UNAVAILABLE_MESSAGE) + updater.setError(getFreeModeUnavailableErrorMessage(error)) if (IS_FREEBUFF) { markFreebuffSessionCountryBlocked( getCountryBlockFromFreeModeError(error) ?? { diff --git a/cli/src/utils/__tests__/error-handling.test.ts b/cli/src/utils/__tests__/error-handling.test.ts index 28a43726c6..73517de083 100644 --- a/cli/src/utils/__tests__/error-handling.test.ts +++ b/cli/src/utils/__tests__/error-handling.test.ts @@ -2,6 +2,7 @@ import { describe, test, expect } from 'bun:test' import { getFreebuffRateLimitErrorMessage, + getFreeModeUnavailableErrorMessage, isOutOfCreditsError, isFreeModeUnavailableError, getCountryBlockFromFreeModeError, @@ -81,6 +82,18 @@ describe('error-handling', () => { expect(isFreeModeUnavailableError(error)).toBe(true) }) + test('returns true for responseBody free_mode_unavailable errors', () => { + expect( + isFreeModeUnavailableError({ + statusCode: 403, + responseBody: JSON.stringify({ + error: 'free_mode_unavailable', + message: 'Freebuff cannot be used from VPN traffic.', + }), + }), + ).toBe(true) + }) + test('returns false for 403 without error field', () => { const error = { statusCode: 403, message: 'Forbidden' } expect(isFreeModeUnavailableError(error)).toBe(false) @@ -234,6 +247,24 @@ describe('error-handling', () => { }) }) + test('extracts country block details from responseBody errors', () => { + const error = { + statusCode: 403, + responseBody: JSON.stringify({ + error: 'free_mode_unavailable', + countryCode: 'US', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['proxy', 'hosting', 123], + }), + } + + expect(getCountryBlockFromFreeModeError(error)).toEqual({ + countryCode: 'US', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['proxy', 'hosting'], + }) + }) + test('defaults missing country code to UNKNOWN', () => { const error = { statusCode: 403, @@ -265,6 +296,44 @@ describe('error-handling', () => { }) }) + describe('getFreeModeUnavailableErrorMessage', () => { + test('uses a VPN/proxy-specific message for anonymous-network blocks', () => { + expect( + getFreeModeUnavailableErrorMessage({ + statusCode: 403, + error: 'free_mode_unavailable', + message: 'Forbidden', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['vpn', 'hosting'], + }), + ).toContain('VPN') + }) + + test('uses a VPN/proxy-specific message from responseBody details', () => { + expect( + getFreeModeUnavailableErrorMessage({ + statusCode: 403, + message: 'Forbidden', + responseBody: JSON.stringify({ + error: 'free_mode_unavailable', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['tor'], + }), + }), + ).toContain('Tor') + }) + + test('preserves server message for non-privacy free mode blocks', () => { + expect( + getFreeModeUnavailableErrorMessage({ + statusCode: 403, + error: 'free_mode_unavailable', + message: 'Free mode is not available in your country.', + }), + ).toBe('Free mode is not available in your country.') + }) + }) + describe('OUT_OF_CREDITS_MESSAGE', () => { test('contains usage URL', () => { expect(OUT_OF_CREDITS_MESSAGE).toContain('/usage') diff --git a/cli/src/utils/error-handling.ts b/cli/src/utils/error-handling.ts index 9adedc6d28..0eb9a682cf 100644 --- a/cli/src/utils/error-handling.ts +++ b/cli/src/utils/error-handling.ts @@ -1,5 +1,6 @@ import { env } from '@codebuff/common/env' import { extractApiErrorDetails } from '@codebuff/common/util/error' +import { formatFreebuffHardBlockedPrivacySignals } from '@codebuff/common/util/freebuff-privacy' import type { ChatMessage } from '../types/chat' import type { @@ -49,17 +50,11 @@ export const isOutOfCreditsError = (error: unknown): boolean => { * Standardized on statusCode === 403 + error === 'free_mode_unavailable'. */ export const isFreeModeUnavailableError = (error: unknown): boolean => { - if ( - error && - typeof error === 'object' && - 'statusCode' in error && - (error as { statusCode: unknown }).statusCode === 403 && - 'error' in error && - (error as { error: unknown }).error === 'free_mode_unavailable' - ) { - return true - } - return false + const details = getCliApiErrorDetails(error) + return ( + details.statusCode === 403 && + details.errorCode === 'free_mode_unavailable' + ) } const getTopLevelApiErrorDetails = ( @@ -68,12 +63,20 @@ const getTopLevelApiErrorDetails = ( statusCode?: number errorCode?: string message?: string + countryCode?: string + countryBlockReason?: string + ipPrivacySignals?: string[] } => { if (!error || typeof error !== 'object') return {} const statusCode = (error as { statusCode?: unknown }).statusCode const status = (error as { status?: unknown }).status const errorCode = (error as { error?: unknown }).error const message = (error as { message?: unknown }).message + const countryCode = (error as { countryCode?: unknown }).countryCode + const countryBlockReason = (error as { countryBlockReason?: unknown }) + .countryBlockReason + const ipPrivacySignals = (error as { ipPrivacySignals?: unknown }) + .ipPrivacySignals const resolvedStatusCode = typeof statusCode === 'number' ? statusCode @@ -85,6 +88,14 @@ const getTopLevelApiErrorDetails = ( ...(resolvedStatusCode !== undefined && { statusCode: resolvedStatusCode }), ...(typeof errorCode === 'string' && { errorCode }), ...(typeof message === 'string' && message.length > 0 && { message }), + ...(typeof countryCode === 'string' && + countryCode.length > 0 && { countryCode }), + ...(typeof countryBlockReason === 'string' && { countryBlockReason }), + ...(Array.isArray(ipPrivacySignals) && { + ipPrivacySignals: ipPrivacySignals.filter( + (signal): signal is string => typeof signal === 'string', + ), + }), } } @@ -97,6 +108,10 @@ const getCliApiErrorDetails = (error: unknown) => { errorCode: topLevel.errorCode ?? parsed.errorCode, // Prefer responseBody messages over top-level HTTP status text. message: parsed.message ?? topLevel.message, + countryCode: topLevel.countryCode ?? parsed.countryCode, + countryBlockReason: + topLevel.countryBlockReason ?? parsed.countryBlockReason, + ipPrivacySignals: topLevel.ipPrivacySignals ?? parsed.ipPrivacySignals, } } @@ -119,11 +134,7 @@ export const getCountryBlockFromFreeModeError = ( ipPrivacySignals?: FreebuffIpPrivacySignal[] } | null => { if (!isFreeModeUnavailableError(error)) return null - const errorDetails = error as { - countryCode?: unknown - countryBlockReason?: unknown - ipPrivacySignals?: unknown - } + const errorDetails = getCliApiErrorDetails(error) const countryCode = typeof errorDetails.countryCode === 'string' && errorDetails.countryCode.length > 0 @@ -136,13 +147,23 @@ export const getCountryBlockFromFreeModeError = ( typeof errorDetails.countryBlockReason === 'string' ? (errorDetails.countryBlockReason as FreebuffCountryBlockReason) : undefined, - ipPrivacySignals: Array.isArray(errorDetails.ipPrivacySignals) - ? errorDetails.ipPrivacySignals.filter( - (signal): signal is FreebuffIpPrivacySignal => - typeof signal === 'string', - ) - : undefined, + ipPrivacySignals: errorDetails.ipPrivacySignals as + | FreebuffIpPrivacySignal[] + | undefined, + } +} + +export const getFreeModeUnavailableErrorMessage = ( + error: unknown, +): string => { + const details = getCliApiErrorDetails(error) + const block = getCountryBlockFromFreeModeError(error) + if (block?.countryBlockReason === 'anonymous_network') { + return `${IS_FREEBUFF ? 'Freebuff' : 'Free mode'} cannot be used from ${formatFreebuffHardBlockedPrivacySignals( + block.ipPrivacySignals, + )} traffic. Please disable it and try again.` } + return details.message ?? FREE_MODE_UNAVAILABLE_MESSAGE } /** diff --git a/common/src/types/freebuff-session.ts b/common/src/types/freebuff-session.ts index 2073441243..732b6f15b1 100644 --- a/common/src/types/freebuff-session.ts +++ b/common/src/types/freebuff-session.ts @@ -168,6 +168,7 @@ export type FreebuffSessionServerResponse = * CLI stops polling and shows a "not available in your country" * screen. `countryCode` is the resolved country, or UNKNOWN. */ status: 'country_blocked' + message?: string countryCode: string countryBlockReason?: FreebuffCountryBlockReason ipPrivacySignals?: FreebuffIpPrivacySignal[] diff --git a/common/src/util/freebuff-privacy.ts b/common/src/util/freebuff-privacy.ts new file mode 100644 index 0000000000..a559f8b897 --- /dev/null +++ b/common/src/util/freebuff-privacy.ts @@ -0,0 +1,55 @@ +import type { FreebuffIpPrivacySignal } from '../types/freebuff-session' + +export const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS = [ + 'vpn', + 'proxy', + 'tor', + 'res_proxy', +] as const satisfies readonly FreebuffIpPrivacySignal[] + +type FreebuffHardBlockedPrivacySignal = + (typeof FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS)[number] + +const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET = + new Set(FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS) + +const FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS: Record< + FreebuffHardBlockedPrivacySignal, + string +> = { + vpn: 'VPN', + proxy: 'proxy', + res_proxy: 'proxy', + tor: 'Tor', +} + +export function isFreebuffHardBlockedPrivacySignal( + signal: FreebuffIpPrivacySignal, +): signal is FreebuffHardBlockedPrivacySignal { + return FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_SET.has(signal) +} + +export function formatFreebuffHardBlockedPrivacySignals( + signals: readonly FreebuffIpPrivacySignal[] | null | undefined, +): string { + const labels = Array.from( + new Set( + (signals ?? []).flatMap((signal): string[] => { + if (!isFreebuffHardBlockedPrivacySignal(signal)) return [] + return [FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNAL_LABELS[signal]] + }), + ), + ) + + if (labels.length === 0) return 'VPN, proxy, or Tor' + if (labels.length === 1) return labels[0] + return `${labels.slice(0, -1).join(', ')} or ${labels[labels.length - 1]}` +} + +export function formatFreebuffHardBlockedMessage( + signals: readonly FreebuffIpPrivacySignal[] | null | undefined, +): string { + return `Freebuff cannot be used from ${formatFreebuffHardBlockedPrivacySignals( + signals, + )} traffic. Please disable it and try again.` +} diff --git a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts index b64f440ee4..d728bc131a 100644 --- a/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts +++ b/web/src/app/api/v1/chat/completions/__tests__/completions.test.ts @@ -589,7 +589,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { method: 'POST', headers: { Authorization: 'Bearer test-api-key-new-free', - 'cf-ipcountry': 'T1', + 'cf-ipcountry': 'XX', 'x-forwarded-for': '8.8.8.8', }, body: JSON.stringify({ @@ -627,6 +627,86 @@ describe('/api/v1/chat/completions POST endpoint', () => { FETCH_PATH_TEST_TIMEOUT_MS, ) + it( + 'blocks hard VPN/proxy privacy signals before the session gate', + async () => { + const req = new NextRequest( + 'http://localhost:3000/api/v1/chat/completions', + { + method: 'POST', + headers: allowedFreeModeHeaders('test-api-key-new-free'), + body: JSON.stringify({ + model: FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID, + stream: false, + codebuff_metadata: { + run_id: 'run-free-deepseek-flash', + client_id: 'test-client-id-123', + cost_mode: 'free', + freebuff_instance_id: 'active-instance-123', + }, + }), + }, + ) + + const endFreebuffSession = mock(async () => {}) + const response = await postChatCompletionsForTest({ + req, + getUserInfoFromApiKey: mockGetUserInfoFromApiKey, + logger: mockLogger, + trackEvent: mockTrackEvent, + getUserUsageData: mockGetUserUsageData, + getAgentRunFromId: mockGetAgentRunFromId, + fetch: mockFetch, + insertMessageBigquery: mockInsertMessageBigquery, + loggerWithContext: mockLoggerWithContext, + checkSessionAdmissible: mock(() => { + throw new Error('session gate should not be reached') + }), + endFreebuffSession, + resolveFreeModeCountryAccess: async () => ({ + allowed: false, + countryCode: 'US', + blockReason: 'anonymous_network', + cfCountry: 'US', + geoipCountry: null, + ipPrivacy: { signals: ['vpn', 'hosting'] }, + hasClientIp: true, + clientIpHash: 'test-ip-hash', + }), + }) + expect(endFreebuffSession).toHaveBeenCalledWith({ + userId: 'user-new-free', + userEmail: null, + }) + + expect(response.status).toBe(403) + const body = await response.json() + expect(body).toMatchObject({ + error: 'free_mode_unavailable', + countryCode: 'US', + countryBlockReason: 'anonymous_network', + ipPrivacySignals: ['vpn', 'hosting'], + }) + expect(body.message).toContain('VPN') + const validationEvent = ( + mockTrackEvent as ReturnType + ).mock.calls + .map(([params]) => params as Parameters[0]) + .find( + ({ event, properties }) => + event === AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR && + properties?.error === 'free_mode_unavailable', + ) + expect(validationEvent?.properties).toMatchObject({ + accessStatus: 'blocked', + countryCode: 'US', + ipPrivacySignals: ['vpn', 'hosting'], + }) + expect(validationEvent?.properties).not.toHaveProperty('accessTier') + }, + FETCH_PATH_TEST_TIMEOUT_MS, + ) + it( 'includes full freebuff access tier on successful usage analytics', async () => { @@ -844,7 +924,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { method: 'POST', headers: { Authorization: 'Bearer test-api-key-new-free', - 'cf-ipcountry': 'T1', + 'cf-ipcountry': 'XX', 'x-forwarded-for': '8.8.8.8', }, body: JSON.stringify({ diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 0a48fce0bc..401039b39c 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -13,6 +13,7 @@ import { isFreeModeAllowedAgentModel, } from '@codebuff/common/constants/free-agents' import { getErrorObject } from '@codebuff/common/util/error' +import { formatFreebuffHardBlockedMessage } from '@codebuff/common/util/freebuff-privacy' import { pluralize } from '@codebuff/common/util/string' import { env } from '@codebuff/internal/env' import { NextResponse } from 'next/server' @@ -91,9 +92,15 @@ import { handleOpenRouterStream, OpenRouterError, } from '@/llm-api/openrouter' -import { checkSessionAdmissible } from '@/server/free-session/public-api' +import { + checkSessionAdmissible, + endUserSession, +} from '@/server/free-session/public-api' import { getCachedFreeModeCountryAccess } from '@/server/free-mode-country-access-cache' -import { getFreeModeAccessTier } from '@/server/free-mode-country' +import { + getFreeModeAccessTier, + shouldHardBlockFreeModeAccess, +} from '@/server/free-mode-country' import type { SessionGateResult } from '@/server/free-session/public-api' import type { @@ -141,6 +148,7 @@ export const formatQuotaResetCountdown = ( } export type CheckSessionAdmissibleFn = typeof checkSessionAdmissible +export type EndUserSessionFn = typeof endUserSession export type CheckFreeModeRateLimitFn = typeof defaultCheckFreeModeRateLimit export type ResolveFreeModeCountryAccessFn = ( userId: string, @@ -170,6 +178,12 @@ const STATUS_BY_GATE_CODE = { freebuff_update_required: 426, } satisfies Record +function getHardBlockedFreeModeMessage( + countryAccess: Pick, +): string { + return formatFreebuffHardBlockedMessage(countryAccess.ipPrivacy?.signals) +} + export async function postChatCompletions(params: { req: NextRequest getUserInfoFromApiKey: GetUserInfoFromApiKeyFn @@ -194,6 +208,8 @@ export async function postChatCompletions(params: { /** Optional override for country/cache checks. Tests inject this to avoid * coupling to Postgres-backed cache state. */ resolveFreeModeCountryAccess?: ResolveFreeModeCountryAccessFn + /** Optional override for releasing stale waiting-room rows on hard blocks. */ + endFreebuffSession?: EndUserSessionFn }) { const { req, @@ -208,6 +224,7 @@ export async function postChatCompletions(params: { checkSessionAdmissible: checkSession = checkSessionAdmissible, checkFreeModeRateLimit = defaultCheckFreeModeRateLimit, resolveFreeModeCountryAccess, + endFreebuffSession = endUserSession, } = params let { logger } = params let { trackEvent } = params @@ -312,9 +329,9 @@ export async function postChatCompletions(params: { ) } - // For free mode requests, classify the request into full or limited - // access. Disallowed countries and anonymized networks are no longer - // blocked outright; they are limited to the cheap DeepSeek Flash path. + // For free mode requests, classify the request into full, limited, or + // hard-blocked access. Most non-allowlist/privacy cases are limited to the + // cheap DeepSeek Flash path, but VPN/proxy/Tor traffic is rejected outright. if (isFreeModeRequest) { const countryAccess = await resolveCountryAccess(userId, req, { fetch, @@ -326,9 +343,7 @@ export async function postChatCompletions(params: { env.FREEBUFF_DEV_FORCE_LIMITED, }) freebuffAccessTier = getFreeModeAccessTier(countryAccess) - trackEvent = withDefaultProperties(trackEvent, { - accessTier: freebuffAccessTier, - }) + const hardBlocked = shouldHardBlockFreeModeAccess(countryAccess) if (!countryAccess.allowed || sampleFreebuffSuccess) { logger.info( @@ -344,6 +359,43 @@ export async function postChatCompletions(params: { ) } + if (hardBlocked) { + const error = 'free_mode_unavailable' + const message = getHardBlockedFreeModeMessage(countryAccess) + await endFreebuffSession({ + userId, + userEmail: userInfo.email ?? null, + }) + trackEvent({ + event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, + userId, + properties: { + error, + countryCode: countryAccess.countryCode, + countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, + clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, + accessStatus: 'blocked', + }, + logger, + }) + return NextResponse.json( + { + error, + message, + countryCode: countryAccess.countryCode ?? 'UNKNOWN', + countryBlockReason: countryAccess.blockReason ?? undefined, + ipPrivacySignals: countryAccess.ipPrivacy?.signals ?? undefined, + }, + { status: 403 }, + ) + } + + trackEvent = withDefaultProperties(trackEvent, { + accessTier: freebuffAccessTier, + accessStatus: freebuffAccessTier, + }) + if (!countryAccess.allowed) { trackEvent({ event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR, diff --git a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts index 1f072b7b03..99424d64db 100644 --- a/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts +++ b/web/src/app/api/v1/freebuff/session/__tests__/session.test.ts @@ -31,7 +31,7 @@ function testCountryAccess(req: NextRequest): FreeModeCountryAccess { blockReason: 'anonymized_or_unknown_country', cfCountry, geoipCountry: null, - ipPrivacy: null, + ipPrivacy: cfCountry === 'T1' ? { signals: ['tor'] } : null, hasClientIp, clientIpHash: hasClientIp ? 'test-ip-hash' : null, } @@ -268,10 +268,10 @@ describe('POST /api/v1/freebuff/session', () => { expect(body.model).toBe(FREEBUFF_DEEPSEEK_V4_FLASH_MODEL_ID) }) - test('creates a limited DeepSeek Flash session for anonymized Cloudflare country', async () => { + test('creates a limited DeepSeek Flash session for unknown Cloudflare country', async () => { const sessionDeps = makeSessionDeps() const resp = await postFreebuffSession( - makeReq('ok', { cfCountry: 'T1' }), + makeReq('ok', { cfCountry: 'XX' }), makeDeps(sessionDeps, 'u1'), ) expect(resp.status).toBe(200) @@ -291,6 +291,82 @@ describe('POST /api/v1/freebuff/session', () => { expect(body.status).toBe('queued') }) + test('blocks VPN/proxy privacy signals before joining the queue', async () => { + const sessionDeps = makeSessionDeps() + sessionDeps.rows.set('u1', { + user_id: 'u1', + status: 'queued', + active_instance_id: 'old-inst', + model: DEFAULT_MODEL, + queued_at: new Date(), + admitted_at: null, + expires_at: null, + created_at: new Date(), + updated_at: new Date(), + }) + const resp = await postFreebuffSession( + makeReq('ok', { cfCountry: 'US' }), + makeDeps(sessionDeps, 'u1', { + getCountryAccess: async () => ({ + allowed: false, + countryCode: 'US', + blockReason: 'anonymous_network', + cfCountry: 'US', + geoipCountry: null, + ipPrivacy: { signals: ['vpn', 'hosting'] }, + hasClientIp: true, + clientIpHash: 'test-ip-hash', + }), + }), + ) + expect(resp.status).toBe(403) + const body = await resp.json() + expect(body.status).toBe('country_blocked') + expect(body.message).toContain('VPN') + expect(body.countryBlockReason).toBe('anonymous_network') + expect(body.ipPrivacySignals).toEqual(['vpn', 'hosting']) + expect(sessionDeps.rows.size).toBe(0) + }) + + test('blocks Cloudflare Tor before joining the queue', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok', { cfCountry: 'T1' }), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(403) + const body = await resp.json() + expect(body.status).toBe('country_blocked') + expect(body.message).toContain('Tor') + expect(body.countryBlockReason).toBe('anonymized_or_unknown_country') + expect(body.ipPrivacySignals).toEqual(['tor']) + expect(sessionDeps.rows.size).toBe(0) + }) + + test('keeps hosting-only privacy signals in limited mode', async () => { + const sessionDeps = makeSessionDeps() + const resp = await postFreebuffSession( + makeReq('ok', { cfCountry: 'US' }), + makeDeps(sessionDeps, 'u1', { + getCountryAccess: async () => ({ + allowed: false, + countryCode: 'US', + blockReason: 'anonymous_network', + cfCountry: 'US', + geoipCountry: null, + ipPrivacy: { signals: ['hosting'] }, + hasClientIp: true, + clientIpHash: 'test-ip-hash', + }), + }), + ) + expect(resp.status).toBe(200) + const body = await resp.json() + expect(body.status).toBe('queued') + expect(body.accessTier).toBe('limited') + expect(body.ipPrivacySignals).toEqual(['hosting']) + }) + test('returns model_unavailable for legacy GLM 5.1 outside deployment hours', async () => { const sessionDeps = makeSessionDeps() const resp = await postFreebuffSession( @@ -348,7 +424,7 @@ describe('GET /api/v1/freebuff/session', () => { expect(body.ipPrivacySignals).toBeNull() }) - test('returns limited-mode privacy reason on GET', async () => { + test('returns limited-mode privacy reason on GET for hosting-only signal', async () => { const sessionDeps = makeSessionDeps() const resp = await getFreebuffSession( makeReq('ok', { cfCountry: 'US' }), @@ -359,7 +435,7 @@ describe('GET /api/v1/freebuff/session', () => { blockReason: 'anonymous_network', cfCountry: 'US', geoipCountry: null, - ipPrivacy: { signals: ['vpn', 'hosting'] }, + ipPrivacy: { signals: ['hosting'] }, hasClientIp: true, clientIpHash: 'test-ip-hash', }), @@ -371,7 +447,70 @@ describe('GET /api/v1/freebuff/session', () => { expect(body.accessTier).toBe('limited') expect(body.countryCode).toBe('US') expect(body.countryBlockReason).toBe('anonymous_network') - expect(body.ipPrivacySignals).toEqual(['vpn', 'hosting']) + expect(body.ipPrivacySignals).toEqual(['hosting']) + }) + + test('returns country_blocked on GET for VPN/proxy privacy signals', async () => { + const sessionDeps = makeSessionDeps() + sessionDeps.rows.set('u1', { + user_id: 'u1', + status: 'active', + active_instance_id: 'old-inst', + model: DEFAULT_MODEL, + queued_at: new Date(), + admitted_at: new Date(), + expires_at: new Date(Date.now() + 60_000), + created_at: new Date(), + updated_at: new Date(), + }) + const resp = await getFreebuffSession( + makeReq('ok', { cfCountry: 'US' }), + makeDeps(sessionDeps, 'u1', { + getCountryAccess: async () => ({ + allowed: false, + countryCode: 'US', + blockReason: 'anonymous_network', + cfCountry: 'US', + geoipCountry: null, + ipPrivacy: { signals: ['res_proxy'] }, + hasClientIp: true, + clientIpHash: 'test-ip-hash', + }), + }), + ) + expect(resp.status).toBe(403) + const body = await resp.json() + expect(body.status).toBe('country_blocked') + expect(body.message).toContain('proxy') + expect(body.countryBlockReason).toBe('anonymous_network') + expect(body.ipPrivacySignals).toEqual(['res_proxy']) + expect(sessionDeps.rows.size).toBe(0) + }) + + test('returns country_blocked on GET for Cloudflare Tor', async () => { + const sessionDeps = makeSessionDeps() + sessionDeps.rows.set('u1', { + user_id: 'u1', + status: 'queued', + active_instance_id: 'old-inst', + model: DEFAULT_MODEL, + queued_at: new Date(), + admitted_at: null, + expires_at: null, + created_at: new Date(), + updated_at: new Date(), + }) + const resp = await getFreebuffSession( + makeReq('ok', { cfCountry: 'T1' }), + makeDeps(sessionDeps, 'u1'), + ) + expect(resp.status).toBe(403) + const body = await resp.json() + expect(body.status).toBe('country_blocked') + expect(body.message).toContain('Tor') + expect(body.countryBlockReason).toBe('anonymized_or_unknown_country') + expect(body.ipPrivacySignals).toEqual(['tor']) + expect(sessionDeps.rows.size).toBe(0) }) test('rechecks country on GET so access tier changes are visible immediately', async () => { diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 3b04c82623..2df9cae864 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -1,4 +1,5 @@ import { NextResponse } from 'next/server' +import { formatFreebuffHardBlockedMessage } from '@codebuff/common/util/freebuff-privacy' import { env } from '@codebuff/internal/env' import { @@ -6,7 +7,10 @@ import { getSessionState, requestSession, } from '@/server/free-session/public-api' -import { getFreeModeAccessTier } from '@/server/free-mode-country' +import { + getFreeModeAccessTier, + shouldHardBlockFreeModeAccess, +} from '@/server/free-mode-country' import { getCachedFreeModeCountryAccess } from '@/server/free-mode-country-access-cache' import { extractApiKeyFromHeader } from '@/util/auth' @@ -68,6 +72,30 @@ function toLimitedModeReason(countryAccess: FreeModeCountryAccess) { } } +function hardBlockedResponse(countryAccess: FreeModeCountryAccess) { + return NextResponse.json( + { + status: 'country_blocked', + message: formatFreebuffHardBlockedMessage(countryAccess.ipPrivacy?.signals), + countryCode: countryAccess.countryCode ?? 'UNKNOWN', + countryBlockReason: countryAccess.blockReason ?? undefined, + ipPrivacySignals: countryAccess.ipPrivacy?.signals ?? undefined, + }, + { status: 403 }, + ) +} + +async function endSessionForHardBlock( + auth: Extract, + deps: FreebuffSessionDeps, +): Promise { + await endUserSession({ + userId: auth.userId, + userEmail: auth.userEmail, + deps: deps.sessionDeps, + }) +} + /** Header the CLI uses to identify which instance is polling. Used by GET to * detect when another CLI on the same account has rotated the id. */ export const FREEBUFF_INSTANCE_HEADER = 'x-freebuff-instance-id' @@ -162,6 +190,10 @@ export async function postFreebuffSession( if ('error' in auth) return auth.error const countryAccess = await getCountryAccess(auth.userId, req, deps) + if (shouldHardBlockFreeModeAccess(countryAccess)) { + await endSessionForHardBlock(auth, deps) + return hardBlockedResponse(countryAccess) + } const accessTier = getFreeModeAccessTier(countryAccess) const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? '' @@ -209,6 +241,10 @@ export async function getFreebuffSession( try { const countryAccess = await getCountryAccess(auth.userId, req, deps) + if (shouldHardBlockFreeModeAccess(countryAccess)) { + await endSessionForHardBlock(auth, deps) + return hardBlockedResponse(countryAccess) + } const accessTier = getFreeModeAccessTier(countryAccess) const claimedInstanceId = diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index badf043774..02b66bae65 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -3,6 +3,7 @@ import { NextRequest } from 'next/server' import { getFreeModeCountryAccess, + shouldHardBlockFreeModeAccess, lookupIpinfoPrivacy, } from '../free-mode-country' @@ -57,7 +58,7 @@ describe('free mode country access', () => { expect(access.blockReason).toBe('country_not_allowed') }) - test('blocks anonymized Cloudflare country codes without falling back to IP geo', async () => { + test('hard-blocks Cloudflare Tor without falling back to IP geo', async () => { const access = await getFreeModeCountryAccess( makeReq({ 'cf-ipcountry': 'T1', @@ -68,6 +69,23 @@ describe('free mode country access', () => { expect(access.allowed).toBe(false) expect(access.countryCode).toBe(null) expect(access.blockReason).toBe('anonymized_or_unknown_country') + expect(access.ipPrivacy?.signals).toEqual(['tor']) + expect(shouldHardBlockFreeModeAccess(access)).toBe(true) + }) + + test('limits unknown Cloudflare country codes without falling back to IP geo', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'XX', + 'x-forwarded-for': '8.8.8.8', + }), + noAnonymousNetwork, + ) + expect(access.allowed).toBe(false) + expect(access.countryCode).toBe(null) + expect(access.blockReason).toBe('anonymized_or_unknown_country') + expect(access.ipPrivacy).toBe(null) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) }) test('blocks missing client location as unknown', async () => { @@ -158,7 +176,7 @@ describe('free mode country access', () => { expect(access.ipPrivacy?.signals).toEqual(['res_proxy']) }) - test('blocks allowlisted countries when IPinfo reports hosting or service', async () => { + test('limits allowlisted countries when IPinfo reports hosting or service', async () => { const access = await getFreeModeCountryAccess( makeReq({ 'cf-ipcountry': 'US', @@ -174,6 +192,39 @@ describe('free mode country access', () => { expect(access.allowed).toBe(false) expect(access.blockReason).toBe('anonymous_network') expect(access.ipPrivacy?.signals).toEqual(['hosting', 'service']) + expect(shouldHardBlockFreeModeAccess(access)).toBe(false) + }) + + test('hard-blocks only VPN, proxy, Tor, or residential proxy signals', async () => { + const vpnAccess = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + lookupIpPrivacy: async () => ({ + signals: ['vpn', 'hosting'], + }), + }, + ) + expect(vpnAccess.allowed).toBe(false) + expect(shouldHardBlockFreeModeAccess(vpnAccess)).toBe(true) + + const anonymousOnlyAccess = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + lookupIpPrivacy: async () => ({ + signals: ['anonymous', 'relay'], + }), + }, + ) + expect(anonymousOnlyAccess.allowed).toBe(false) + expect(shouldHardBlockFreeModeAccess(anonymousOnlyAccess)).toBe(false) }) test('allows allowlisted countries when privacy lookup finds no anonymous signals', async () => { diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 1eea833d32..af035594c5 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -1,6 +1,10 @@ import { createHmac } from 'node:crypto' import geoip from 'geoip-lite' +import { + FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS, + isFreebuffHardBlockedPrivacySignal, +} from '@codebuff/common/util/freebuff-privacy' import type { NextRequest } from 'next/server' import type { FreebuffAccessTier } from '@codebuff/common/constants/freebuff-models' @@ -37,7 +41,11 @@ export const FREE_MODE_ALLOWED_COUNTRIES = new Set([ 'IS', ]) -const CLOUDFLARE_ANONYMIZED_OR_UNKNOWN_COUNTRIES = new Set(['T1', 'XX']) +const CLOUDFLARE_TOR_COUNTRY = 'T1' +const CLOUDFLARE_ANONYMIZED_OR_UNKNOWN_COUNTRIES = new Set([ + CLOUDFLARE_TOR_COUNTRY, + 'XX', +]) export type FreeModeCountryBlockReason = FreebuffCountryBlockReason export type FreeModeIpPrivacySignal = FreebuffIpPrivacySignal @@ -101,17 +109,35 @@ const ipinfoPrivacyCache = new Map< { expiresAt: number; privacy: FreeModeIpPrivacy | null } >() -const FREE_MODE_BLOCKED_PRIVACY_SIGNALS = new Set([ +const FREE_MODE_LIMITED_PRIVACY_SIGNALS = new Set([ + ...FREEBUFF_HARD_BLOCKED_PRIVACY_SIGNALS, 'anonymous', - 'vpn', - 'proxy', - 'tor', 'relay', - 'res_proxy', 'hosting', 'service', ]) +export function hasHardBlockedPrivacySignal( + ipPrivacy: FreeModeIpPrivacy | null | undefined, +): boolean { + return ( + ipPrivacy?.signals.some(isFreebuffHardBlockedPrivacySignal) ?? false + ) +} + +export function shouldHardBlockFreeModeAccess( + countryAccess: Pick< + FreeModeCountryAccess, + 'blockReason' | 'cfCountry' | 'ipPrivacy' + >, +): boolean { + return ( + countryAccess.cfCountry === CLOUDFLARE_TOR_COUNTRY || + (countryAccess.blockReason === 'anonymous_network' && + hasHardBlockedPrivacySignal(countryAccess.ipPrivacy)) + ) +} + export function extractClientIp(req: NextRequest): string | undefined { const cfConnectingIp = req.headers.get('cf-connecting-ip')?.trim() if (cfConnectingIp) return cfConnectingIp @@ -255,7 +281,8 @@ export async function getFreeModeCountryAccess( blockReason: 'anonymized_or_unknown_country', cfCountry, geoipCountry: null, - ipPrivacy: null, + ipPrivacy: + cfCountry === CLOUDFLARE_TOR_COUNTRY ? { signals: ['tor'] } : null, hasClientIp: Boolean(clientIp), clientIpHash, } @@ -354,7 +381,7 @@ export async function getFreeModeCountryAccess( if ( ipPrivacy.signals.some((signal) => - FREE_MODE_BLOCKED_PRIVACY_SIGNALS.has(signal), + FREE_MODE_LIMITED_PRIVACY_SIGNALS.has(signal), ) ) { return {