Skip to content

Commit 51392f3

Browse files
feat(web): bound server-function request payloads (#3115, #3119)
Nothing bounded what a call could send: a 32 MB body was buffered and decoded before any application code could decline it, and a modest argument list forced a range error out of any function when spread into the call. bodySizeLimit (default 1 MiB) refuses an oversized POST body or ?args= encoding with 413 before decoding — a declared Content-Length is checked up front, a chunked body is buffered under the cap — and maxArguments (default 1000) refuses an oversized argument list with 400. Both configurable server-wide and per handler; Infinity removes a bound. The decode depth cap now holds whichever body format the caller selects (#3119): plain JSON walked into a bare JSON.parse with no ceiling where the framed codec enforced 64 levels. The same ceiling now applies to both, iteratively so the check cannot re-create the overflow it prevents, and a non-array argument encoding answers 400 in either format. The open-gaps marker for #3119 comes off; the test stays as an ordinary guard. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 21976b1 commit 51392f3

4 files changed

Lines changed: 376 additions & 10 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": minor
3+
---
4+
5+
Bound what a server-function call may send (#3115). The argument payload is buffered and decoded before dispatch, so its cost was paid before application code could decline it: a 32 MB body was accepted and decoded, and a modest argument list forced a range error out of any function when spread into the call. `bodySizeLimit` (default 1 MiB, matching the neighbours' server-action ceilings) now refuses an oversized POST body or `?args=` encoding with 413 before any decoding — a declared Content-Length is checked up front, a chunked body is buffered under the cap — and `maxArguments` (default 1000) refuses an oversized argument list with 400. Both are configurable through `configureServerFunctionsServer` and per-handler options; `Infinity` removes a bound. The decode depth cap also now holds whichever body format the caller selects (#3119): the plain-JSON format walked into a bare `JSON.parse` with no ceiling, where the framed codec enforced 64 levels — the same ceiling now applies to both, and a non-array argument encoding in either body format answers 400 instead of surfacing as the function's own failure.

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

Lines changed: 170 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -338,6 +338,24 @@ export interface ServerFunctionsServerConfig {
338338
* `decodeResponse` sees them too.
339339
*/
340340
codec?: JSONCodecOptions;
341+
/**
342+
* Upper bound, in bytes, on a call's argument payload — the POST body,
343+
* or the `?args=` query encoding. The payload is buffered and decoded
344+
* before dispatch, so its cost is paid before application code can
345+
* decline it; the bound is enforced up front and a request over it is
346+
* refused with `413` before any decoding (#3115). Raise it for functions
347+
* that accept large uploads, or set `Infinity` to remove the bound.
348+
* @default 1_048_576 (1 MiB)
349+
*/
350+
bodySizeLimit?: number;
351+
/**
352+
* Upper bound on the number of arguments a call may carry. The decoded
353+
* argument array is spread into the function call, so an unbounded list
354+
* forces a range error out of any function regardless of what it does;
355+
* past the bound the request is refused with `400` (#3115).
356+
* @default 1000
357+
*/
358+
maxArguments?: number;
341359
}
342360

343361
/**
@@ -467,6 +485,16 @@ export interface HandleServerFunctionOptions {
467485
csrf?: boolean | ServerFunctionCSRFOptions;
468486
/** Overrides the configured codec options for this handler. */
469487
codec?: JSONCodecOptions;
488+
/**
489+
* Overrides the configured argument payload bound for this handler (see
490+
* `ServerFunctionsServerConfig.bodySizeLimit`).
491+
*/
492+
bodySizeLimit?: number;
493+
/**
494+
* Overrides the configured argument count bound for this handler (see
495+
* `ServerFunctionsServerConfig.maxArguments`).
496+
*/
497+
maxArguments?: number;
470498
}
471499

472500
export interface ServerFunctionRequestCall {
@@ -498,7 +526,9 @@ const config = {
498526
transformDirectResult: undefined,
499527
handleNoJS: undefined,
500528
endpoint: "/_server",
501-
csrf: true
529+
csrf: true,
530+
bodySizeLimit: 1_048_576,
531+
maxArguments: 1000
502532
}; /**
503533
* Configures the server runtime. Call once at server startup, before
504534
* handling requests. Only needed when deviating from the defaults (custom
@@ -535,7 +565,9 @@ export function configureServerFunctionsServer({
535565
handleNoJS,
536566
endpoint,
537567
csrf,
538-
codec
568+
codec,
569+
bodySizeLimit,
570+
maxArguments
539571
} = {}) {
540572
if (provideEvent !== undefined) config.provideEvent = provideEvent;
541573
if (wrapInvocation !== undefined) config.wrapInvocation = wrapInvocation;
@@ -547,6 +579,8 @@ export function configureServerFunctionsServer({
547579
if (endpoint !== undefined) config.endpoint = endpoint;
548580
if (csrf !== undefined) config.csrf = csrf;
549581
if (codec !== undefined) configureServerFunctionsCodec(codec);
582+
if (bodySizeLimit !== undefined) config.bodySizeLimit = bodySizeLimit;
583+
if (maxArguments !== undefined) config.maxArguments = maxArguments;
550584
}
551585

552586
// Named flight-data collectors, keyed by source id. The unnamed
@@ -963,6 +997,68 @@ function resolveAddress(url) {
963997
return parseServerFunctionAddress(url.pathname, config.endpoint);
964998
}
965999

1000+
// Mirrors the codec's JSON_CODEC_DEPTH_LIMIT (serialization/serializer-decode):
1001+
// the seroval path enforces it because payloads may come from an untrusted
1002+
// peer, but the body FORMAT is the caller's choice — selecting the plain
1003+
// JSON format handed the payload to a bare JSON.parse and skipped the cap
1004+
// entirely (#3119). This applies the same ceiling to that road.
1005+
const DECODE_DEPTH_LIMIT = 64;
1006+
1007+
/**
1008+
* Breadth-first depth check over a decoded plain-JSON payload. Iterative on
1009+
* purpose: the attack input is deep nesting, and a recursive walk would
1010+
* re-create the stack overflow the cap exists to prevent. `JSON.parse`
1011+
* output is acyclic and prototype-free, so plain enumeration covers it.
1012+
*/
1013+
function assertDecodeDepth(value) {
1014+
let level = [value];
1015+
for (let depth = 0; level.length > 0; depth++) {
1016+
if (depth > DECODE_DEPTH_LIMIT) {
1017+
throw new TypeError("Server function arguments exceed the decode depth limit");
1018+
}
1019+
const next = [];
1020+
for (const node of level) {
1021+
if (node === null || typeof node !== "object") continue;
1022+
if (Array.isArray(node)) {
1023+
for (const child of node) next.push(child);
1024+
} else {
1025+
for (const key of Object.keys(node)) next.push(node[key]);
1026+
}
1027+
}
1028+
level = next;
1029+
}
1030+
}
1031+
1032+
/**
1033+
* Buffers a POST body that declared no length (chunked transfer), refusing
1034+
* once it runs past the limit — a declared length is enforced by the HTTP
1035+
* server's own framing and is checked against the limit before this runs.
1036+
* Returns a replacement Request carrying the buffered body (everything
1037+
* else, signal included, is inherited), or `null` past the limit.
1038+
*/
1039+
async function bufferBodyWithin(request, limit) {
1040+
const reader = request.clone().body.getReader();
1041+
const chunks = [];
1042+
let total = 0;
1043+
for (;;) {
1044+
const { done, value } = await reader.read();
1045+
if (done) break;
1046+
total += value.byteLength;
1047+
if (total > limit) {
1048+
reader.cancel().catch(() => {});
1049+
return null;
1050+
}
1051+
chunks.push(value);
1052+
}
1053+
const body = new Uint8Array(total);
1054+
let offset = 0;
1055+
for (const chunk of chunks) {
1056+
body.set(chunk, offset);
1057+
offset += chunk.byteLength;
1058+
}
1059+
return new Request(request, { body });
1060+
}
1061+
9661062
async function parseArguments(request, url, scripted, codec) {
9671063
const parsed = [];
9681064
// Bound arguments arrive on the url for GET calls, no-JS form posts, and
@@ -978,7 +1074,15 @@ async function parseArguments(request, url, scripted, codec) {
9781074
// integrations building urls by hand). Anything that is not an argument
9791075
// array is a malformed request, which dispatch answers as one: the query
9801076
// reserves `args`, so a caller that sends it sent an encoding.
981-
const result = args.startsWith(";0x") ? await deserializeString(args, codec) : JSON.parse(args);
1077+
let result;
1078+
if (args.startsWith(";0x")) {
1079+
result = await deserializeString(args, codec);
1080+
} else {
1081+
// The framed codec enforces its own depth cap; bare JSON must not be
1082+
// the uncapped alternative (#3119).
1083+
result = JSON.parse(args);
1084+
assertDecodeDepth(result);
1085+
}
9821086
if (!Array.isArray(result)) {
9831087
throw new TypeError("Server function arguments must encode an array");
9841088
}
@@ -997,8 +1101,16 @@ async function parseArguments(request, url, scripted, codec) {
9971101
}
9981102
if (request.method === "POST" && request.body !== null) {
9991103
const decoded = await extractBody(request.clone(), codec);
1000-
// Both argument-array encodings: codec-framed and plain JSON.
1104+
// Both argument-array encodings: codec-framed and plain JSON. The
1105+
// framed codec enforces its own depth cap during decode; bare JSON
1106+
// must not be the uncapped alternative (#3119). Either way the payload
1107+
// is spread into the call, so anything but an array is malformed — a
1108+
// 400, not a range error out of the function.
10011109
if (bodyFormat === BodyFormat.Serialized || bodyFormat === BodyFormat.Json) {
1110+
if (bodyFormat === BodyFormat.Json) assertDecodeDepth(decoded);
1111+
if (!Array.isArray(decoded)) {
1112+
throw new TypeError("Server function arguments must encode an array");
1113+
}
10021114
return decoded;
10031115
}
10041116
parsed.push(decoded);
@@ -1951,6 +2063,47 @@ export async function handleServerFunctionRequest(request, options = {}) {
19512063
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
19522064
}
19532065

2066+
// The argument payload is buffered and decoded before dispatch, so its
2067+
// cost is paid before application code can decline it — bound it before
2068+
// paying (#3115). A declared Content-Length is trusted (the HTTP server's
2069+
// framing enforces it); a body without one is buffered under the cap. The
2070+
// `?args=` encoding is the same payload on a different road, so it gets
2071+
// the same ceiling.
2072+
const bodySizeLimit =
2073+
options.bodySizeLimit !== undefined ? options.bodySizeLimit : config.bodySizeLimit;
2074+
const argsEncoding = url.searchParams.get("args");
2075+
if (argsEncoding !== null && argsEncoding.length > bodySizeLimit) {
2076+
const response = new Response(
2077+
DEV ? "Server function arguments exceed the configured bodySizeLimit" : null,
2078+
{ status: 413 }
2079+
);
2080+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
2081+
}
2082+
if (method === "POST" && request.body !== null && bodySizeLimit !== Infinity) {
2083+
const declared = Number(request.headers.get("content-length"));
2084+
if (declared > bodySizeLimit) {
2085+
const response = new Response(
2086+
DEV ? "Server function request body exceeds the configured bodySizeLimit" : null,
2087+
{ status: 413 }
2088+
);
2089+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
2090+
}
2091+
if (!declared) {
2092+
const bounded = await bufferBodyWithin(request, bodySizeLimit);
2093+
if (bounded === null) {
2094+
const response = new Response(
2095+
DEV ? "Server function request body exceeds the configured bodySizeLimit" : null,
2096+
{ status: 413 }
2097+
);
2098+
return finalizeTransportResponse(
2099+
protectsRequest ? withCSRFVary(response) : response,
2100+
method
2101+
);
2102+
}
2103+
request = bounded;
2104+
}
2105+
}
2106+
19542107
const event = options.createEvent ? options.createEvent(request) : { request, locals: {} };
19552108
const provide = options.provideEvent || provideEvent;
19562109
const flightHook =
@@ -2005,6 +2158,19 @@ export async function handleServerFunctionRequest(request, options = {}) {
20052158
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
20062159
}
20072160

2161+
// The decoded array is spread into the call, so an unbounded argument
2162+
// list forces a range error out of ANY function regardless of what it
2163+
// does (#3115). Refused as the malformed request it is.
2164+
const maxArguments =
2165+
options.maxArguments !== undefined ? options.maxArguments : config.maxArguments;
2166+
if (parsed.length > maxArguments) {
2167+
const response = new Response(
2168+
DEV ? "Server function call exceeds the configured maxArguments" : null,
2169+
{ status: 400 }
2170+
);
2171+
return finalizeTransportResponse(protectsRequest ? withCSRFVary(response) : response, method);
2172+
}
2173+
20082174
// What the fold needs to build a body itself, and what a result transform
20092175
// needs to know to leave one for it. The call's identity (`id`, parsed
20102176
// `args`) rides along, mirroring `transformDirectResult`'s context — a

packages/web/test/server/server-functions-open-gaps.spec.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,8 @@
44
* `test/lifecycle-matrix/MATRIX.md`: the marker is the point — the suite
55
* stays green while the gap is open and turns red the day it closes, at
66
* which point the marker comes off and the test becomes an ordinary guard.
7-
* Each carries the issue that tracks it: #3117, #3118, #3119.
7+
* Each carries the issue that tracks it: #3117, #3118 (open); #3119
8+
* (closed by #3115's request bounds — its test is an ordinary guard now).
89
*
910
* Like the other server-function specs, these run against the built
1011
* bundles (server-functions/dist/*, wired up in vite.config.server.mjs).
@@ -139,11 +140,12 @@ describe("a streamed result nobody is reading", () => {
139140
});
140141

141142
describe("the decode depth cap", () => {
142-
// GAP (#3119): the cap is opt-out. `depthLimit: 64` exists "because payloads may
143-
// come from an untrusted peer" and guards the seroval path only, while
144-
// the body format is chosen by the CALLER — selecting the JSON format
145-
// hands the payload to a bare JSON.parse and skips the cap entirely.
146-
test.fails("holds whichever body format the caller selects", async () => {
143+
// Closed (#3119, with #3115's request bounds): the plain-JSON format now
144+
// walks the decoded payload against the same 64-level ceiling the seroval
145+
// path enforces, so the caller's format choice no longer opts out of the
146+
// cap. Kept here as an ordinary guard; the full bounds matrix lives in
147+
// server-functions-request-bounds.spec.tsx.
148+
test("holds whichever body format the caller selects", async () => {
147149
registerServerFunction("gap-depth", async (value: unknown) => {
148150
let depth = 0;
149151
let cursor: any = value;

0 commit comments

Comments
 (0)