Skip to content

Commit da1d012

Browse files
committed
Allow localhost free mode in dev
1 parent 37020fe commit da1d012

4 files changed

Lines changed: 76 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: process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
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: process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod',
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: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,14 @@ type FreeModeCountryAccessOptions = {
5656
fetch?: typeof globalThis.fetch
5757
ipinfoToken: string
5858
ipHashSecret?: string
59+
allowLocalhost?: boolean
60+
}
61+
62+
const LOCALHOST_IPS = new Set(['127.0.0.1', '::1', '::ffff:127.0.0.1'])
63+
64+
function isLocalhostIp(ip: string | undefined): boolean {
65+
if (!ip) return false
66+
return LOCALHOST_IPS.has(ip) || ip.startsWith('127.')
5967
}
6068

6169
type ResolvedCountryAccess = Omit<
@@ -183,6 +191,28 @@ export async function getFreeModeCountryAccess(
183191
const clientIp = extractClientIp(req)
184192
const clientIpHash = hashClientIp(clientIp, options.ipHashSecret)
185193

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

0 commit comments

Comments
 (0)