feat(console): enforce 10mb request body limit on zen api

This commit is contained in:
starptech 2026-07-03 23:01:20 +02:00
parent 436cd39d14
commit 9e39f1c8c7
5 changed files with 109 additions and 1 deletions

View file

@ -350,6 +350,7 @@ export const dict = {
"zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.",
"zen.api.error.modelNotSupported": "Model {{model}} is not supported",
"zen.api.error.requestBodyTooLarge": "Request body too large. Maximum allowed size is {{maxMB}}MB.",
"zen.api.error.modelFormatNotSupported": "Model {{model}} is not supported for format {{format}}",
"zen.api.error.noProviderAvailable": "No provider available",
"zen.api.error.providerNotSupported": "Provider {{provider}} not supported",

View file

@ -0,0 +1,37 @@
import { BodyLimitError } from "./error"
export const MAX_BODY_BYTES = 10 * 1024 * 1024
/**
* Parses a JSON request body while enforcing a maximum size so callers cannot
* send arbitrarily large context payloads. Rejects on the declared
* content-length before reading anything, but does not trust it: chunked
* requests can omit or understate the header, so bytes are also counted while
* consuming the stream. Chunks are decoded as they arrive so the body is never
* buffered twice.
*/
export async function readJsonBody(request: Request) {
const declared = Number(request.headers.get("content-length"))
if (declared > MAX_BODY_BYTES) throw tooLarge()
const reader = request.body?.getReader()
if (!reader) return request.json()
const decoder = new TextDecoder()
let received = 0
let text = ""
while (true) {
const { done, value } = await reader.read()
if (done) return JSON.parse(text + decoder.decode())
received += value.length
if (received > MAX_BODY_BYTES) {
await reader.cancel()
throw tooLarge()
}
text += decoder.decode(value, { stream: true })
}
}
function tooLarge() {
return new BodyLimitError(`Request body exceeds ${MAX_BODY_BYTES} bytes`)
}

View file

@ -4,6 +4,7 @@ export class MonthlyLimitError extends Error {}
export class UserLimitError extends Error {}
export class ModelError extends Error {}
export class RegionError extends Error {}
export class BodyLimitError extends Error {}
class LimitError extends Error {
retryAfter?: number

View file

@ -17,6 +17,7 @@ import { ProviderTable } from "@opencode-ai/console-core/schema/provider.sql.js"
import { logger } from "./logger"
import {
AuthError,
BodyLimitError,
CreditsError,
MonthlyLimitError,
UserLimitError,
@ -38,6 +39,7 @@ import { anthropicHelper } from "./provider/anthropic"
import { googleHelper } from "./provider/google"
import { openaiHelper } from "./provider/openai"
import { oaCompatHelper } from "./provider/openai-compatible"
import { MAX_BODY_BYTES, readJsonBody } from "./body"
import { createRateLimiter as createIpRateLimiter } from "./ipRateLimiter"
import { createRateLimiter as createKeyRateLimiter } from "./keyRateLimiter"
import { createTrialLimiter } from "./trialLimiter"
@ -97,7 +99,7 @@ export async function handler(
try {
const url = input.request.url
const body = await input.request.json()
const body = await readJsonBody(input.request)
const model = opts.parseModel(url, body)
const variant = opts.parseVariant(url, body)
const isStream = opts.parseIsStream(url, body)
@ -466,6 +468,18 @@ export async function handler(
} catch {}
}
if (error instanceof BodyLimitError)
return new Response(
JSON.stringify({
type: "error",
error: {
type: error.constructor.name,
message: t("zen.api.error.requestBodyTooLarge", { maxMB: MAX_BODY_BYTES / 1024 / 1024 }),
},
}),
{ status: 413 },
)
if (error instanceof RegionError)
return new Response(
JSON.stringify({

View file

@ -0,0 +1,55 @@
import { describe, expect, test } from "bun:test"
import { MAX_BODY_BYTES, readJsonBody } from "../src/routes/zen/util/body"
import { BodyLimitError } from "../src/routes/zen/util/error"
function post(body: BodyInit, headers?: Record<string, string>) {
return new Request("https://opencode.ai/zen/v1/chat/completions", { method: "POST", body, headers })
}
function streamOf(...chunks: Uint8Array[]) {
return new ReadableStream<Uint8Array>({
start(c) {
for (const chunk of chunks) c.enqueue(chunk)
c.close()
},
})
}
describe("readJsonBody", () => {
test("parses a body within the limit", async () => {
const body = await readJsonBody(post(JSON.stringify({ model: "test", messages: [] })))
expect(body).toEqual({ model: "test", messages: [] })
})
test("parses a body of exactly the limit", async () => {
const wrapper = '{"padding":""}'
const json = `{"padding":"${"x".repeat(MAX_BODY_BYTES - wrapper.length)}"}`
const body = await readJsonBody(post(json))
expect(body.padding.length).toBe(MAX_BODY_BYTES - wrapper.length)
})
test("parses a streamed body split mid multi-byte character", async () => {
const bytes = new TextEncoder().encode('{"name":"café"}')
// 13 splits the two-byte "é", exercising incremental decoding
const body = await readJsonBody(post(streamOf(bytes.slice(0, 13), bytes.slice(13))))
expect(body).toEqual({ name: "café" })
})
test("rejects when the declared content-length exceeds the limit", async () => {
const request = post("{}", { "content-length": String(MAX_BODY_BYTES + 1) })
await expect(readJsonBody(request)).rejects.toThrow(BodyLimitError)
})
test("rejects an oversized stream without a content-length header", async () => {
const chunk = new TextEncoder().encode("x".repeat(1024 * 1024))
let sent = 0
const stream = new ReadableStream<Uint8Array>({
pull(c) {
if (sent > MAX_BODY_BYTES) return c.close()
sent += chunk.length
c.enqueue(chunk)
},
})
await expect(readJsonBody(post(stream))).rejects.toThrow(BodyLimitError)
})
})