Skip to content

Commit 3a76beb

Browse files
committed
Fail free request with non-free model
1 parent 9463fde commit 3a76beb

File tree

2 files changed

+159
-7
lines changed

2 files changed

+159
-7
lines changed

web/src/app/api/v1/chat/completions/__tests__/completions.test.ts

Lines changed: 123 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -135,6 +135,13 @@ describe('/api/v1/chat/completions POST endpoint', () => {
135135
status: 'running',
136136
}
137137
}
138+
if (runId === 'run-free') {
139+
return {
140+
// Real free-mode allowlisted agent (see FREE_MODE_AGENT_MODELS).
141+
agent_id: 'base2-free',
142+
status: 'running',
143+
}
144+
}
138145
if (runId === 'run-completed') {
139146
return {
140147
agent_id: 'agent-123',
@@ -529,10 +536,10 @@ describe('/api/v1/chat/completions POST endpoint', () => {
529536
method: 'POST',
530537
headers: { Authorization: 'Bearer test-api-key-new-free' },
531538
body: JSON.stringify({
532-
model: 'test/test-model',
539+
model: 'z-ai/glm-5.1',
533540
stream: false,
534541
codebuff_metadata: {
535-
run_id: 'run-123',
542+
run_id: 'run-free',
536543
client_id: 'test-client-id-123',
537544
cost_mode: 'free',
538545
},
@@ -562,10 +569,10 @@ describe('/api/v1/chat/completions POST endpoint', () => {
562569
method: 'POST',
563570
headers: { Authorization: 'Bearer test-api-key-no-credits' },
564571
body: JSON.stringify({
565-
model: 'test/test-model',
572+
model: 'z-ai/glm-5.1',
566573
stream: false,
567574
codebuff_metadata: {
568-
run_id: 'run-123',
575+
run_id: 'run-free',
569576
client_id: 'test-client-id-123',
570577
cost_mode: 'free',
571578
},
@@ -587,6 +594,116 @@ describe('/api/v1/chat/completions POST endpoint', () => {
587594

588595
expect(response.status).toBe(200)
589596
})
597+
598+
it('rejects free-mode requests using a non-allowlisted model (e.g. Opus)', async () => {
599+
const req = new NextRequest(
600+
'http://localhost:3000/api/v1/chat/completions',
601+
{
602+
method: 'POST',
603+
headers: { Authorization: 'Bearer test-api-key-new-free' },
604+
body: JSON.stringify({
605+
// Expensive model the attacker wants for free.
606+
model: 'anthropic/claude-4.7-opus',
607+
stream: true,
608+
codebuff_metadata: {
609+
run_id: 'run-free',
610+
client_id: 'test-client-id-123',
611+
cost_mode: 'free',
612+
},
613+
}),
614+
},
615+
)
616+
617+
const response = await postChatCompletions({
618+
req,
619+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
620+
logger: mockLogger,
621+
trackEvent: mockTrackEvent,
622+
getUserUsageData: mockGetUserUsageData,
623+
getAgentRunFromId: mockGetAgentRunFromId,
624+
fetch: mockFetch,
625+
insertMessageBigquery: mockInsertMessageBigquery,
626+
loggerWithContext: mockLoggerWithContext,
627+
})
628+
629+
expect(response.status).toBe(403)
630+
const body = await response.json()
631+
expect(body.error).toBe('free_mode_invalid_agent_model')
632+
})
633+
634+
it('rejects free-mode requests with an allowlisted agent but a model outside its allowed set', async () => {
635+
// agent=base2-free is allowlisted, but Opus is not in its allowed
636+
// model set. This is the spoofing variant of the attack where the
637+
// caller picks a real free-mode agentId to try to sneak past the gate.
638+
const req = new NextRequest(
639+
'http://localhost:3000/api/v1/chat/completions',
640+
{
641+
method: 'POST',
642+
headers: { Authorization: 'Bearer test-api-key-new-free' },
643+
body: JSON.stringify({
644+
model: 'anthropic/claude-4.7-opus',
645+
stream: true,
646+
codebuff_metadata: {
647+
run_id: 'run-free',
648+
client_id: 'test-client-id-123',
649+
cost_mode: 'free',
650+
},
651+
}),
652+
},
653+
)
654+
655+
const response = await postChatCompletions({
656+
req,
657+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
658+
logger: mockLogger,
659+
trackEvent: mockTrackEvent,
660+
getUserUsageData: mockGetUserUsageData,
661+
getAgentRunFromId: mockGetAgentRunFromId,
662+
fetch: mockFetch,
663+
insertMessageBigquery: mockInsertMessageBigquery,
664+
loggerWithContext: mockLoggerWithContext,
665+
})
666+
667+
expect(response.status).toBe(403)
668+
const body = await response.json()
669+
expect(body.error).toBe('free_mode_invalid_agent_model')
670+
})
671+
672+
it('rejects free-mode requests where agentId is not in the allowlist at all', async () => {
673+
// run-123 points to agent-123, which is not a free-mode agent.
674+
const req = new NextRequest(
675+
'http://localhost:3000/api/v1/chat/completions',
676+
{
677+
method: 'POST',
678+
headers: { Authorization: 'Bearer test-api-key-new-free' },
679+
body: JSON.stringify({
680+
model: 'z-ai/glm-5.1',
681+
stream: true,
682+
codebuff_metadata: {
683+
run_id: 'run-123',
684+
client_id: 'test-client-id-123',
685+
cost_mode: 'free',
686+
},
687+
}),
688+
},
689+
)
690+
691+
const response = await postChatCompletions({
692+
req,
693+
getUserInfoFromApiKey: mockGetUserInfoFromApiKey,
694+
logger: mockLogger,
695+
trackEvent: mockTrackEvent,
696+
getUserUsageData: mockGetUserUsageData,
697+
getAgentRunFromId: mockGetAgentRunFromId,
698+
fetch: mockFetch,
699+
insertMessageBigquery: mockInsertMessageBigquery,
700+
loggerWithContext: mockLoggerWithContext,
701+
})
702+
703+
expect(response.status).toBe(403)
704+
const body = await response.json()
705+
expect(body.error).toBe('free_mode_invalid_agent_model')
706+
})
590707
})
591708

592709
describe('Successful responses', () => {
@@ -734,10 +851,10 @@ describe('/api/v1/chat/completions POST endpoint', () => {
734851
method: 'POST',
735852
headers: { Authorization: 'Bearer test-api-key-123' },
736853
body: JSON.stringify({
737-
model: 'test/test-model',
854+
model: 'z-ai/glm-5.1',
738855
stream: false,
739856
codebuff_metadata: {
740-
run_id: 'run-123',
857+
run_id: 'run-free',
741858
client_id: 'test-client-id-123',
742859
cost_mode: 'free',
743860
},

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

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
import { AnalyticsEvent } from '@codebuff/common/constants/analytics-events'
22
import { BYOK_OPENROUTER_HEADER } from '@codebuff/common/constants/byok'
3-
import { isFreeMode } from '@codebuff/common/constants/free-agents'
3+
import {
4+
isFreeMode,
5+
isFreeModeAllowedAgentModel,
6+
} from '@codebuff/common/constants/free-agents'
47
import { getErrorObject } from '@codebuff/common/util/error'
58
import { pluralize } from '@codebuff/common/util/string'
69
import { env } from '@codebuff/internal/env'
@@ -359,6 +362,38 @@ export async function postChatCompletions(params: {
359362
)
360363
}
361364

365+
// Free-mode requests must use an allowlisted agent+model combination.
366+
// Without this gate, an attacker on a brand-new unpaid account can set
367+
// cost_mode='free' to bypass both the paid-account check and the balance
368+
// check, then request an expensive model (Opus, etc). Our OpenRouter key
369+
// pays for the call; the downstream credit-consumption step records an
370+
// audit row but can't actually deduct from a user who has no grants —
371+
// net result is free Opus for the attacker, real dollars for us. Check
372+
// must happen here, before any call to OpenRouter.
373+
if (
374+
isFreeModeRequest &&
375+
!isFreeModeAllowedAgentModel(agentId, typedBody.model)
376+
) {
377+
trackEvent({
378+
event: AnalyticsEvent.CHAT_COMPLETIONS_VALIDATION_ERROR,
379+
userId,
380+
properties: {
381+
error: 'free_mode_invalid_agent_model',
382+
agentId,
383+
model: typedBody.model,
384+
},
385+
logger,
386+
})
387+
return NextResponse.json(
388+
{
389+
error: 'free_mode_invalid_agent_model',
390+
message:
391+
'Free mode is only available for specific agent and model combinations.',
392+
},
393+
{ status: 403 },
394+
)
395+
}
396+
362397
// Rate limit free mode requests (after validation so invalid requests don't consume quota)
363398
if (isFreeModeRequest) {
364399
const rateLimitResult = checkFreeModeRateLimit(userId)

0 commit comments

Comments
 (0)