Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions web/src/app/api/v1/chat/completions/_post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -260,6 +260,7 @@ export async function postChatCompletions(params: {
fetch,
ipinfoToken: env.IPINFO_TOKEN,
ipHashSecret: env.NEXTAUTH_SECRET,
allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev',
})

logger.info(
Expand Down
1 change: 1 addition & 0 deletions web/src/app/api/v1/freebuff/session/_handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ async function getCountryAccess(
getFreeModeCountryAccess(req, {
ipinfoToken: env.IPINFO_TOKEN,
ipHashSecret: env.NEXTAUTH_SECRET,
allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev',
})
)
}
Expand Down
44 changes: 44 additions & 0 deletions web/src/server/__tests__/free-mode-country.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down
29 changes: 29 additions & 0 deletions web/src/server/free-mode-country.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,13 @@ type FreeModeCountryAccessOptions = {
fetch?: typeof globalThis.fetch
ipinfoToken: string
ipHashSecret?: string
allowLocalhost?: boolean
}

const LOCALHOST_IPS = new Set(['::1', '::ffff:127.0.0.1'])

function isLocalhostIp(ip: string): boolean {
return ip.startsWith('127.') || LOCALHOST_IPS.has(ip)
}

type ResolvedCountryAccess = Omit<
Expand Down Expand Up @@ -183,6 +190,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,
Expand Down
Loading