Skip to content

Commit 929642b

Browse files
fix: negative Content-Length no longer bypasses bodySizeLimit (#3153)
Number("-1") is -1: neither `> limit` nor falsy, so a negative declared length satisfied neither guard and the body streamed into the decoder uncapped (195x the configured limit in the report). Validate the string form — only a plain digit string is a declaration worth trusting to the HTTP server's framing — and route anything non-conforming through the bounded buffer alongside undeclared bodies. Unreachable on stock node:http (its parser refuses the header first); hardening for hand-rolled adapters, laxer edge parsers, and rewriting proxies. Reported by @frenzzy with the guard-by-guard analysis and fix. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 08b4d1c commit 929642b

3 files changed

Lines changed: 71 additions & 6 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@solidjs/web": patch
3+
---
4+
5+
Trust only a conforming (digit-string) Content-Length in the bodySizeLimit guard: a negative declaration (`-1`) satisfied neither the over-limit check nor the undeclared-body buffer path and streamed the body into the decoder uncapped; non-conforming declarations now route through the bounded buffer (#3153)

packages/web/server-functions/src/server.ts

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2382,10 +2382,11 @@ export async function handleServerFunctionRequest(request, options = {}) {
23822382

23832383
// The argument payload is buffered and decoded before dispatch, so its
23842384
// cost is paid before application code can decline it — bound it before
2385-
// paying (#3115). A declared Content-Length is trusted (the HTTP server's
2386-
// framing enforces it); a body without one is buffered under the cap. The
2387-
// `?args=` encoding is the same payload on a different road, so it gets
2388-
// the same ceiling.
2385+
// paying (#3115). A CONFORMING declared Content-Length is trusted (the
2386+
// HTTP server's framing enforces it); a body without one — or with a
2387+
// declaration that isn't a plain digit string (#3153) — is buffered under
2388+
// the cap. The `?args=` encoding is the same payload on a different road,
2389+
// so it gets the same ceiling.
23892390
const bodySizeLimit =
23902391
options.bodySizeLimit !== undefined ? options.bodySizeLimit : config.bodySizeLimit;
23912392
const argsEncoding = url.searchParams.get("args");
@@ -2397,15 +2398,24 @@ export async function handleServerFunctionRequest(request, options = {}) {
23972398
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
23982399
}
23992400
if (method === "POST" && request.body !== null && bodySizeLimit !== Infinity) {
2400-
const declared = Number(request.headers.get("content-length"));
2401+
// Trust only a CONFORMING declaration — digits, per RFC 9110 §8.6. The
2402+
// bare Number() parse lost that information: Number("-1") is -1, which
2403+
// is neither `> limit` nor falsy, so a negative declaration satisfied
2404+
// NEITHER guard and the body streamed into the decoder uncapped
2405+
// (#3153). A stock node:http parser refuses it first, but an adapter
2406+
// that builds the Request itself, or a rewriting proxy, delivers it
2407+
// here — anything non-conforming now routes through the bounded buffer
2408+
// alongside the undeclared bodies.
2409+
const raw = request.headers.get("content-length");
2410+
const declared = raw !== null && /^\d+$/.test(raw) ? Number(raw) : NaN;
24012411
if (declared > bodySizeLimit) {
24022412
const response = new Response(
24032413
DEV ? "Server function request body exceeds the configured bodySizeLimit" : null,
24042414
{ status: 413 }
24052415
);
24062416
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
24072417
}
2408-
if (!declared) {
2418+
if (!(declared > 0)) {
24092419
const bounded = await bufferBodyWithin(request, bodySizeLimit);
24102420
if (bounded === null) {
24112421
const response = new Response(

packages/web/test/server/server-functions-request-bounds.spec.tsx

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -88,6 +88,56 @@ describe("the body size bound", () => {
8888
expect(response.status).toBe(200);
8989
});
9090

91+
it("routes a non-conforming Content-Length through the cap instead of trusting it (#3153)", async () => {
92+
// Number("-1") is -1: neither `> limit` nor falsy, so a negative
93+
// declaration satisfied neither guard and the body streamed in uncapped
94+
// — 195× the configured limit in the report. A stock node:http parser
95+
// refuses the header first; an adapter that builds the Request itself,
96+
// or a proxy that rewrites the header (a decompressor preserving the
97+
// compressed length), delivers it here.
98+
const fn = vi.fn(async (s: string) => s.length);
99+
registerServerFunction("bounds-negative-length", fn);
100+
const oversized = JSON.stringify(["x".repeat(200_000)]);
101+
102+
// (" 5 " is unreachable here: the Headers layer itself trims OWS, so the
103+
// guard sees a conforming "5")
104+
for (const raw of ["-1", "+5", "abc,def"]) {
105+
const response = await handleServerFunctionRequest(
106+
new Request("https://app.example/_server/data/bounds-negative-length", {
107+
method: "POST",
108+
body: oversized,
109+
headers: {
110+
"Sec-Fetch-Site": "same-origin",
111+
"X-Server-Function-Instance": "server-function:test",
112+
"content-length": raw,
113+
[BODY_FORMAT_HEADER]: JSON_FORMAT
114+
}
115+
}),
116+
{ bodySizeLimit: 1024 }
117+
);
118+
expect([raw, response.status]).toEqual([raw, 413]);
119+
}
120+
expect(fn).not.toHaveBeenCalled();
121+
122+
// control: a conforming declaration within the limit still dispatches
123+
const body = JSON.stringify(["ok"]);
124+
const accepted = await handleServerFunctionRequest(
125+
new Request("https://app.example/_server/data/bounds-negative-length", {
126+
method: "POST",
127+
body,
128+
headers: {
129+
"Sec-Fetch-Site": "same-origin",
130+
"X-Server-Function-Instance": "server-function:test",
131+
"content-length": String(body.length),
132+
[BODY_FORMAT_HEADER]: JSON_FORMAT
133+
}
134+
}),
135+
{ bodySizeLimit: 1024 }
136+
);
137+
expect(accepted.status).toBe(200);
138+
expect(fn).toHaveBeenCalledTimes(1);
139+
});
140+
91141
it("applies the same ceiling to the ?args= encoding", async () => {
92142
const response = await handleServerFunctionRequest(urlArgs(JSON.stringify(["y"])), {
93143
bodySizeLimit: 4

0 commit comments

Comments
 (0)