@@ -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
472500export 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+
9661062async 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
0 commit comments