Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions test/zen-proxy.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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" } })
Expand Down Expand Up @@ -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", () => {
Expand Down
25 changes: 19 additions & 6 deletions zen-proxy.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand All @@ -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" } })
}

Expand Down Expand Up @@ -1132,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 })
}
}
Expand Down Expand Up @@ -1247,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 })
}
}
Expand Down
Loading