From 7631282b7f52548eae34c99314645922574ca08e Mon Sep 17 00:00:00 2001 From: anupamme Date: Wed, 7 Oct 2026 06:27:27 +0000 Subject: [PATCH 1/2] fix: multi_agent.cwe-400 security vulnerability Automated security fix generated by OrbisAI Security --- zen-proxy.mjs | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/zen-proxy.mjs b/zen-proxy.mjs index 1fb9393..127c996 100644 --- a/zen-proxy.mjs +++ b/zen-proxy.mjs @@ -500,9 +500,16 @@ function authForUpstream(req) { return "Bearer public" } -async function readBody(req) { +async function readBody(req, limit = MAX_BODY) { let raw = "" - for await (const chunk of req) raw += chunk + for await (const chunk of req) { + raw += chunk + if (raw.length > limit) { + const err = new Error("request body too large") + err.statusCode = 413 + throw err + } + } return raw } @@ -516,14 +523,14 @@ async function handleChat(req, res) { let body try { const raw = await readBody(req) - if (raw.length > MAX_BODY) { - return json(res, 413, { error: { type: "invalid_request_error", message: "request body too large" } }) - } body = JSON.parse(raw) if (typeof body !== "object" || body === null || Array.isArray(body)) { return json(res, 400, { error: { type: "invalid_request_error", message: "body must be a JSON object" } }) } - } catch { + } catch (err) { + if (err && err.statusCode === 413) { + return json(res, 413, { error: { type: "invalid_request_error", message: "request body too large" } }) + } return json(res, 400, { error: { type: "invalid_request_error", message: "invalid JSON body" } }) } From 47efb427cd163cc3c92fa4bc3375790afea7f744 Mon Sep 17 00:00:00 2001 From: OrbisAI Security Date: Wed, 7 Oct 2026 23:34:28 +0530 Subject: [PATCH 2/2] test: prove mid-stream body-limit abort, extend 413 precision to admin routes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit readBody() already aborts mid-stream once a request exceeds MAX_BODY (see previous commit), but the existing oversized-body test only checks the final status, so it can't distinguish a true early abort from the old buffer-everything-then-check pattern. Add a chunk-tracking request mock (mockStreamingReq) and tests that assert the handler stops consuming chunks before the stream is drained, across handleChat, handleApiConfig (PUT), and handleTest. Also give handleApiConfig (PUT) and handleTest the same precise 413 response handleChat already has for oversized bodies, instead of a generic 400 — all three share the same readBody() primitive, so the memory-safety property was already in place; this just makes the response shape consistent. --- test/zen-proxy.test.mjs | 97 +++++++++++++++++++++++++++++++++++++++++ zen-proxy.mjs | 6 +++ 2 files changed, 103 insertions(+) diff --git a/test/zen-proxy.test.mjs b/test/zen-proxy.test.mjs index 1605dbd..49df020 100644 --- a/test/zen-proxy.test.mjs +++ b/test/zen-proxy.test.mjs @@ -56,6 +56,31 @@ function mockReq(over = {}) { } } +// Multi-chunk request mock that records how many chunks were actually pulled +// before the consumer (readBody) stops iterating. Used to prove early-abort +// (mid-stream rejection) rather than full-buffer-then-check. +function mockStreamingReq(chunks, over = {}) { + const pulled = { count: 0, finished: false } + return { + headers: over.headers ?? {}, + socket: { remoteAddress: over.remoteAddress ?? "127.0.0.1" }, + method: over.method ?? "POST", + url: over.url ?? "/v1/chat/completions", + signal: new AbortController().signal, + on: () => {}, + destroy: () => {}, + pulled, + [Symbol.asyncIterator]: async function* () { + for (const chunk of chunks) { + pulled.count++ + yield chunk + } + pulled.finished = true + }, + ...over, + } +} + function mockRes() { let finishCb = null let resolveDone @@ -910,6 +935,19 @@ describe("test endpoint honest reporting", () => { }) }) +describe("handleTest body size", () => { + test("mid-stream abort: oversized body rejected before stream fully drained", async () => { + const chunk = "x".repeat(100 * 1024) + const chunks = Array.from({ length: 20 }, () => chunk) + const req = mockStreamingReq(chunks, { method: "POST" }) + const res = mockRes() + await zp.handleTest(req, res) + assert.equal(res.state.status, 413) + assert.ok(req.pulled.count < chunks.length, `expected early abort, pulled ${req.pulled.count}/${chunks.length} chunks`) + assert.equal(req.pulled.finished, false, "generator must not run to completion") + }) +}) + describe("recordReq stats", () => { test("dedups consecutive identical records", () => { const req = mockReq() @@ -1033,6 +1071,54 @@ describe("handleChat", () => { assert.equal(res.state.status, 413) }) + test("mid-stream abort: oversized body is rejected before the stream is fully drained", async () => { + globalThis.fetch = routeFetch([["/chat/completions", jsonResponse({ model: "ok" })]]) + // 20 chunks of 100KB = ~2MB total, well over MAX_BODY (1MB). Each chunk is + // small relative to MAX_BODY so the overflow is detected partway through, + // not on the first or last chunk. + const chunk = "x".repeat(100 * 1024) + const chunks = Array.from({ length: 20 }, () => chunk) + const req = mockStreamingReq(chunks) + const res = mockRes() + await zp.handleChat(req, res) + assert.equal(res.state.status, 413) + assert.ok(req.pulled.count < chunks.length, `expected early abort, pulled ${req.pulled.count}/${chunks.length} chunks`) + assert.equal(req.pulled.finished, false, "generator must not run to completion") + }) + + test("body exactly at MAX_BODY → not rejected for size", async () => { + globalThis.fetch = routeFetch([["/chat/completions", jsonResponse({ model: "ok" })]]) + const pad = "x".repeat(zp.MAX_BODY - JSON.stringify({ model: "m", messages: [{ role: "user", content: "" }] }).length) + const body = JSON.stringify({ model: "m", messages: [{ role: "user", content: pad }] }) + assert.equal(body.length, zp.MAX_BODY, "sanity: body is exactly MAX_BODY") + const req = mockReq({ _body: body }) + const res = mockRes() + await zp.handleChat(req, res) + assert.notEqual(res.state.status, 413) + }) + + test("body one byte over MAX_BODY → 413", async () => { + globalThis.fetch = routeFetch([["/chat/completions", jsonResponse({ model: "ok" })]]) + const pad = "x".repeat(zp.MAX_BODY - JSON.stringify({ model: "m", messages: [{ role: "user", content: "" }] }).length + 1) + const body = JSON.stringify({ model: "m", messages: [{ role: "user", content: pad }] }) + assert.equal(body.length, zp.MAX_BODY + 1, "sanity: body is MAX_BODY + 1") + const req = mockReq({ _body: body }) + const res = mockRes() + await zp.handleChat(req, res) + assert.equal(res.state.status, 413) + }) + + test("body one byte under MAX_BODY → not 413", async () => { + globalThis.fetch = routeFetch([["/chat/completions", jsonResponse({ model: "ok" })]]) + const pad = "x".repeat(zp.MAX_BODY - JSON.stringify({ model: "m", messages: [{ role: "user", content: "" }] }).length - 1) + const body = JSON.stringify({ model: "m", messages: [{ role: "user", content: pad }] }) + assert.equal(body.length, zp.MAX_BODY - 1, "sanity: body is MAX_BODY - 1") + const req = mockReq({ _body: body }) + const res = mockRes() + await zp.handleChat(req, res) + assert.notEqual(res.state.status, 413) + }) + test("wrong proxy key → 401", async () => { zp.saveConfig({ proxyKey: "admin-1" }) const req = mockReq({ _body: JSON.stringify({ model: "m", messages: [] }), headers: { authorization: "Bearer nope" } }) @@ -1404,6 +1490,17 @@ describe("handleApiConfig", () => { await zp.handleApiConfig(req, res) assert.equal(res.state.status, 400) }) + + test("PUT mid-stream abort: oversized body rejected before stream fully drained", async () => { + const chunk = "x".repeat(100 * 1024) + const chunks = Array.from({ length: 20 }, () => chunk) // ~2MB, no proxyKey set so adminAuth passes + const req = mockStreamingReq(chunks, { method: "PUT" }) + const res = mockRes() + await zp.handleApiConfig(req, res) + assert.equal(res.state.status, 413) + assert.ok(req.pulled.count < chunks.length, `expected early abort, pulled ${req.pulled.count}/${chunks.length} chunks`) + assert.equal(req.pulled.finished, false, "generator must not run to completion") + }) }) describe("sanitize / maskKey", () => { diff --git a/zen-proxy.mjs b/zen-proxy.mjs index 127c996..481456c 100644 --- a/zen-proxy.mjs +++ b/zen-proxy.mjs @@ -1139,6 +1139,9 @@ async function handleApiConfig(req, res) { log("config updated via UI") return json(res, 200, { config: sanitize(config) }) } catch (err) { + if (err && err.statusCode === 413) { + return json(res, 413, { error: { type: "invalid_request_error", message: "request body too large" } }) + } return json(res, 400, { error: err.message }) } } @@ -1254,6 +1257,9 @@ async function handleTest(req, res) { detail: gated ? "free tier accepts real agent requests only — this probe can't verify it" : detail, }) } catch (err) { + if (err && err.statusCode === 413) { + return json(res, 413, { error: { type: "invalid_request_error", message: "request body too large" } }) + } json(res, 400, { ok: false, error: err.message }) } }