Skip to content

Commit 2c94b30

Browse files
committed
Block free mode VPN traffic
1 parent 35819f6 commit 2c94b30

8 files changed

Lines changed: 300 additions & 45 deletions

File tree

docs/environment-variables.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
- Public client env: `NEXT_PUBLIC_*` only, validated in `common/src/env-schema.ts` (used via `@codebuff/common/env`).
66
- Server secrets: validated in `packages/internal/src/env-schema.ts` (used via `@codebuff/internal/env`).
77
- Runtime/OS env: pass typed snapshots instead of reading `process.env` throughout the codebase.
8+
- `IPINFO_TOKEN` is required; free-mode country gating uses it to check IPinfo privacy signals for VPN/proxy/Tor/relay/hosting traffic.
89

910
## Env DI Helpers
1011

packages/internal/src/env-schema.ts

Lines changed: 18 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export const serverEnvSchema = clientEnvSchema.extend({
1212
LINKUP_API_KEY: z.string().min(1),
1313
CONTEXT7_API_KEY: z.string().optional(),
1414
GRAVITY_API_KEY: z.string().min(1),
15+
IPINFO_TOKEN: z.string().min(1),
1516
// BuySellAds (Carbon) zone key used for the Freebuff waiting-room ad.
1617
// Optional: when unset the Carbon provider returns no ad and callers fall
1718
// back to their cached ads / fallback content. `CVADC53U` is the public
@@ -58,8 +59,16 @@ export const serverEnvSchema = clientEnvSchema.extend({
5859
.enum(['true', 'false'])
5960
.default('false')
6061
.transform((v) => v === 'true'),
61-
FREEBUFF_SESSION_LENGTH_MS: z.coerce.number().int().positive().default(60 * 60 * 1000),
62-
FREEBUFF_SESSION_GRACE_MS: z.coerce.number().int().nonnegative().default(30 * 60 * 1000),
62+
FREEBUFF_SESSION_LENGTH_MS: z.coerce
63+
.number()
64+
.int()
65+
.positive()
66+
.default(60 * 60 * 1000),
67+
FREEBUFF_SESSION_GRACE_MS: z.coerce
68+
.number()
69+
.int()
70+
.nonnegative()
71+
.default(30 * 60 * 1000),
6372
})
6473
export const serverEnvVars = serverEnvSchema.keyof().options
6574
export type ServerEnvVar = (typeof serverEnvVars)[number]
@@ -87,6 +96,7 @@ export const serverProcessEnv: ServerInput = {
8796
LINKUP_API_KEY: process.env.LINKUP_API_KEY,
8897
CONTEXT7_API_KEY: process.env.CONTEXT7_API_KEY,
8998
GRAVITY_API_KEY: process.env.GRAVITY_API_KEY,
99+
IPINFO_TOKEN: process.env.IPINFO_TOKEN,
90100
CARBON_ZONE_KEY: process.env.CARBON_ZONE_KEY,
91101
PORT: process.env.PORT,
92102

@@ -101,9 +111,12 @@ export const serverProcessEnv: ServerInput = {
101111
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
102112
STRIPE_WEBHOOK_SECRET_KEY: process.env.STRIPE_WEBHOOK_SECRET_KEY,
103113
STRIPE_TEAM_FEE_PRICE_ID: process.env.STRIPE_TEAM_FEE_PRICE_ID,
104-
STRIPE_SUBSCRIPTION_100_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_100_PRICE_ID,
105-
STRIPE_SUBSCRIPTION_200_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID,
106-
STRIPE_SUBSCRIPTION_500_PRICE_ID: process.env.STRIPE_SUBSCRIPTION_500_PRICE_ID,
114+
STRIPE_SUBSCRIPTION_100_PRICE_ID:
115+
process.env.STRIPE_SUBSCRIPTION_100_PRICE_ID,
116+
STRIPE_SUBSCRIPTION_200_PRICE_ID:
117+
process.env.STRIPE_SUBSCRIPTION_200_PRICE_ID,
118+
STRIPE_SUBSCRIPTION_500_PRICE_ID:
119+
process.env.STRIPE_SUBSCRIPTION_500_PRICE_ID,
107120
LOOPS_API_KEY: process.env.LOOPS_API_KEY,
108121
DISCORD_PUBLIC_KEY: process.env.DISCORD_PUBLIC_KEY,
109122
DISCORD_BOT_TOKEN: process.env.DISCORD_BOT_TOKEN,

packages/internal/src/env.ts

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,19 +3,23 @@ import { serverEnvSchema, serverProcessEnv } from './env-schema'
33
// Only provide safe defaults in CI to avoid schema failures during tests
44
// In local dev, missing env vars should fail fast so devs know to configure them
55
const isCI = process.env.CI === 'true' || process.env.CI === '1'
6+
const envInput = { ...serverProcessEnv }
67

78
if (isCI) {
89
const ensureEnvDefault = (key: string, value: string) => {
910
if (!process.env[key]) {
1011
process.env[key] = value
1112
}
13+
envInput[key as keyof typeof envInput] = process.env[key]
1214
}
1315

1416
ensureEnvDefault('OPEN_ROUTER_API_KEY', 'test')
1517
ensureEnvDefault('OPENAI_API_KEY', 'test')
1618
ensureEnvDefault('ANTHROPIC_API_KEY', 'test')
19+
ensureEnvDefault('FIREWORKS_API_KEY', 'test')
1720
ensureEnvDefault('LINKUP_API_KEY', 'test')
1821
ensureEnvDefault('GRAVITY_API_KEY', 'test')
22+
ensureEnvDefault('IPINFO_TOKEN', 'test')
1923
ensureEnvDefault('PORT', '4242')
2024
ensureEnvDefault('DATABASE_URL', 'postgres://user:pass@localhost:5432/db')
2125
ensureEnvDefault('CODEBUFF_GITHUB_ID', 'test-id')
@@ -26,6 +30,9 @@ if (isCI) {
2630
ensureEnvDefault('STRIPE_SECRET_KEY', 'sk_test_dummy')
2731
ensureEnvDefault('STRIPE_WEBHOOK_SECRET_KEY', 'whsec_dummy')
2832
ensureEnvDefault('STRIPE_TEAM_FEE_PRICE_ID', 'price_test')
33+
ensureEnvDefault('STRIPE_SUBSCRIPTION_100_PRICE_ID', 'price_test_100')
34+
ensureEnvDefault('STRIPE_SUBSCRIPTION_200_PRICE_ID', 'price_test_200')
35+
ensureEnvDefault('STRIPE_SUBSCRIPTION_500_PRICE_ID', 'price_test_500')
2936
ensureEnvDefault('LOOPS_API_KEY', 'test')
3037
ensureEnvDefault('DISCORD_PUBLIC_KEY', 'test')
3138
ensureEnvDefault('DISCORD_BOT_TOKEN', 'test')
@@ -46,4 +53,4 @@ if (process.env.NEXT_PUBLIC_CB_ENVIRONMENT !== 'prod') {
4653
}
4754
}
4855

49-
export const env = serverEnvSchema.parse(serverProcessEnv)
56+
export const env = serverEnvSchema.parse(envInput)

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

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -256,14 +256,18 @@ export async function postChatCompletions(params: {
256256

257257
// For free mode requests, require a resolved allowlisted country.
258258
if (isFreeModeRequest) {
259-
const countryAccess = getFreeModeCountryAccess(req)
259+
const countryAccess = await getFreeModeCountryAccess(req, {
260+
fetch,
261+
ipinfoToken: env.IPINFO_TOKEN,
262+
})
260263

261264
logger.info(
262265
{
263266
cfHeader: countryAccess.cfCountry,
264267
geoipResult: countryAccess.geoipCountry,
265268
resolvedCountry: countryAccess.countryCode,
266269
countryBlockReason: countryAccess.blockReason,
270+
ipPrivacySignals: countryAccess.ipPrivacy?.signals,
267271
clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined,
268272
},
269273
'Free mode country detection',
@@ -277,6 +281,7 @@ export async function postChatCompletions(params: {
277281
error: 'free_mode_not_available_in_country',
278282
countryCode: countryAccess.countryCode,
279283
countryBlockReason: countryAccess.blockReason,
284+
ipPrivacySignals: countryAccess.ipPrivacy?.signals,
280285
clientIp: countryAccess.hasClientIp ? '[redacted]' : undefined,
281286
},
282287
logger,

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

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { NextResponse } from 'next/server'
2+
import { env } from '@codebuff/internal/env'
23

34
import {
45
endUserSession,
@@ -22,8 +23,13 @@ import type { NextRequest } from 'next/server'
2223
* `country_blocked` status and would tight-poll on an unrecognized 200
2324
* body — fall into their existing `!resp.ok` error path and back off on
2425
* the 10s error retry cadence. The new CLI parses the 403 body directly. */
25-
function countryBlockedResponse(req: NextRequest): NextResponse | null {
26-
const countryAccess = getFreeModeCountryAccess(req)
26+
async function countryBlockedResponse(
27+
req: NextRequest,
28+
deps: FreebuffSessionDeps,
29+
): Promise<NextResponse | null> {
30+
const countryAccess = await getFreeModeCountryAccess(req, {
31+
ipinfoToken: env.IPINFO_TOKEN,
32+
})
2733
if (countryAccess.allowed) return null
2834
return NextResponse.json(
2935
{
@@ -126,7 +132,7 @@ export async function postFreebuffSession(
126132
const auth = await resolveUser(req, deps)
127133
if ('error' in auth) return auth.error
128134

129-
const blocked = countryBlockedResponse(req)
135+
const blocked = await countryBlockedResponse(req, deps)
130136
if (blocked) return blocked
131137

132138
const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? ''
@@ -170,7 +176,7 @@ export async function getFreebuffSession(
170176
const auth = await resolveUser(req, deps)
171177
if ('error' in auth) return auth.error
172178

173-
const blocked = countryBlockedResponse(req)
179+
const blocked = await countryBlockedResponse(req, deps)
174180
if (blocked) return blocked
175181

176182
try {

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

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,17 @@ import { logger } from '@/util/logger'
99

1010
import type { NextRequest } from 'next/server'
1111

12+
const freebuffSessionDeps = {
13+
getUserInfoFromApiKey,
14+
logger,
15+
}
16+
1217
export async function GET(req: NextRequest) {
13-
return getFreebuffSession(req, { getUserInfoFromApiKey, logger })
18+
return getFreebuffSession(req, freebuffSessionDeps)
1419
}
1520

1621
export async function POST(req: NextRequest) {
17-
return postFreebuffSession(req, { getUserInfoFromApiKey, logger })
22+
return postFreebuffSession(req, freebuffSessionDeps)
1823
}
1924

2025
export async function DELETE(req: NextRequest) {
Lines changed: 100 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,45 +1,136 @@
11
import { describe, expect, test } from 'bun:test'
22
import { NextRequest } from 'next/server'
33

4-
import { getFreeModeCountryAccess } from '../free-mode-country'
4+
import {
5+
getFreeModeCountryAccess,
6+
lookupIpinfoPrivacy,
7+
} from '../free-mode-country'
58

69
function makeReq(headers: Record<string, string> = {}): NextRequest {
710
return new NextRequest('http://localhost:3000/api/v1/chat/completions', {
811
headers,
912
})
1013
}
1114

15+
const noAnonymousNetwork = {
16+
ipinfoToken: 'test-token',
17+
lookupIpPrivacy: async () => ({ signals: [] }),
18+
}
19+
1220
describe('free mode country access', () => {
13-
test('allows allowlisted Cloudflare countries', () => {
14-
const access = getFreeModeCountryAccess(makeReq({ 'cf-ipcountry': 'us' }))
21+
test('allows allowlisted Cloudflare countries', async () => {
22+
const access = await getFreeModeCountryAccess(
23+
makeReq({ 'cf-ipcountry': 'us' }),
24+
noAnonymousNetwork,
25+
)
1526
expect(access.allowed).toBe(true)
1627
expect(access.countryCode).toBe('US')
1728
expect(access.blockReason).toBe(null)
1829
})
1930

20-
test('blocks countries outside the allowlist', () => {
21-
const access = getFreeModeCountryAccess(makeReq({ 'cf-ipcountry': 'FR' }))
31+
test('blocks countries outside the allowlist', async () => {
32+
const access = await getFreeModeCountryAccess(
33+
makeReq({ 'cf-ipcountry': 'FR' }),
34+
noAnonymousNetwork,
35+
)
2236
expect(access.allowed).toBe(false)
2337
expect(access.countryCode).toBe('FR')
2438
expect(access.blockReason).toBe('country_not_allowed')
2539
})
2640

27-
test('blocks anonymized Cloudflare country codes without falling back to IP geo', () => {
28-
const access = getFreeModeCountryAccess(
41+
test('blocks anonymized Cloudflare country codes without falling back to IP geo', async () => {
42+
const access = await getFreeModeCountryAccess(
2943
makeReq({
3044
'cf-ipcountry': 'T1',
3145
'x-forwarded-for': '8.8.8.8',
3246
}),
47+
noAnonymousNetwork,
3348
)
3449
expect(access.allowed).toBe(false)
3550
expect(access.countryCode).toBe(null)
3651
expect(access.blockReason).toBe('anonymized_or_unknown_country')
3752
})
3853

39-
test('blocks missing client location as unknown', () => {
40-
const access = getFreeModeCountryAccess(makeReq())
54+
test('blocks missing client location as unknown', async () => {
55+
const access = await getFreeModeCountryAccess(makeReq(), noAnonymousNetwork)
4156
expect(access.allowed).toBe(false)
4257
expect(access.countryCode).toBe(null)
4358
expect(access.blockReason).toBe('missing_client_ip')
4459
})
60+
61+
test('blocks allowlisted countries when the client IP is an anonymous network', async () => {
62+
const access = await getFreeModeCountryAccess(
63+
makeReq({
64+
'cf-ipcountry': 'US',
65+
'x-forwarded-for': '203.0.113.10',
66+
}),
67+
{
68+
ipinfoToken: 'test-token',
69+
lookupIpPrivacy: async () => ({
70+
signals: ['vpn'],
71+
}),
72+
},
73+
)
74+
expect(access.allowed).toBe(false)
75+
expect(access.countryCode).toBe('US')
76+
expect(access.blockReason).toBe('anonymous_network')
77+
expect(access.ipPrivacy?.signals).toEqual(['vpn'])
78+
})
79+
80+
test('allows allowlisted countries when privacy lookup finds no anonymous signals', async () => {
81+
const access = await getFreeModeCountryAccess(
82+
makeReq({
83+
'cf-ipcountry': 'US',
84+
'x-forwarded-for': '203.0.113.10',
85+
}),
86+
{
87+
ipinfoToken: 'test-token',
88+
lookupIpPrivacy: async () => ({
89+
signals: [],
90+
}),
91+
},
92+
)
93+
expect(access.allowed).toBe(true)
94+
expect(access.blockReason).toBe(null)
95+
})
96+
97+
test('allows allowlisted countries when privacy lookup fails', async () => {
98+
const access = await getFreeModeCountryAccess(
99+
makeReq({
100+
'cf-ipcountry': 'US',
101+
'x-forwarded-for': '203.0.113.10',
102+
}),
103+
{
104+
ipinfoToken: 'test-token',
105+
lookupIpPrivacy: async () => {
106+
throw new Error('provider unavailable')
107+
},
108+
},
109+
)
110+
expect(access.allowed).toBe(true)
111+
expect(access.blockReason).toBe(null)
112+
expect(access.ipPrivacy).toBe(null)
113+
})
114+
115+
test('parses IPinfo privacy signals', async () => {
116+
const fetch = async () =>
117+
Response.json({
118+
vpn: true,
119+
proxy: false,
120+
tor: true,
121+
relay: false,
122+
hosting: true,
123+
service: 'Example VPN',
124+
})
125+
126+
const privacy = await lookupIpinfoPrivacy({
127+
ip: '203.0.113.10',
128+
token: 'test-token',
129+
fetch: fetch as unknown as typeof globalThis.fetch,
130+
})
131+
132+
expect(privacy).toEqual({
133+
signals: ['vpn', 'tor', 'hosting', 'service'],
134+
})
135+
})
45136
})

0 commit comments

Comments
 (0)