Skip to content

Commit b305351

Browse files
Block free mode VPN traffic (#552)
Co-authored-by: James Grugett <jahooma@gmail.com>
1 parent 35819f6 commit b305351

10 files changed

Lines changed: 423 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/__tests__/completions.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,7 @@ describe('/api/v1/chat/completions POST endpoint', () => {
6969
const allowedFreeModeHeaders = (apiKey: string) => ({
7070
Authorization: `Bearer ${apiKey}`,
7171
'cf-ipcountry': 'US',
72+
'cf-connecting-ip': '203.0.113.10',
7273
})
7374

7475
beforeEach(() => {

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/__tests__/session.test.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,10 @@ function makeReq(
2727
if (apiKey) headers.set('Authorization', `Bearer ${apiKey}`)
2828
if (opts.instanceId) headers.set(FREEBUFF_INSTANCE_HEADER, opts.instanceId)
2929
const cfCountry = opts.cfCountry === null ? null : (opts.cfCountry ?? 'US')
30-
if (cfCountry) headers.set('cf-ipcountry', cfCountry)
30+
if (cfCountry) {
31+
headers.set('cf-ipcountry', cfCountry)
32+
headers.set('cf-connecting-ip', '203.0.113.10')
33+
}
3134
if (opts.model) headers.set(FREEBUFF_MODEL_HEADER, opts.model)
3235
return {
3336
headers,

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

Lines changed: 9 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,12 @@ 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+
): Promise<NextResponse | null> {
29+
const countryAccess = await getFreeModeCountryAccess(req, {
30+
ipinfoToken: env.IPINFO_TOKEN,
31+
})
2732
if (countryAccess.allowed) return null
2833
return NextResponse.json(
2934
{
@@ -126,7 +131,7 @@ export async function postFreebuffSession(
126131
const auth = await resolveUser(req, deps)
127132
if ('error' in auth) return auth.error
128133

129-
const blocked = countryBlockedResponse(req)
134+
const blocked = await countryBlockedResponse(req)
130135
if (blocked) return blocked
131136

132137
const requestedModel = req.headers.get(FREEBUFF_MODEL_HEADER) ?? ''
@@ -170,7 +175,7 @@ export async function getFreebuffSession(
170175
const auth = await resolveUser(req, deps)
171176
if ('error' in auth) return auth.error
172177

173-
const blocked = countryBlockedResponse(req)
178+
const blocked = await countryBlockedResponse(req)
174179
if (blocked) return blocked
175180

176181
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) {

0 commit comments

Comments
 (0)