From da1d0123af44f0e44333873ea2ada6ebd9846c8b Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 28 Apr 2026 18:00:44 -0700 Subject: [PATCH 1/3] Allow localhost free mode in dev --- web/src/app/api/v1/chat/completions/_post.ts | 1 + .../app/api/v1/freebuff/session/_handlers.ts | 1 + .../__tests__/free-mode-country.test.ts | 44 +++++++++++++++++++ web/src/server/free-mode-country.ts | 30 +++++++++++++ 4 files changed, 76 insertions(+) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index b49a30aba..81ba49004 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -260,6 +260,7 @@ export async function postChatCompletions(params: { fetch, ipinfoToken: env.IPINFO_TOKEN, ipHashSecret: env.NEXTAUTH_SECRET, + allowLocalhost: process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', }) logger.info( diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 7c6442f20..0c671b772 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -44,6 +44,7 @@ async function getCountryAccess( getFreeModeCountryAccess(req, { ipinfoToken: env.IPINFO_TOKEN, ipHashSecret: env.NEXTAUTH_SECRET, + allowLocalhost: process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', }) ) } diff --git a/web/src/server/__tests__/free-mode-country.test.ts b/web/src/server/__tests__/free-mode-country.test.ts index 277e2dd05..3523b1e77 100644 --- a/web/src/server/__tests__/free-mode-country.test.ts +++ b/web/src/server/__tests__/free-mode-country.test.ts @@ -260,6 +260,50 @@ describe('free mode country access', () => { }) }) + test('allowLocalhost bypasses gating when no CF country and no client IP', async () => { + const access = await getFreeModeCountryAccess(makeReq(), { + ipinfoToken: 'test-token', + allowLocalhost: true, + }) + expect(access.allowed).toBe(true) + expect(access.countryCode).toBe('US') + expect(access.blockReason).toBe(null) + expect(access.ipPrivacy?.signals).toEqual([]) + }) + + test('allowLocalhost bypasses gating for loopback client IPs', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ 'x-forwarded-for': '127.0.0.1' }), + { + ipinfoToken: 'test-token', + allowLocalhost: true, + }, + ) + expect(access.allowed).toBe(true) + expect(access.countryCode).toBe('US') + expect(access.blockReason).toBe(null) + }) + + test('allowLocalhost does not bypass when cf-ipcountry is set', async () => { + const access = await getFreeModeCountryAccess( + makeReq({ 'cf-ipcountry': 'FR' }), + { + ipinfoToken: 'test-token', + allowLocalhost: true, + }, + ) + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('country_not_allowed') + }) + + test('allowLocalhost off (default) keeps the strict missing-IP block', async () => { + const access = await getFreeModeCountryAccess(makeReq(), { + ipinfoToken: 'test-token', + }) + expect(access.allowed).toBe(false) + expect(access.blockReason).toBe('missing_client_ip') + }) + test('treats is_anonymous as blocking even when service is present', async () => { const fetch = async () => Response.json({ diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 4ad90219c..28db6f5f1 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -56,6 +56,14 @@ type FreeModeCountryAccessOptions = { fetch?: typeof globalThis.fetch ipinfoToken: string ipHashSecret?: string + allowLocalhost?: boolean +} + +const LOCALHOST_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']) + +function isLocalhostIp(ip: string | undefined): boolean { + if (!ip) return false + return LOCALHOST_IPS.has(ip) || ip.startsWith('127.') } type ResolvedCountryAccess = Omit< @@ -183,6 +191,28 @@ export async function getFreeModeCountryAccess( const clientIp = extractClientIp(req) const clientIpHash = hashClientIp(clientIp, options.ipHashSecret) + // Dev-only bypass: when no Cloudflare country header is set and the request + // is from loopback (or has no client IP at all), treat it as US-allowed so + // local development doesn't require ipinfo or geoip resolution. In + // production behind Cloudflare, cf-ipcountry is always set, so this branch + // is unreachable. + if ( + options.allowLocalhost && + !cfCountry && + (!clientIp || isLocalhostIp(clientIp)) + ) { + return { + allowed: true, + countryCode: 'US', + blockReason: null, + cfCountry: null, + geoipCountry: null, + ipPrivacy: { signals: [] }, + hasClientIp: Boolean(clientIp), + clientIpHash, + } + } + if (cfCountry && CLOUDFLARE_ANONYMIZED_OR_UNKNOWN_COUNTRIES.has(cfCountry)) { return { allowed: false, From 597fa4dd2c45e491ff99458864682ca8d6dd28c1 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 28 Apr 2026 18:17:05 -0700 Subject: [PATCH 2/3] Limit localhost free mode bypass to dev --- web/src/app/api/v1/chat/completions/_post.ts | 2 +- web/src/app/api/v1/freebuff/session/_handlers.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/web/src/app/api/v1/chat/completions/_post.ts b/web/src/app/api/v1/chat/completions/_post.ts index 81ba49004..5f9c2b7e6 100644 --- a/web/src/app/api/v1/chat/completions/_post.ts +++ b/web/src/app/api/v1/chat/completions/_post.ts @@ -260,7 +260,7 @@ export async function postChatCompletions(params: { fetch, ipinfoToken: env.IPINFO_TOKEN, ipHashSecret: env.NEXTAUTH_SECRET, - allowLocalhost: process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', + allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', }) logger.info( diff --git a/web/src/app/api/v1/freebuff/session/_handlers.ts b/web/src/app/api/v1/freebuff/session/_handlers.ts index 0c671b772..05c120677 100644 --- a/web/src/app/api/v1/freebuff/session/_handlers.ts +++ b/web/src/app/api/v1/freebuff/session/_handlers.ts @@ -44,7 +44,7 @@ async function getCountryAccess( getFreeModeCountryAccess(req, { ipinfoToken: env.IPINFO_TOKEN, ipHashSecret: env.NEXTAUTH_SECRET, - allowLocalhost: process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod', + allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev', }) ) } From 634d4c7b24bab35f4f19948a40534b8305788466 Mon Sep 17 00:00:00 2001 From: James Grugett Date: Tue, 28 Apr 2026 18:44:24 -0700 Subject: [PATCH 3/3] Simplify localhost IP detection --- web/src/server/free-mode-country.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/web/src/server/free-mode-country.ts b/web/src/server/free-mode-country.ts index 28db6f5f1..c5454cf13 100644 --- a/web/src/server/free-mode-country.ts +++ b/web/src/server/free-mode-country.ts @@ -59,11 +59,10 @@ type FreeModeCountryAccessOptions = { allowLocalhost?: boolean } -const LOCALHOST_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1']) +const LOCALHOST_IPS = new Set(['::1', '::ffff:127.0.0.1']) -function isLocalhostIp(ip: string | undefined): boolean { - if (!ip) return false - return LOCALHOST_IPS.has(ip) || ip.startsWith('127.') +function isLocalhostIp(ip: string): boolean { + return ip.startsWith('127.') || LOCALHOST_IPS.has(ip) } type ResolvedCountryAccess = Omit<