mirror of
https://github.com/anomalyco/opencode.git
synced 2026-07-10 04:58:28 +00:00
feat(console): enforce 10mb request body limit on zen api
This commit is contained in:
parent
436cd39d14
commit
9e39f1c8c7
5 changed files with 109 additions and 1 deletions
|
|
@ -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",
|
||||
|
|
|
|||
37
packages/console/app/src/routes/zen/util/body.ts
Normal file
37
packages/console/app/src/routes/zen/util/body.ts
Normal 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`)
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
55
packages/console/app/test/body.test.ts
Normal file
55
packages/console/app/test/body.test.ts
Normal 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)
|
||||
})
|
||||
})
|
||||
Loading…
Add table
Add a link
Reference in a new issue