From 2c94b30130f1b0e1610b9d2662cd34414c6b73e0 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 26 Apr 2026 15:20:55 -0700 Subject: [PATCH 1/4] Block free mode VPN traffic --- docs/environment-variables.md | 1 + packages/internal/src/env-schema.ts | 23 ++- packages/internal/src/env.ts | 9 +- web/src/app/api/v1/chat/completions/_post.ts | 7 +- .../app/api/v1/freebuff/session/_handlers.ts | 14 +- web/src/app/api/v1/freebuff/session/route.ts | 9 +- .../__tests__/free-mode-country.test.ts | 109 ++++++++++- web/src/server/free-mode-country.ts | 173 +++++++++++++++--- 8 files changed, 300 insertions(+), 45 deletions(-) diff --git a/docs/environment-variables.md b/docs/environment-variables.md index 6514dba0f..a58b5ed98 100644 --- a/docs/environment-variables.md +++ b/docs/environment-variables.md @@ -5,6 +5,7 @@ - Public client env: `NEXT_PUBLIC_*` only, validated in `common/src/env-schema.ts` (used via `@codebuff/common/env`). - Server secrets: validated in `packages/internal/src/env-schema.ts` (used via `@codebuff/internal/env`). - Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase. +- `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic. ## Env DI Helpers diff --git a/packages/internal/src/env-schema.ts b/packages/internal/src/env-schema.ts index 98a874a7a..a8af80f06 100644 --- a/packages/internal/src/env-schema.ts +++ b/packages/internal/src/env-schema.ts @@ -12,6 +12,7 @@ export const serverEnvSchema = clientEnvSchema.extend({ LINKUP_API_KEY: z.string().min(1), CONTEXT7_API_KEY: z.string().optional(), GRAVITY_API_KEY: z.string().min(1), + IPINFO_TOKEN: z.string().min(1), // BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad. // Optional: when unset the Carbon provider returns no ad and callers fall // back to their cached ads / fallback content. `CVADC53U` is the public @@ -58,8 +59,16 @@ export const serverEnvSchema = clientEnvSchema.extend({ .enum(['true', 'false']) .default('false') .transform((v) => v === 'true'), - FREEBUFF_SESSION_LENGTH_MS: z.coerce.number().int().positive().default(60 * 60 * 1000), - FREEBUFF_SESSION_GRACE_MS: z.coerce.number().int().nonnegative().default(30 * 60 * 1000), + FREEBUFF_SESSION_LENGTH_MS: z.coerce + .number() + .int() + .positive() + .default(60 * 60 * 1000), + FREEBUFF_SESSION_GRACE_MS: z.coerce + .number() + .int() + .nonnegative() + .default(30 * 60 * 1000), }) export const serverEnvVars = serverEnvSchema.keyof().options export type ServerEnvVar = (typeof serverEnvVars)[number] @@ -87,6 +96,7 @@ export const serverProcessEnv: ServerInput = { LINKUP_API_KEY: process.env.LINKUP_API_KEY, CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY, GRAVITY_API_KEY: process.env.GRAVITY_API_KEY, + IPINFO_TOKEN: process.env.IPINFO_TOKEN, CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY, PORT: process.env.PORT, @@ -101,9 +111,12 @@ export const serverProcessEnv: ServerInput = { STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY, STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY, STRIPE_TEAM_FEE_PRICE_ID: process.env.STRIPE_TEAM_FEE_PRICE_ID, - STRIPE_SUBSCRIPTION_100_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_100_PRICE_ID, - STRIPE_SUBSCRIPTION_200_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID, - STRIPE_SUBSCRIPTION_500_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_500_PRICE_ID, + STRIPE_SUBSCRIPTION_100_PRICE_ID: + process.env.STRIPE_SUBSCRIPTION_100_PRICE_ID, + STRIPE_SUBSCRIPTION_200_PRICE_ID: + process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID, + STRIPE_SUBSCRIPTION_500_PRICE_ID: + process.env.STRIPE_SUBSCRIPTION_500_PRICE_ID, LOOPS_API_KEY: process.env.LOOPS_API_KEY, DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY, DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN, diff --git a/packages/internal/src/env.ts b/packages/internal/src/env.ts index b32f90564..d99483322 100644 --- a/packages/internal/src/env.ts +++ b/packages/internal/src/env.ts @@ -3,19 +3,23 @@ import { serverEnvSchema, serverProcessEnv } from './env-schema' // Only provide safe defaults in CI to avoid schema failures during tests // In local dev, missing env vars should fail fast so devs know to configure them const isCI = process.env.CI === 'true' || process.env.CI === '1' +const envInput = { ...serverProcessEnv } if (isCI) { const ensureEnvDefault = (key: string, value: string) => { if (!process.env[key]) { process.env[key] = value } + envInput[key as keyof typeof envInput] = process.env[key] } ensureEnvDefault('OPEN_ROUTER_API_KEY', 'test') ensureEnvDefault('OPENAI_API_KEY', 'test') ensureEnvDefault('ANTHROPIC_API_KEY', 'test') + ensureEnvDefault('FIREWORKS_API_KEY', 'test') ensureEnvDefault('LINKUP_API_KEY', 'test') ensureEnvDefault('GRAVITY_API_KEY', 'test') + ensureEnvDefault('IPINFO_TOKEN', 'test') ensureEnvDefault('PORT', '4242') ensureEnvDefault('DATABASE_URL', 'postgres://user:pass@localhost:5432/db') ensureEnvDefault('CODEBUFF_GITHUB_ID', 'test-id') @@ -26,6 +30,9 @@ if (isCI) { ensureEnvDefault('STRIPE_SECRET_KEY', 'sk_test_dummy') ensureEnvDefault('STRIPE_WEBHOOK_SECRET_KEY', 'whsec_dummy') ensureEnvDefault('STRIPE_TEAM_FEE_PRICE_ID', 'price_test') + ensureEnvDefault('STRIPE_SUBSCRIPTION_100_PRICE_ID', 'price_test_100') + ensureEnvDefault('STRIPE_SUBSCRIPTION_200_PRICE_ID', 'price_test_200') + ensureEnvDefault('STRIPE_SUBSCRIPTION_500_PRICE_ID', 'price_test_500') ensureEnvDefault('LOOPS_API_KEY', 'test') ensureEnvDefault('DISCORD_PUBLIC_KEY', 'test') ensureEnvDefault('DISCORD_BOT_TOKEN', 'test') @@ -46,4 +53,4 @@ if (process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod') { } } -export const env = serverEnvSchema.parse(serverProcessEnv) +export const env = serverEnvSchema.parse(envInput) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 426f65e18..84943dbf6 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -256,7 +256,10 @@ export async function postChatCompletions(params: { // For free mode requests, require a resolved allowlisted country. if (isFreeModeRequest) { - const countryAccess = getFreeModeCountryAccess(req) + const countryAccess = await getFreeModeCountryAccess(req, { + fetch, + ipinfoToken: env.IPINFO_TOKEN, + }) logger.info( { @@ -264,6 +267,7 @@ export async function postChatCompletions(params: { geoipResult: countryAccess.geoipCountry, resolvedCountry: countryAccess.countryCode, countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, }, 'Free mode country detection', @@ -277,6 +281,7 @@ export async function postChatCompletions(params: { error: 'free_mode_not_available_in_country', countryCode: countryAccess.countryCode, countryBlockReason: countryAccess.blockReason, + ipPrivacySignals: countryAccess.ipPrivacy?.signals, clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined, }, logger, diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 1ad7fea3c..c177019f9 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 { env } from '@codebuff/internal/env' import { endUserSession, @@ -22,8 +23,13 @@ import type { NextRequest } from 'next/server' * `country_blocked` status and would tight-poll on an unrecognized 200 * body — fall into their existing `!resp.ok` error path and back off on * the 10s error retry cadence. The new CLI parses the 403 body directly. */ -function countryBlockedResponse(req: NextRequest): NextResponse | null { - const countryAccess = getFreeModeCountryAccess(req) +async function countryBlockedResponse( + req: NextRequest, + deps: FreebuffSessionDeps, +): Promise { + const countryAccess = await getFreeModeCountryAccess(req, { + ipinfoToken: env.IPINFO_TOKEN, + }) if (countryAccess.allowed) return null return NextResponse.json( { @@ -126,7 +132,7 @@ export async function postFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const blocked = countryBlockedResponse(req) + const blocked = await countryBlockedResponse(req, deps) if (blocked) return blocked const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? '' @@ -170,7 +176,7 @@ export async function getFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const blocked = countryBlockedResponse(req) + const blocked = await countryBlockedResponse(req, deps) if (blocked) return blocked try { diff --git a/web/src/app/api/v1/freebuff/session/route.ts b/web/src/app/api/v1/freebuff/session/route.ts index cf5802afd..3bd014d35 100644 --- a/web/src/app/api/v1/freebuff/session/route.ts +++ b/web/src/app/api/v1/freebuff/session/route.ts @@ -9,12 +9,17 @@ import { logger } from '@/util/logger' import type { NextRequest } from 'next/server' +const freebuffSessionDeps = { + getUserInfoFromApiKey, + logger, +} + export async function GET(req: NextRequest) { - return getFreebuffSession(req, { getUserInfoFromApiKey, logger }) + return getFreebuffSession(req, freebuffSessionDeps) } export async function POST(req: NextRequest) { - return postFreebuffSession(req, { getUserInfoFromApiKey, logger }) + return postFreebuffSession(req, freebuffSessionDeps) } export async function DELETE(req: NextRequest) { diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index db632b5ad..b5081f2ed 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from 'bun:test' import { NextRequest } from 'next/server' -import { getFreeModeCountryAccess } from '../free-mode-country' +import { + getFreeModeCountryAccess, + lookupIpinfoPrivacy, +} from '../free-mode-country' function makeReq(headers: Record = {}): NextRequest { return new NextRequest('http://localhost:3000/api/v1/chat/completions', { @@ -9,37 +12,125 @@ function makeReq(headers: Record = {}): NextRequest { }) } +const noAnonymousNetwork = { + ipinfoToken: 'test-token', + lookupIpPrivacy: async () => ({ signals: [] }), +} + describe('free mode country access', () => { - test('allows allowlisted Cloudflare countries', () => { - const access = getFreeModeCountryAccess(makeReq({ 'cf-ipcountry': 'us' })) + test('allows allowlisted Cloudflare countries', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ 'cf-ipcountry': 'us' }), + noAnonymousNetwork, + ) expect(access.allowed).toBe(true) expect(access.countryCode).toBe('US') expect(access.blockReason).toBe(null) }) - test('blocks countries outside the allowlist', () => { - const access = getFreeModeCountryAccess(makeReq({ 'cf-ipcountry': 'FR' })) + test('blocks countries outside the allowlist', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ 'cf-ipcountry': 'FR' }), + noAnonymousNetwork, + ) expect(access.allowed).toBe(false) expect(access.countryCode).toBe('FR') expect(access.blockReason).toBe('country_not_allowed') }) - test('blocks anonymized Cloudflare country codes without falling back to IP geo', () => { - const access = getFreeModeCountryAccess( + test('blocks anonymized Cloudflare country codes without falling back to IP geo', async () => { + const access = await getFreeModeCountryAccess( makeReq({ 'cf-ipcountry': 'T1', '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') }) - test('blocks missing client location as unknown', () => { - const access = getFreeModeCountryAccess(makeReq()) + test('blocks missing client location as unknown', async () => { + const access = await getFreeModeCountryAccess(makeReq(), noAnonymousNetwork) expect(access.allowed).toBe(false) expect(access.countryCode).toBe(null) expect(access.blockReason).toBe('missing_client_ip') }) + + test('blocks allowlisted countries when the client IP is an anonymous network', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + lookupIpPrivacy: async () => ({ + signals: ['vpn'], + }), + }, + ) + expect(access.allowed).toBe(false) + expect(access.countryCode).toBe('US') + expect(access.blockReason).toBe('anonymous_network') + expect(access.ipPrivacy?.signals).toEqual(['vpn']) + }) + + test('allows allowlisted countries when privacy lookup finds no anonymous signals', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + lookupIpPrivacy: async () => ({ + signals: [], + }), + }, + ) + expect(access.allowed).toBe(true) + expect(access.blockReason).toBe(null) + }) + + test('allows allowlisted countries when privacy lookup fails', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + lookupIpPrivacy: async () => { + throw new Error('provider unavailable') + }, + }, + ) + expect(access.allowed).toBe(true) + expect(access.blockReason).toBe(null) + expect(access.ipPrivacy).toBe(null) + }) + + test('parses IPinfo privacy signals', async () => { + const fetch = async () => + Response.json({ + vpn: true, + proxy: false, + tor: true, + relay: false, + hosting: true, + service: 'Example VPN', + }) + + const privacy = await lookupIpinfoPrivacy({ + ip: '203.0.113.10', + token: 'test-token', + fetch: fetch as unknown as typeof globalThis.fetch, + }) + + expect(privacy).toEqual({ + signals: ['vpn', 'tor', 'hosting', 'service'], + }) + }) }) diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 684511c9b..c19bd1a44 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -26,18 +26,55 @@ const CLOUDFLARE_ANONYMIZED_OR_UNKNOWN_COUNTRIES = new Set(['T1', 'XX']) export type FreeModeCountryBlockReason = | 'country_not_allowed' | 'anonymized_or_unknown_country' + | 'anonymous_network' | 'missing_client_ip' | 'unresolved_client_ip' +export type FreeModeIpPrivacySignal = + | 'vpn' + | 'proxy' + | 'tor' + | 'relay' + | 'hosting' + | 'service' + +export type FreeModeIpPrivacy = { + signals: FreeModeIpPrivacySignal[] +} + export type FreeModeCountryAccess = { allowed: boolean countryCode: string | null blockReason: FreeModeCountryBlockReason | null cfCountry: string | null geoipCountry: string | null + ipPrivacy: FreeModeIpPrivacy | null hasClientIp: boolean } +export type LookupIpPrivacyFn = ( + ip: string, +) => Promise + +type FreeModeCountryAccessOptions = { + lookupIpPrivacy?: LookupIpPrivacyFn + fetch?: typeof globalThis.fetch + ipinfoToken: string +} + +type ResolvedCountryAccess = Omit< + FreeModeCountryAccess, + 'allowed' | 'blockReason' | 'ipPrivacy' | 'countryCode' +> & { + countryCode: string +} + +const IPINFO_PRIVACY_CACHE_TTL_MS = 30 * 60 * 1000 +const ipinfoPrivacyCache = new Map< + string, + { expiresAt: number; privacy: FreeModeIpPrivacy | null } +>() + export function extractClientIp(req: NextRequest): string | undefined { const forwardedFor = req.headers.get('x-forwarded-for') if (forwardedFor) { @@ -46,9 +83,76 @@ export function extractClientIp(req: NextRequest): string | undefined { return req.headers.get('x-real-ip') ?? undefined } -export function getFreeModeCountryAccess( +function privacySignalsFromIpinfo( + data: Record, +): FreeModeIpPrivacySignal[] { + const signals: FreeModeIpPrivacySignal[] = [] + if (data.vpn === true) signals.push('vpn') + if (data.proxy === true) signals.push('proxy') + if (data.tor === true) signals.push('tor') + if (data.relay === true) signals.push('relay') + if (data.hosting === true) signals.push('hosting') + if ( + data.service === true || + (typeof data.service === 'string' && data.service.length > 0) + ) { + signals.push('service') + } + return signals +} + +export async function lookupIpinfoPrivacy(params: { + ip: string + token: string + fetch: typeof globalThis.fetch +}): Promise { + const cached = ipinfoPrivacyCache.get(params.ip) + if (cached && cached.expiresAt > Date.now()) { + return cached.privacy + } + + const response = await params.fetch( + `https://ipinfo.io/${encodeURIComponent(params.ip)}/privacy?token=${encodeURIComponent(params.token)}`, + ) + if (!response.ok) { + return null + } + + const data = (await response.json()) as Record + const signals = privacySignalsFromIpinfo(data) + const privacy = { + signals, + } + ipinfoPrivacyCache.set(params.ip, { + expiresAt: Date.now() + IPINFO_PRIVACY_CACHE_TTL_MS, + privacy, + }) + return privacy +} + +async function getIpPrivacy( + clientIp: string | undefined, + options: FreeModeCountryAccessOptions, +): Promise { + if (!clientIp) return null + try { + if (options.lookupIpPrivacy) { + return await options.lookupIpPrivacy(clientIp) + } + return await lookupIpinfoPrivacy({ + ip: clientIp, + token: options.ipinfoToken, + fetch: options.fetch ?? globalThis.fetch, + }) + } catch { + return null + } +} + +export async function getFreeModeCountryAccess( req: NextRequest, -): FreeModeCountryAccess { + options: FreeModeCountryAccessOptions, +): Promise { const cfCountry = req.headers.get('cf-ipcountry')?.toUpperCase() ?? null const clientIp = extractClientIp(req) @@ -59,52 +163,75 @@ export function getFreeModeCountryAccess( blockReason: 'anonymized_or_unknown_country', cfCountry, geoipCountry: null, + ipPrivacy: null, hasClientIp: Boolean(clientIp), } } + let baseAccess: ResolvedCountryAccess + if (cfCountry) { - const allowed = FREE_MODE_ALLOWED_COUNTRIES.has(cfCountry) - return { - allowed, + baseAccess = { countryCode: cfCountry, - blockReason: allowed ? null : 'country_not_allowed', cfCountry, geoipCountry: null, hasClientIp: Boolean(clientIp), } - } - - if (!clientIp) { + } else if (!clientIp) { return { allowed: false, countryCode: null, blockReason: 'missing_client_ip', cfCountry: null, geoipCountry: null, + ipPrivacy: null, hasClientIp: false, } + } else { + const geoipCountry = geoip.lookup(clientIp)?.country ?? null + if (!geoipCountry) { + return { + allowed: false, + countryCode: null, + blockReason: 'unresolved_client_ip', + cfCountry: null, + geoipCountry: null, + ipPrivacy: null, + hasClientIp: true, + } + } + + baseAccess = { + countryCode: geoipCountry, + cfCountry: null, + geoipCountry, + hasClientIp: true, + } } - const geoipCountry = geoip.lookup(clientIp)?.country ?? null - if (!geoipCountry) { + if (!FREE_MODE_ALLOWED_COUNTRIES.has(baseAccess.countryCode)) { return { + ...baseAccess, allowed: false, - countryCode: null, - blockReason: 'unresolved_client_ip', - cfCountry: null, - geoipCountry: null, - hasClientIp: true, + blockReason: 'country_not_allowed', + ipPrivacy: null, + } + } + + const ipPrivacy = await getIpPrivacy(clientIp, options) + if (ipPrivacy?.signals.length) { + return { + ...baseAccess, + allowed: false, + blockReason: 'anonymous_network', + ipPrivacy, } } - const allowed = FREE_MODE_ALLOWED_COUNTRIES.has(geoipCountry) return { - allowed, - countryCode: geoipCountry, - blockReason: allowed ? null : 'country_not_allowed', - cfCountry: null, - geoipCountry, - hasClientIp: true, + ...baseAccess, + allowed: true, + blockReason: null, + ipPrivacy, } } From 0341a78d825dc091bd0a48504a327cdbd0626630 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 26 Apr 2026 15:39:32 -0700 Subject: [PATCH 2/4] Address VPN gate review feedback --- .../completions/__tests__/completions.test.ts | 1 + .../session/__tests__/session.test.ts | 5 +- .../app/api/v1/freebuff/session/_handlers.ts | 5 +- .../__tests__/free-mode-country.test.ts | 29 +++++++++++- web/src/server/free-mode-country.ts | 47 +++++++++++++++++-- 5 files changed, 77 insertions(+), 10 deletions(-) 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 3e4a1149d..f12362ab6 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 @@ -69,6 +69,7 @@ describe('/api/v1/chat/completions POST endpoint', () => { const allowedFreeModeHeaders = (apiKey: string) => ({ Authorization: `Bearer ${apiKey}`, 'cf-ipcountry': 'US', + 'cf-connecting-ip': '203.0.113.10', }) beforeEach(() => { 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 676dea44f..a7eaaa7cd 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 @@ -27,7 +27,10 @@ function makeReq( if (apiKey) headers.set('Authorization', `Bearer ${apiKey}`) if (opts.instanceId) headers.set(FREEBUFF_INSTANCE_HEADER, opts.instanceId) const cfCountry = opts.cfCountry === null ? null : (opts.cfCountry ?? 'US') - if (cfCountry) headers.set('cf-ipcountry', cfCountry) + if (cfCountry) { + headers.set('cf-ipcountry', cfCountry) + headers.set('cf-connecting-ip', '203.0.113.10') + } if (opts.model) headers.set(FREEBUFF_MODEL_HEADER, opts.model) return { headers, diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index c177019f9..716a8a3c2 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -25,7 +25,6 @@ import type { NextRequest } from 'next/server' * the 10s error retry cadence. The new CLI parses the 403 body directly. */ async function countryBlockedResponse( req: NextRequest, - deps: FreebuffSessionDeps, ): Promise { const countryAccess = await getFreeModeCountryAccess(req, { ipinfoToken: env.IPINFO_TOKEN, @@ -132,7 +131,7 @@ export async function postFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const blocked = await countryBlockedResponse(req, deps) + const blocked = await countryBlockedResponse(req) if (blocked) return blocked const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? '' @@ -176,7 +175,7 @@ export async function getFreebuffSession( const auth = await resolveUser(req, deps) if ('error' in auth) return auth.error - const blocked = await countryBlockedResponse(req, deps) + const blocked = await countryBlockedResponse(req) if (blocked) return blocked try { diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index b5081f2ed..9eb440138 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -20,7 +20,10 @@ const noAnonymousNetwork = { describe('free mode country access', () => { test('allows allowlisted Cloudflare countries', async () => { const access = await getFreeModeCountryAccess( - makeReq({ 'cf-ipcountry': 'us' }), + makeReq({ + 'cf-ipcountry': 'us', + 'cf-connecting-ip': '203.0.113.10', + }), noAnonymousNetwork, ) expect(access.allowed).toBe(true) @@ -58,6 +61,30 @@ describe('free mode country access', () => { expect(access.blockReason).toBe('missing_client_ip') }) + test('blocks allowlisted Cloudflare countries when client IP is missing', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ 'cf-ipcountry': 'US' }), + noAnonymousNetwork, + ) + expect(access.allowed).toBe(false) + expect(access.countryCode).toBe(null) + expect(access.blockReason).toBe('missing_client_ip') + expect(access.cfCountry).toBe('US') + }) + + test('uses CF-Connecting-IP as a client IP fallback', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'cf-connecting-ip': '203.0.113.10', + }), + noAnonymousNetwork, + ) + expect(access.allowed).toBe(true) + expect(access.countryCode).toBe('US') + expect(access.hasClientIp).toBe(true) + }) + test('blocks allowlisted countries when the client IP is an anonymous network', async () => { const access = await getFreeModeCountryAccess( makeReq({ diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index c19bd1a44..617a3c3d0 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -70,6 +70,7 @@ type ResolvedCountryAccess = Omit< } const IPINFO_PRIVACY_CACHE_TTL_MS = 30 * 60 * 1000 +const IPINFO_PRIVACY_CACHE_MAX_ENTRIES = 5000 const ipinfoPrivacyCache = new Map< string, { expiresAt: number; privacy: FreeModeIpPrivacy | null } @@ -80,7 +81,34 @@ export function extractClientIp(req: NextRequest): string | undefined { if (forwardedFor) { return forwardedFor.split(',')[0].trim() } - return req.headers.get('x-real-ip') ?? undefined + return ( + req.headers.get('cf-connecting-ip') ?? + req.headers.get('x-real-ip') ?? + undefined + ) +} + +function setIpinfoPrivacyCache( + ip: string, + privacy: FreeModeIpPrivacy | null, +): void { + const now = Date.now() + for (const [cachedIp, cached] of ipinfoPrivacyCache) { + if (cached.expiresAt <= now) { + ipinfoPrivacyCache.delete(cachedIp) + } + } + + while (ipinfoPrivacyCache.size >= IPINFO_PRIVACY_CACHE_MAX_ENTRIES) { + const oldestIp = ipinfoPrivacyCache.keys().next().value + if (!oldestIp) break + ipinfoPrivacyCache.delete(oldestIp) + } + + ipinfoPrivacyCache.set(ip, { + expiresAt: now + IPINFO_PRIVACY_CACHE_TTL_MS, + privacy, + }) } function privacySignalsFromIpinfo( @@ -123,10 +151,7 @@ export async function lookupIpinfoPrivacy(params: { const privacy = { signals, } - ipinfoPrivacyCache.set(params.ip, { - expiresAt: Date.now() + IPINFO_PRIVACY_CACHE_TTL_MS, - privacy, - }) + setIpinfoPrivacyCache(params.ip, privacy) return privacy } @@ -218,6 +243,18 @@ export async function getFreeModeCountryAccess( } } + if (!clientIp) { + return { + allowed: false, + countryCode: null, + blockReason: 'missing_client_ip', + cfCountry, + geoipCountry: null, + ipPrivacy: null, + hasClientIp: false, + } + } + const ipPrivacy = await getIpPrivacy(clientIp, options) if (ipPrivacy?.signals.length) { return { From bf4f4498eddf718868667516b9625feda6e5a267 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 26 Apr 2026 15:50:42 -0700 Subject: [PATCH 3/4] Fix free mode country cache test isolation --- web/src/server/__tests__/free-mode-country.test.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index 9eb440138..ec8fd213f 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -17,6 +17,8 @@ const noAnonymousNetwork = { lookupIpPrivacy: async () => ({ signals: [] }), } +const IPINFO_PRIVACY_TEST_IP = '198.51.100.42' + describe('free mode country access', () => { test('allows allowlisted Cloudflare countries', async () => { const access = await getFreeModeCountryAccess( @@ -151,7 +153,7 @@ describe('free mode country access', () => { }) const privacy = await lookupIpinfoPrivacy({ - ip: '203.0.113.10', + ip: IPINFO_PRIVACY_TEST_IP, token: 'test-token', fetch: fetch as unknown as typeof globalThis.fetch, }) From 724d90838075a8f0c5fdb0d2049a6aac0bc987e9 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Sun, 26 Apr 2026 15:58:58 -0700 Subject: [PATCH 4/4] Use IPinfo Max anonymous signals --- .../__tests__/free-mode-country.test.ts | 60 ++++++++++++++++--- web/src/server/free-mode-country.ts | 24 ++++++-- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index ec8fd213f..ad3e57a5a 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -106,6 +106,24 @@ describe('free mode country access', () => { expect(access.ipPrivacy?.signals).toEqual(['vpn']) }) + test('blocks allowlisted countries when IPinfo reports a residential proxy', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ + 'cf-ipcountry': 'US', + 'x-forwarded-for': '203.0.113.10', + }), + { + ipinfoToken: 'test-token', + lookupIpPrivacy: async () => ({ + signals: ['res_proxy'], + }), + }, + ) + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('anonymous_network') + expect(access.ipPrivacy?.signals).toEqual(['res_proxy']) + }) + test('allows allowlisted countries when privacy lookup finds no anonymous signals', async () => { const access = await getFreeModeCountryAccess( makeReq({ @@ -141,25 +159,49 @@ describe('free mode country access', () => { expect(access.ipPrivacy).toBe(null) }) - test('parses IPinfo privacy signals', async () => { + test('parses IPinfo Max anonymous signals', async () => { + let requestedUrl = '' + const fetch = async (url: string | URL | Request) => { + requestedUrl = String(url) + return Response.json({ + anonymous: { + is_proxy: false, + is_relay: true, + is_tor: true, + is_vpn: false, + is_res_proxy: true, + }, + is_anonymous: true, + is_hosting: true, + }) + } + + const privacy = await lookupIpinfoPrivacy({ + ip: IPINFO_PRIVACY_TEST_IP, + token: 'test-token', + fetch: fetch as unknown as typeof globalThis.fetch, + }) + + expect(requestedUrl).toContain('https://api.ipinfo.io/lookup/') + expect(privacy).toEqual({ + signals: ['tor', 'relay', 'res_proxy', 'hosting'], + }) + }) + + test('blocks generic IPinfo anonymous results without a specific signal', async () => { const fetch = async () => Response.json({ - vpn: true, - proxy: false, - tor: true, - relay: false, - hosting: true, - service: 'Example VPN', + is_anonymous: true, }) const privacy = await lookupIpinfoPrivacy({ - ip: IPINFO_PRIVACY_TEST_IP, + ip: '198.51.100.43', token: 'test-token', fetch: fetch as unknown as typeof globalThis.fetch, }) expect(privacy).toEqual({ - signals: ['vpn', 'tor', 'hosting', 'service'], + signals: ['anonymous'], }) }) }) diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 617a3c3d0..55490a6e1 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -31,10 +31,12 @@ export type FreeModeCountryBlockReason = | 'unresolved_client_ip' export type FreeModeIpPrivacySignal = + | 'anonymous' | 'vpn' | 'proxy' | 'tor' | 'relay' + | 'res_proxy' | 'hosting' | 'service' @@ -114,18 +116,28 @@ function setIpinfoPrivacyCache( function privacySignalsFromIpinfo( data: Record, ): FreeModeIpPrivacySignal[] { + const anonymous = + data.anonymous && typeof data.anonymous === 'object' + ? (data.anonymous as Record) + : {} const signals: FreeModeIpPrivacySignal[] = [] - if (data.vpn === true) signals.push('vpn') - if (data.proxy === true) signals.push('proxy') - if (data.tor === true) signals.push('tor') - if (data.relay === true) signals.push('relay') - if (data.hosting === true) signals.push('hosting') + if (data.vpn === true || anonymous.is_vpn === true) signals.push('vpn') + if (data.proxy === true || anonymous.is_proxy === true) signals.push('proxy') + if (data.tor === true || anonymous.is_tor === true) signals.push('tor') + if (data.relay === true || anonymous.is_relay === true) signals.push('relay') + if (anonymous.is_res_proxy === true) signals.push('res_proxy') + if (data.hosting === true || data.is_hosting === true) { + signals.push('hosting') + } if ( data.service === true || (typeof data.service === 'string' && data.service.length > 0) ) { signals.push('service') } + if (signals.length === 0 && data.is_anonymous === true) { + signals.push('anonymous') + } return signals } @@ -140,7 +152,7 @@ export async function lookupIpinfoPrivacy(params: { } const response = await params.fetch( - `https://ipinfo.io/${encodeURIComponent(params.ip)}/privacy?token=${encodeURIComponent(params.token)}`, + `https://api.ipinfo.io/lookup/${encodeURIComponent(params.ip)}?token=${encodeURIComponent(params.token)}`, ) if (!response.ok) { return null