From 9e39f1c8c71b5c0fac6b09a4b1bea98c8162de0f Mon Sep 17 00:00:00 2001 From: starptech Date: Fri, 3 Jul 2026 23:01:20 +0200 Subject: [PATCH] feat(console): enforce 10mb request body limit on zen api --- packages/console/app/src/i18n/en.ts | 1 + .../console/app/src/routes/zen/util/body.ts | 37 +++++++++++++ .../console/app/src/routes/zen/util/error.ts | 1 + .../app/src/routes/zen/util/handler.ts | 16 +++++- packages/console/app/test/body.test.ts | 55 +++++++++++++++++++ 5 files changed, 109 insertions(+), 1 deletion(-) create mode 100644 packages/console/app/src/routes/zen/util/body.ts create mode 100644 packages/console/app/test/body.test.ts diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 9cd10f6407..466c70d2f6 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -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", diff --git a/packages/console/app/src/routes/zen/util/body.ts b/packages/console/app/src/routes/zen/util/body.ts new file mode 100644 index 0000000000..78870629e6 --- /dev/null +++ b/packages/console/app/src/routes/zen/util/body.ts @@ -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`) +} diff --git a/packages/console/app/src/routes/zen/util/error.ts b/packages/console/app/src/routes/zen/util/error.ts index 5117815d27..00fc3aaec5 100644 --- a/packages/console/app/src/routes/zen/util/error.ts +++ b/packages/console/app/src/routes/zen/util/error.ts @@ -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 diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index a255d54262..b64f183a4d 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -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({ diff --git a/packages/console/app/test/body.test.ts b/packages/console/app/test/body.test.ts new file mode 100644 index 0000000000..4f70c7fd37 --- /dev/null +++ b/packages/console/app/test/body.test.ts @@ -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) { + return new Request("https://opencode.ai/zen/v1/chat/completions", { method: "POST", body, headers }) +} + +function streamOf(...chunks: Uint8Array[]) { + return new ReadableStream({ + 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({ + pull(c) { + if (sent > MAX_BODY_BYTES) return c.close() + sent += chunk.length + c.enqueue(chunk) + }, + }) + await expect(readJsonBody(post(stream))).rejects.toThrow(BodyLimitError) + }) +})