Skip to content

Commit c68b19d

Browse files
authored
Allow localhost free mode in dev (#564)
1 parent 37020fe commit c68b19d

4 files changed

Lines changed: 75 additions & 0 deletions

File tree

web/src/app/api/v1/chat/completions/_post.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ export async function postChatCompletions(params: {
260260
fetch,
261261
ipinfoToken: env.IPINFO_TOKEN,
262262
ipHashSecret: env.NEXTAUTH_SECRET,
263+
allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev',
263264
})
264265

265266
logger.info(

web/src/app/api/v1/freebuff/session/_handlers.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ async function getCountryAccess(
4444
getFreeModeCountryAccess(req, {
4545
ipinfoToken: env.IPINFO_TOKEN,
4646
ipHashSecret: env.NEXTAUTH_SECRET,
47+
allowLocalhost: env.NEXT_PUBLIC_CB_ENVIRONMENT === 'dev',
4748
})
4849
)
4950
}

web/src/server/__tests__/free-mode-country.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,50 @@ describe('free mode country access', () => {
260260
})
261261
})
262262

263+
test('allowLocalhost bypasses gating when no CF country and no client IP', async () => {
264+
const access = await getFreeModeCountryAccess(makeReq(), {
265+
ipinfoToken: 'test-token',
266+
allowLocalhost: true,
267+
})
268+
expect(access.allowed).toBe(true)
269+
expect(access.countryCode).toBe('US')
270+
expect(access.blockReason).toBe(null)
271+
expect(access.ipPrivacy?.signals).toEqual([])
272+
})
273+
274+
test('allowLocalhost bypasses gating for loopback client IPs', async () => {
275+
const access = await getFreeModeCountryAccess(
276+
makeReq({ 'x-forwarded-for': '127.0.0.1' }),
277+
{
278+
ipinfoToken: 'test-token',
279+
allowLocalhost: true,
280+
},
281+
)
282+
expect(access.allowed).toBe(true)
283+
expect(access.countryCode).toBe('US')
284+
expect(access.blockReason).toBe(null)
285+
})
286+
287+
test('allowLocalhost does not bypass when cf-ipcountry is set', async () => {
288+
const access = await getFreeModeCountryAccess(
289+
makeReq({ 'cf-ipcountry': 'FR' }),
290+
{
291+
ipinfoToken: 'test-token',
292+
allowLocalhost: true,
293+
},
294+
)
295+
expect(access.allowed).toBe(false)
296+
expect(access.blockReason).toBe('country_not_allowed')
297+
})
298+
299+
test('allowLocalhost off (default) keeps the strict missing-IP block', async () => {
300+
const access = await getFreeModeCountryAccess(makeReq(), {
301+
ipinfoToken: 'test-token',
302+
})
303+
expect(access.allowed).toBe(false)
304+
expect(access.blockReason).toBe('missing_client_ip')
305+
})
306+
263307
test('treats is_anonymous as blocking even when service is present', async () => {
264308
const fetch = async () =>
265309
Response.json({

web/src/server/free-mode-country.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,13 @@ type FreeModeCountryAccessOptions = {
5656
fetch?: typeof globalThis.fetch
5757
ipinfoToken: string
5858
ipHashSecret?: string
59+
allowLocalhost?: boolean
60+
}
61+
62+
const LOCALHOST_IPS = new Set(['::1', '::ffff:127.0.0.1'])
63+
64+
function isLocalhostIp(ip: string): boolean {
65+
return ip.startsWith('127.') || LOCALHOST_IPS.has(ip)
5966
}
6067

6168
type ResolvedCountryAccess = Omit<
@@ -183,6 +190,28 @@ export async function getFreeModeCountryAccess(
183190
const clientIp = extractClientIp(req)
184191
const clientIpHash = hashClientIp(clientIp, options.ipHashSecret)
185192

193+
// Dev-only bypass: when no Cloudflare country header is set and the request
194+
// is from loopback (or has no client IP at all), treat it as US-allowed so
195+
// local development doesn't require ipinfo or geoip resolution. In
196+
// production behind Cloudflare, cf-ipcountry is always set, so this branch
197+
// is unreachable.
198+
if (
199+
options.allowLocalhost &&
200+
!cfCountry &&
201+
(!clientIp || isLocalhostIp(clientIp))
202+
) {
203+
return {
204+
allowed: true,
205+
countryCode: 'US',
206+
blockReason: null,
207+
cfCountry: null,
208+
geoipCountry: null,
209+
ipPrivacy: { signals: [] },
210+
hasClientIp: Boolean(clientIp),
211+
clientIpHash,
212+
}
213+
}
214+
186215
if (cfCountry && CLOUDFLARE_ANONYMIZED_OR_UNKNOWN_COUNTRIES.has(cfCountry)) {
187216
return {
188217
allowed: false,

0 commit comments

Comments
 (0)