fix(web): judge arguments, results and redirect targets by what they are - #3203
Merged
ryansolid merged 17 commits intoSep 2, 2026
Merged
Conversation
Seven defects across the server-function guards, five of them introduced by the guards themselves (solidjs#3168, solidjs#3170, solidjs#3175, solidjs#3176). Every fix removes a special case rather than adding one — 45 lines of code in, 28 out. - The argument walk stops for no prototype (solidjs#3200) and strips the whole unsafe key set (solidjs#3202). It mutates in place and rebuilds nothing, so the non-plain-prototype `continue` was never guarding a rebuild; it only hid a payload under a carrier the codec revives with own properties. `constructor` joins `__proto__` because a recursive merge reaches Object.prototype through it. - The guard shell carries only the flag that reaches the wire (solidjs#3196, solidjs#3198). Replicating a frozen source's `writable`/`configurable` made the write-back illegal; pinning them true lost `enumerable: false` and put a deliberately hidden field on the wire. One descriptor shape replaces the two-branch conditional. - The scheme floor asks the URL parser instead of a regex (solidjs#3201). A parser strips ASCII tab and newline before it begins, so `java<TAB>script:` read as scheme-less to the grammar and as `javascript:` to every consumer. The masked and no-JS roads already resolved, which is why neither was fooled. - The event is awaited only when it is genuinely a promise, and a failure is answered rather than thrown (solidjs#3199). Awaiting anything wearing a `then` parked the request forever on a lazy-locals proxy and starved the event loop on a self-resolving one. An `async createEvent` still works. - `Content-Length` is never forwarded onto a body the transport composed (solidjs#3197). The declared length described the source; the body is ours, so the answer arrived truncated at the socket — 13 of 815 bytes over a real connection. RFC 9110 §8.6. Tests are table-driven over the adjacent shapes each fix must close and the ones it must leave alone, so a later change cannot move a hole sideways: every descriptor combination, every carrier the codec revives, every whitespace a URL parser strips in every position on every road, every thenable spelling, and every producer that merges author headers onto an encoded body. Suite: 586 -> 626 passing.
🦋 Changeset detectedLatest commit: afed2e0 The changes in this PR will be included in the next version bump. This PR includes changesets to release 11 packages
Not sure what this means? Click here to learn what changesets are. Click here if you're a maintainer who wants to add another changeset to this PR |
Disable wall-clock diagnostics across benchmark-shaped structural tests so coverage and runner contention cannot produce unrelated failures. Co-authored-by: Cursor <cursoragent@cursor.com>
Merging this PR will not alter performance
Comparing Footnotes
|
Document and ratchet the two app scenarios that retain the in-place class mutation fix. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com>
… ran Three gaps a mutation pass found in the tests, plus one simplification. The solidjs#3200 fix had NO coverage: restoring the removed `continue` left the whole suite green. The reachable shape is a plain object one level UNDER a non-plain carrier — an unsafe key ON an `Error` is dropped by the codec at encode time, so the obvious test cannot fail. The added row encodes a real `Error` carrying the payload; with the fix reverted it reports `payloadKeys=["constructor","n"]` and `Object.prototype.polluted="viaCarrier"`. Two of the five tables passed when the handler dispatched nothing: `ship()` collapsed "threw", "500" and "no header" into one "refused", and the content-length rows never mentioned status, so `absent / 0 bytes` was a pass. Both now carry the observation that makes them fail — `ran=1` and the expected status. With a handler that answers 500 as its first statement, all five files now go red (41 tests) instead of two staying green. `Headers` rejects CR/LF in a value before the scheme floor is reached, so that protection comes from the platform rather than from the code under test. The helper now reports it as its own outcome instead of counting it as a refusal the floor made. Also `Object.prototype.hasOwnProperty.call` -> `Object.hasOwn` in the strip walk: same shadow-proofing (verified against a payload that shadows `hasOwnProperty`), one line shorter, and well inside the platform floor this package already assumes elsewhere.
…s-mutation fix(web): track in-place class mutations
… params Implementation review, all three measured against the branch. `Content-Length` was one name in three hand-placed checks, and the class it belongs to is wider: `Content-Encoding` still rode onto every body the transport composed and never compressed — measured `gzip` surviving on both `respond()` and a returned Response. One `COMPOSED_BODY_FRAMING` set in `response.ts`, the leaf module all three sites already import, replaces the three checks and covers `transfer-encoding` too. It also makes the `content-length` clause in `fillsStubGap` reachable-but-redundant, so that special case goes. The `base` threaded into `refusedTargetScheme` was doing nothing: across 19 targets x 3 real bases the verdict is identical to a constant stand-in, because an absolute scheme always beats the base and a relative target always inherits an http(s) one. That removes a parameter, a signature change and a threaded argument, so the fix stops touching the dispatch tail entirely. `Object.hasOwn(v, key)` before `delete v[key]` changes no outcome — delete on an absent or inherited key is a no-op, and on a non-configurable own key both spellings throw the same TypeError. The guard was ceremony. The Content-Encoding rows are pinned: dropping the name from the set reddens them.
Preserve the authored tuple alongside its attached wrapper so unrelated spread updates do not reorder or churn stable listeners. Co-authored-by: Cursor <cursoragent@cursor.com>
…t-handler-tuple fix(web): preserve reusable bound event tuples
Seven defects across the server-function guards, five of them introduced by the guards themselves (solidjs#3168, solidjs#3170, solidjs#3175, solidjs#3176). Every fix removes a special case rather than adding one — 45 lines of code in, 28 out. - The argument walk stops for no prototype (solidjs#3200) and strips the whole unsafe key set (solidjs#3202). It mutates in place and rebuilds nothing, so the non-plain-prototype `continue` was never guarding a rebuild; it only hid a payload under a carrier the codec revives with own properties. `constructor` joins `__proto__` because a recursive merge reaches Object.prototype through it. - The guard shell carries only the flag that reaches the wire (solidjs#3196, solidjs#3198). Replicating a frozen source's `writable`/`configurable` made the write-back illegal; pinning them true lost `enumerable: false` and put a deliberately hidden field on the wire. One descriptor shape replaces the two-branch conditional. - The scheme floor asks the URL parser instead of a regex (solidjs#3201). A parser strips ASCII tab and newline before it begins, so `java<TAB>script:` read as scheme-less to the grammar and as `javascript:` to every consumer. The masked and no-JS roads already resolved, which is why neither was fooled. - The event is awaited only when it is genuinely a promise, and a failure is answered rather than thrown (solidjs#3199). Awaiting anything wearing a `then` parked the request forever on a lazy-locals proxy and starved the event loop on a self-resolving one. An `async createEvent` still works. - `Content-Length` is never forwarded onto a body the transport composed (solidjs#3197). The declared length described the source; the body is ours, so the answer arrived truncated at the socket — 13 of 815 bytes over a real connection. RFC 9110 §8.6. Tests are table-driven over the adjacent shapes each fix must close and the ones it must leave alone, so a later change cannot move a hole sideways: every descriptor combination, every carrier the codec revives, every whitespace a URL parser strips in every position on every road, every thenable spelling, and every producer that merges author headers onto an encoded body. Suite: 586 -> 626 passing.
… ran Three gaps a mutation pass found in the tests, plus one simplification. The solidjs#3200 fix had NO coverage: restoring the removed `continue` left the whole suite green. The reachable shape is a plain object one level UNDER a non-plain carrier — an unsafe key ON an `Error` is dropped by the codec at encode time, so the obvious test cannot fail. The added row encodes a real `Error` carrying the payload; with the fix reverted it reports `payloadKeys=["constructor","n"]` and `Object.prototype.polluted="viaCarrier"`. Two of the five tables passed when the handler dispatched nothing: `ship()` collapsed "threw", "500" and "no header" into one "refused", and the content-length rows never mentioned status, so `absent / 0 bytes` was a pass. Both now carry the observation that makes them fail — `ran=1` and the expected status. With a handler that answers 500 as its first statement, all five files now go red (41 tests) instead of two staying green. `Headers` rejects CR/LF in a value before the scheme floor is reached, so that protection comes from the platform rather than from the code under test. The helper now reports it as its own outcome instead of counting it as a refusal the floor made. Also `Object.prototype.hasOwnProperty.call` -> `Object.hasOwn` in the strip walk: same shadow-proofing (verified against a payload that shadows `hasOwnProperty`), one line shorter, and well inside the platform floor this package already assumes elsewhere.
… params Implementation review, all three measured against the branch. `Content-Length` was one name in three hand-placed checks, and the class it belongs to is wider: `Content-Encoding` still rode onto every body the transport composed and never compressed — measured `gzip` surviving on both `respond()` and a returned Response. One `COMPOSED_BODY_FRAMING` set in `response.ts`, the leaf module all three sites already import, replaces the three checks and covers `transfer-encoding` too. It also makes the `content-length` clause in `fillsStubGap` reachable-but-redundant, so that special case goes. The `base` threaded into `refusedTargetScheme` was doing nothing: across 19 targets x 3 real bases the verdict is identical to a constant stand-in, because an absolute scheme always beats the base and a relative target always inherits an http(s) one. That removes a parameter, a signature change and a threaded argument, so the fix stops touching the dispatch tail entirely. `Object.hasOwn(v, key)` before `delete v[key]` changes no outcome — delete on an absent or inherited key is a no-op, and on a non-configurable own key both spellings throw the same TypeError. The guard was ceremony. The Content-Encoding rows are pinned: dropping the name from the set reddens them.
Close cross-realm, descriptor, argument, and SSR response gaps found while auditing the server-function transport. Co-authored-by: Cursor <cursoragent@cursor.com>
Co-authored-by: Cursor <cursoragent@cursor.com> # Conflicts: # .changeset/server-function-guard-simplification.md # packages/web/server-functions/src/server.ts # packages/web/test/server/server-functions-event-hook.spec.tsx # packages/web/test/server/server-functions-proto-keys.spec.tsx # packages/web/test/server/server-functions-result-descriptors.spec.tsx
Avoid exposing the shared framing-header set while preserving the audited server behavior. Co-authored-by: Cursor <cursoragent@cursor.com>
This was referenced Sep 2, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #3196, #3197, #3198, #3199, #3200, #3201, #3202.
Seven defects across the server-function guards. Five were introduced by the guards themselves — #3168, #3170, #3175 and #3176 — which is the reason to fix them together: each one is a special case that turned out to be guarding nothing, and removing it closes the defect.
48 lines of code in, 19 out, across three commits: the fixes, a test pass that closed a hole in its own coverage, and a simplification pass that replaced three hand-placed one-name checks with one named set.
The five changes
The argument walk stops for no prototype (#3200) and strips the whole unsafe key set (#3202).
} else { - const proto = Object.getPrototypeOf(v); - if (proto !== Object.prototype && proto !== null) continue; - if (Object.prototype.hasOwnProperty.call(v, "__proto__")) { - delete v["__proto__"]; - } + for (const key of UNSAFE_ARGUMENT_KEYS) { + if (Object.prototype.hasOwnProperty.call(v, key)) delete v[key]; + } for (const key of Object.keys(v)) stack.push(v[key]); }stripOwnProtoKeysmutates in place and rebuilds nothing, so thecontinuewas never protecting a rebuild — it only stopped the walk, which is the whole of #3200: a payload under a codec-revivedErrorkept its keys.constructorjoins__proto__because a recursive merge reachesObject.prototypethroughconstructor.prototype, and #3168's rationale only covered the shallow spelling.The guard shell carries only the flag that reaches the wire (#3196, #3198). It is scratch the codec reads once and the author never holds, so replicating a frozen source's
writable/configurablebought nothing and made the write-back illegal; pinning them true lostenumerable: falseand put a deliberately hidden field on the wire. One descriptor shape replaces a two-branch conditional.The scheme floor asks the URL parser instead of a regex (#3201). A parser strips ASCII tab and newline before it begins, so
java<TAB>script:read as scheme-less to the grammar and asjavascript:to every consumer. The masked and no-JS roads already resolved throughnew URL(), which is why neither was ever fooled — this makes the third road do what the other two do, and deletes a hand-written scheme grammar. No base is threaded: across 19 targets x 3 real bases the verdict is identical to a constant stand-in, because an absolute scheme always beats the base and a relative target always inherits an http(s) one.The event is awaited only when it is genuinely a promise, and a failure is answered rather than thrown (#3199). Awaiting anything wearing a
thenparked the request forever on a lazy-locals proxy and starved the whole event loop on a self-resolving one. Anasync createEvent— #3170's own case — returns a real promise and still works.No framing header is forwarded onto a body the transport composed (#3197). The declared length described the source; the body is ours. Over a real socket the answer arrived as 13 of 815 bytes.
Content-Lengthturned out to be one name in a class. Measured on the first version of this branch,Content-Encoding: gzipstill rode onto every composed body — telling the peer to decompress bytes nobody compressed — on bothrespond()and a returnedResponse. So the three hand-placed one-name checks became oneCOMPOSED_BODY_FRAMINGset inresponse.ts, the leaf module all three sites already import, coveringtransfer-encodingtoo and making thecontent-lengthspecial case infillsStubGapredundant.Tests
Five spec files, 40 tests, table-driven over the adjacent shapes — deliberately, because three of these seven defects were a previous fix closing one shape while the hole moved to the shape next door. Each table asserts both halves:
Mapkeyed by the literal string"__proto__", a field namedconstructorName, relative and cross-origin and protocol-relative redirect targets, a genuinely asynccreateEvent, and aContent-Lengththe runtime did not invalidateEvery one of the five files goes red (41 tests) against a handler that answers 500 as its first statement — two of them did not, until the second commit put
ran=1and the expected status into the row.No existing test changed.
Deliberately not included
X-Content-Rawpassthrough keeps its author-suppliedContent-Length: there the runtime forwards the author's own body, so §8.6's "known to be incorrect" does not apply.decodeFlashCookieand the client'sdecodeResponsestill have no key strip, so__proto__survives both. That is Decoded arguments keep __proto__ as an own key, so an ordinary Object.assign merge in a handler re-prototypes the result #3168's defect one road over and wants its own decision about scope — happy to fold it in here or file it separately.Why
deleteand not a copy — measuredThe value handed to
stripOwnProtoKeyscomes straight fromJSON.parse(args)or the codec decode, one or two lines earlier (server.ts:1204,:1230). Nothing else holds a reference, so mutating it is safe and copying the graph on every request would be pure cost.The alternative that looks cleaner — stripping inside
JSON.parsewith a reviver — is three times slower, because a reviver runs a callback for every key of every object whiledeletetouches only the rare object that carries one:deletedoes move the object off V8's fast path, and that is real but small: 16 ms vs 12 ms over two million property reads, and only for objects that actually had the key.What the strip is and is not protecting against — measured
Three of the four idiomatic ways to copy a decoded object are already safe, which narrows this change and belongs in the record:
Object.assignis unsafe because it merges through[[Set]], which wakes the inherited setter; spread andfromEntriesdefine rather than set. Theconstructorroad narrows identically —t[k] = vreachesObject.prototype,definePropertydoes not.So this part of the PR protects application code from a footgun the application can avoid by writing spread. That is a boundary decision, and reviewers should take it as one rather than as a clear-cut fix. The argument for the runtime owning it is that this boundary is already hardened in other ways — depth cap, RegExp revival disabled, argument count bounded. The argument against is that the strip is lossy: a field honestly named
constructordisappears with no error. Refusing with a 400 that names the key would keep the protection without the silent loss, and I am happy to switch to that if it is the preferred shape.Two claims from review that did not survive checking
redirect()andreload()still forwarding a staleContent-Length— refuted. Measured on this branch:content-length=nullfor both. The merge filter already covers them.URL.parse()would remove the try/catch — rejected on evidence rather than taste. It is Node 22.1 / Safari 18, while this package still carries a fallback for aHeadersimplementation withoutgetSetCookie(≈ Node 19.7), and the twin checkdecodeRedirectHeaderValuelives inshared.ts, which ships to browsers — the two halves of one floor would end up on different idioms.