You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
A server function result decoded on the client keeps __proto__, constructor and prototype as own keys, and one ordinary merge of that value writes onto the page's shared Object.prototype. This is the same sink #3168/#3200/#3202 closed for arguments; the guard was attached to a caller of the decoder (parseArguments in server.ts) rather than to extractBody, the boundary both legs decode through, so only the request half is covered. The flash cookie is a third decode road building a graph from bytes a peer sent, and it applies no strip at all — worse, the flash encoder writes the key itself, so that road pollutes with no hostile party anywhere in the picture. Measured against f0f7531b (@solidjs/web 2.0.0-rc.6): the argument leg is clean, the response leg (both wire formats) and the flash leg are not.
This report merges two symptoms that were found separately, so both are searchable here:
response-leg prototype pollution — decodeResponse / createServerReference hand __proto__ to the caller with the own descriptor intact;
flash-cookie decode hygiene — decodeFlashCookie hands input and result to the render unstripped.
They are one defect: one guard, three decode roads (POST argument body, client response body, flash cookie). Fixing only the response leg leaves the cookie leg polluting the same Object.prototype, and fixing them separately produces two copies of the same walk to keep in sync.
Reproduction
repro.mjs, dropped in packages/web/ and run against the built bundles (pnpm -F @solidjs/web build first). The handler is the most ordinary server function there is — it returns a document a user wrote, parsed with JSON.parse. No hostile server, no hand-built frame.
import{AsyncLocalStorage}from"node:async_hooks";import{handleServerFunctionRequest,registerServerFunction,decodeFlashCookie,FLASH_COOKIE}from"@solidjs/web/server-functions/server";import{createServerReference}from"@solidjs/web/server-functions/client";globalThis[Symbol.for("solid.RequestContext")]=newAsyncLocalStorage();constFORMAT="X-Server-Function-Format";// The naive recursive merge #3168's own rationale names as the sink.functiondeepMerge(target,source){for(constkeyofObject.keys(source)){if(source[key]&&typeofsource[key]==="object"){target[key]??={};deepMerge(target[key],source[key]);}elsetarget[key]=source[key];}returntarget;}functiontakePollution(){constleaked=Object.prototype.polluted;deleteObject.prototype.polluted;returnleaked===undefined ? "undefined" : JSON.stringify(leaked);}constrow=(label,ownKeys,polluted)=>console.log(`${label.padEnd(34)} ownKeys=${JSON.stringify(ownKeys).padEnd(28)} Object.prototype.polluted=${polluted}`);// A document a user wrote. Written through JSON.parse: an object literal's// __proto__ sets the prototype instead of creating an own key.constDOC='{"displayName":"ada","__proto__":{"polluted":"yes"},"n":1}';// ---------- CONTROL: the argument leg, which IS guarded ----------letseen;registerServerFunction("arg-leg",asyncarg=>{seen=arg;return"ok";});awaithandleServerFunctionRequest(newRequest("http://app.example/_server/data/arg-leg",{method: "POST",headers: {[FORMAT]: "8","Sec-Fetch-Site": "same-origin"},body: `[${DOC}]`}));deepMerge({},seen);row("CONTROL argument leg (POST json)",Object.keys(seen),takePollution());// ---------- the response leg: the same graph, the other direction ----------asyncfunctioncallAndDecode(road){constid=`res-${road}`;registerServerFunction(id,async()=>// the codec road needs one non-JSON value somewhere in the graphroad==="codec" ? {payload: JSON.parse(DOC),at: newDate(0)} : JSON.parse(DOC));constoriginal=globalThis.fetch;letobserved;globalThis.fetch=async(url,init)=>{constrequest=newRequest(newURL(url,"http://app.example"),init);request.headers.set("Sec-Fetch-Site","same-origin");constresponse=awaithandleServerFunctionRequest(request);observed=response.headers.get(FORMAT);returnresponse;};try{constdecoded=awaitcreateServerReference(id)();constvalue=road==="codec" ? decoded.payload : decoded;deepMerge({},value);row(`response leg (format ${observed})`,Object.keys(value),takePollution());}finally{globalThis.fetch=original;}}awaitcallAndDecode("json");awaitcallAndDecode("codec");// ---------- the flash cookie leg ----------constsubmission=decodeFlashCookie(`${FLASH_COOKIE}=`+encodeURIComponent(`{"url":"/_server/save","error":false,"thrown":false,"input":[],"result":${DOC}}`));deepMerge({},submission.result);row("flash cookie leg (result)",Object.keys(submission.result),takePollution());
Measured output at f0f7531b:
CONTROL argument leg (POST json) ownKeys=["displayName","n"] Object.prototype.polluted=undefined
response leg (format 8) ownKeys=["displayName","__proto__","n"] Object.prototype.polluted="yes"
response leg (format 0) ownKeys=["displayName","__proto__","n"] Object.prototype.polluted="yes"
flash cookie leg (result) ownKeys=["displayName","__proto__","n"] Object.prototype.polluted="yes"
The CONTROL row is the point: the identical graph, on the identical primitives, is clean going one way and live going the other. Format 8 is the JSON fast path, format 0 a serialized codec frame — the wire formats were read off the response headers, so the two roads are provably distinct.
The flash row above hand-writes the cookie, which implies someone who can set one. That is not required — the package's own encoder writes the key:
JSON.stringify emits an own __proto__ verbatim, JSON.parse revives it as an own property, and the render merges it.
After the fix, the same repro.mjs, same build steps:
CONTROL argument leg (POST json) ownKeys=["displayName","n"] Object.prototype.polluted=undefined
response leg (format 8) ownKeys=["displayName","n"] Object.prototype.polluted=undefined
response leg (format 0) ownKeys=["displayName","n"] Object.prototype.polluted=undefined
flash cookie leg (result) ownKeys=["displayName","n"] Object.prototype.polluted=undefined
Where
Line numbers are on f0f7531b.
packages/web/server-functions/src/shared.ts:900-918 — extractBody, the one function an argument body and a response body both pass through. The two roads that decode to a structured graph, case format === BodyFormat.Serialized (:906) and case format === BodyFormat.Json (:908), return the decoded graph directly. No strip. Introduced by 71821959 (2026-08-25, "Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders") — the boundary predates the guard and was never given one.
packages/web/server-functions/src/shared.ts:1262 — decodeResponse is a three-line wrapper over extractBody, and it is the decoder integrations call by hand (routers, on redirects and single-flight payloads). The client stub's own calls are client.ts:716 and client.ts:781. Same commit.
packages/web/server-functions/src/flash.ts:157-171 — decodeFlashCookie: JSON.parse(match) at :161, then payload.input and payload.result copied onto the returned FlashSubmission at :165-168. No strip, and no import of one. Introduced by 71821959.
The client bundle carries no mirror of the walk at all: grep -c "__proto__" server-functions/dist/client.js → 0, and stripUnsafeArgumentKeys appears only in server.ts.
Why it matters
The realistic path needs no attacker in the transport and no unusual integration:
A server function returns a graph built by JSON.parse over content a user authored — an imported document, a webhook body, a stored config blob, a pasted payload. async raw => JSON.parse(raw) is the whole handler.
The encoder puts the own __proto__ on the wire verbatim, on both the JSON fast path and the codec frame.
The client's decoder hands it to the caller with the own descriptor intact.
Step 4 is where this leg is worse than the argument leg it mirrors: the write now lands in the browser, on the page's single shared Object.prototype, which every framework internal and every third-party script on that page reads through. The single-flight path is the sharpest destination — a data slice is written into the router's cache before the caller's await resolves, so every later reader picks it up.
On the codec road an honest server cannot send constructor. The codec's encode half refuses an object with an own constructor — the call answers 500 before anything is decoded (measured: serializeString(JSON.parse('{"constructor":{...}}')) throws Seroval Error). So from an honest server the codec road carries __proto__ and prototype only. A peer writing the frame by hand can carry all three; a frame is just text, and that cell is covered in the spec through decodeResponse.
The response leg's peer is the app's own server. This is not a hostile-server scenario, and framing it as one would be overstating it. The value is untrusted because of what the handler parsed, not because the origin is.
The flash leg needs the cookie on the origin. In the ordinary case the app's own encoder writes it, as measured above, so no cookie-tosser is needed for the pollution to happen. Where one is wanted, the cookie is unsigned and not host-locked on f0f7531b (FLASH_COOKIE = "flash"), so a sibling subdomain can write one — that is a separate finding and should not be leaned on here.
Options
A. Mirror the walk in the client bundle. Copy stripUnsafeArgumentKeys into the client's decode path. Closes the response leg, adds a second copy of a guard that must stay byte-identical with the first, ships the walk into every browser bundle as new code, and does nothing for the flash road — three decode roads, two copies, one still open.
B. Move the strip to extractBody, and have flash call the same export. One call site covers the argument body and the response body by construction, because they are the same two encodings through the same function; flash imports the same symbol. The walk moves rather than duplicates: shared.ts already ships to both bundles, and server.ts loses the private copy. This is what the tree here does — stripUnsafeKeys exported from shared.ts, applied on the two structured cases of extractBody, with server.ts:1402-1442 deleted and flash.ts importing from ./shared.js.
C. Strip at each outermost consumer — the client stub after decode, the flight-slice write, the flash render seam. Keeps the walk off roads nobody merges on, at the cost of one enumeration of call sites that has to stay complete forever; decodeResponse is public, so integrations decoding by hand would be outside it. This is the shape that produced the current defect.
D. Document rather than guard the response leg. "Treat a decoded result as untrusted; freeze or sanitize before merging." Defensible in isolation — the peer is the app's own server. But the argument leg already declined this answer for the identical sink, and a boundary that guards one direction and documents the other is the asymmetry a reader will not expect. This is a maintainer call: it is a real position, not an oversight, and if it is the chosen one the argument-leg guard deserves the same re-examination rather than the two staying split.
E. Revive with a null prototype instead of deleting keys. Removes the class rather than the keys, but it is a shape change the codec owns, and it breaks instanceof, structural equality and every consumer that assumes a plain object. Much larger blast radius than the defect.
F. Cost. B walks every structured decode, including responses nobody merges. If that is unwelcome on a hot path, the walk can be gated on a cheap presence check before recursing. Also a maintainer call — whether the always-on walk or the conditional one better fits Solid's tolerance for per-decode work.
Recommendation: B. It is the minimalist answer in Solid's own terms — the guard stops being a thing attached to one leg's plumbing and becomes a property of the decode boundary, which is where the seam already makes decisions of exactly this class (the decode depth cap, the argument-count bound, the RegExp exclusion). It deletes code rather than adding it: one function, one call site, three roads covered, and the sync hazard in A never comes into existence.
One consequence worth stating plainly, because it is the fragile edge of B: with the strip at extractBody, the by-hand call on the structured argument road becomes redundant and is removed, so the argument leg's guarantee now depends on arguments continuing to route through extractBody. That is true today and is measured (the pre-existing argument-leg specs stay green, below), but a future refactor that decodes arguments some other way would silently drop the guard. The url-args road is the case that already decodes elsewhere — it goes through deserializeString / JSON.parse and never passes extractBody — so it keeps the one remaining hand-applied call, with a comment saying why. Whether to leave that asymmetry or route the url road through the boundary too is a judgement for the maintainer.
Regression test
packages/web/test/server/server-functions-flash-decode-hygiene.spec.tsx — the two strip tests (the file's third test, "never hands the render a url that is not a string", belongs to a separate falsy-outcome finding):
describe("the flash decode strips what the argument decode strips",()=>{it("carries no live __proto__ out of the submission echo",()=>{constsubmission=decodeFlashCookie(cookieHeader('{"url":"/_server/save","result":"ok","error":false,"thrown":false,'+'"input":[{"name":"Ada","__proto__":{"polluted":"yes"}}]}'))!;// the shallow merge #3168 fixed for the argument roadexpect(Object.getPrototypeOf(Object.assign({},submission.input[0]))).toBe(Object.prototype);expect(Object.keys(submission.input[0])).toEqual(["name"]);});it("carries no live constructor out of the result an integration renders",()=>{constsubmission=decodeFlashCookie(cookieHeader('{"url":"/_server/save","error":false,"thrown":false,"input":[],'+'"result":{"ok":true,"constructor":{"prototype":{"isAdmin":true}}}}'))!;deepMerge({},submission.result);expect(({}asany).isAdmin,"Object.prototype was written through the result").toBeUndefined();});});
packages/web/test/server/server-functions-response-proto-keys.spec.tsx — seven tests over both wire roads, the revived collections, the single-flight slice and decodeResponse itself. The three that carry the core of it:
it("removes each dangerous own key from a result while leaving lookalike data intact",async()=>{// The invariant is key REMOVAL, not "this particular merge stayed// clean": an author who neutralizes one sink leaves every other merge// helper in the ecosystem holding the same key. `constructorName` is// the control in the same table, so a strip that over-reaches fails// here rather than in someone's application. The codec row carries one// key fewer for the encode-side reason noted above, not because the// decoder treats it differently.constCARRIERS: [Road,string][]=[["json",'{"__proto__":{"polluted":"a"},"constructor":{"polluted":"b"},'+'"prototype":{"polluted":"c"},"constructorName":"Widget","n":1}'],["codec",'{"__proto__":{"polluted":"a"},"prototype":{"polluted":"c"},'+'"constructorName":"Widget","n":1}']];constrows: string[]=[];for(const[road,document]ofCARRIERS){const{ status, value }=awaitcallServerFunction(road,JSON.parse(document));rows.push(`${road}: status=${status} ownKeys=${JSON.stringify(Object.keys(value))}`);}expect(rows).toEqual(CARRIERS.map(([road])=>`${road}: status=200 ownKeys=["constructorName","n"]`));});it("a shallow merge of a decoded result cannot re-prototype the copy (#3168, response leg)",async()=>{// Verbatim #3168, one leg over: `Object.assign` merges by [[Set]], so// an own `__proto__` on the source re-prototypes the destination with// attacker-supplied data. This is the exact case the argument decoder// was taught to close.constrows: string[]=[];for(constroadofROADS){const{ status, value }=awaitcallServerFunction(road,JSON.parse('{"displayName":"ada","__proto__":{"isAdmin":true},"n":1}'));constcopy: any=Object.assign({},value);rows.push(`${road}: status=${status} reprototyped=${Object.getPrototypeOf(copy)!==Object.prototype} `+`isAdmin=${JSON.stringify(copy.isAdmin)}`);}expect(rows).toEqual(ROADS.map(road=>`${road}: status=200 reprototyped=false isAdmin=undefined`));});it("hands the single-flight data slice to its consumer with the key already gone",async()=>{// The worst destination on this leg: a slice does not merely reach the// caller, it is written into the router's cache before the caller's// await resolves, from where every later reader picks it up.registerServerFunction("response-proto-flight",async()=>{invocations++;return"mutated";});constoriginal=globalThis.fetch;globalThis.fetch=(async(url: string,init?: RequestInit)=>{constrequest=newRequest(newURL(url,"http://localhost"),init);request.headers.set("Sec-Fetch-Site","same-origin");returnhandleServerFunctionRequest(request,{collectFlightData: ()=>JSON.parse('{"/notes":{"__proto__":{"polluted":"viaFlight"},"n":1}}')});})astypeoffetch;constdelivered: any[]=[];constunsubscribe=subscribeFlightData(data=>{delivered.push(data);});try{constvalue=awaitcreateServerReference("response-proto-flight")();expect(value).toBe("mutated");expect(delivered).toHaveLength(1);deepMerge({},delivered[0]["/notes"]);expect({sliceKeys: Object.keys(delivered[0]["/notes"]),polluted: takePollution()}).toEqual({sliceKeys: ["n"],polluted: undefined});}finally{unsubscribe();globalThis.fetch=original;}});
Both files run against the built bundles (vite.config.server.mjs), so they check the artifacts the package publishes. Measured, on the two files together:
# f0f7531b, unfixed
Test Files 2 failed (2)
Tests 10 failed (10)
# with the fix
Test Files 2 passed (2)
Tests 10 passed (10)
They go red against exactly the defect: a decoded result or flash payload reaching its consumer with __proto__ / constructor / prototype as own keys, on either wire road, through the stub, through decodeResponse, through the flight slice, and through the cookie. The pre-existing argument-leg specs — server-functions-proto-keys.spec.tsx and server-functions-open-gaps.spec.tsx — stay green untouched on the fixed build (48 passed), which is what pins the claim that moving the strip to extractBody does not change the argument leg's behaviour.
Summary
A server function result decoded on the client keeps
__proto__,constructorandprototypeas own keys, and one ordinary merge of that value writes onto the page's sharedObject.prototype. This is the same sink #3168/#3200/#3202 closed for arguments; the guard was attached to a caller of the decoder (parseArgumentsin server.ts) rather than toextractBody, the boundary both legs decode through, so only the request half is covered. The flash cookie is a third decode road building a graph from bytes a peer sent, and it applies no strip at all — worse, the flash encoder writes the key itself, so that road pollutes with no hostile party anywhere in the picture. Measured againstf0f7531b(@solidjs/web2.0.0-rc.6): the argument leg is clean, the response leg (both wire formats) and the flash leg are not.This report merges two symptoms that were found separately, so both are searchable here:
decodeResponse/createServerReferencehand__proto__to the caller with the own descriptor intact;decodeFlashCookiehandsinputandresultto the render unstripped.They are one defect: one guard, three decode roads (POST argument body, client response body, flash cookie). Fixing only the response leg leaves the cookie leg polluting the same
Object.prototype, and fixing them separately produces two copies of the same walk to keep in sync.Reproduction
repro.mjs, dropped inpackages/web/and run against the built bundles (pnpm -F @solidjs/web buildfirst). The handler is the most ordinary server function there is — it returns a document a user wrote, parsed withJSON.parse. No hostile server, no hand-built frame.Measured output at
f0f7531b:The CONTROL row is the point: the identical graph, on the identical primitives, is clean going one way and live going the other. Format 8 is the JSON fast path, format 0 a serialized codec frame — the wire formats were read off the response headers, so the two roads are provably distinct.
The flash row above hand-writes the cookie, which implies someone who can set one. That is not required — the package's own encoder writes the key:
JSON.stringifyemits an own__proto__verbatim,JSON.parserevives it as an own property, and the render merges it.After the fix, the same
repro.mjs, same build steps:Where
Line numbers are on
f0f7531b.packages/web/server-functions/src/shared.ts:900-918—extractBody, the one function an argument body and a response body both pass through. The two roads that decode to a structured graph,case format === BodyFormat.Serialized(:906) andcase format === BodyFormat.Json(:908), return the decoded graph directly. No strip. Introduced by71821959(2026-08-25, "Migrate the absorbed DOM runtime to TypeScript and flatten it into feature folders") — the boundary predates the guard and was never given one.packages/web/server-functions/src/shared.ts:1262—decodeResponseis a three-line wrapper overextractBody, and it is the decoder integrations call by hand (routers, on redirects and single-flight payloads). The client stub's own calls areclient.ts:716andclient.ts:781. Same commit.packages/web/server-functions/src/server.ts:1199-1220—stripUnsafeArgumentKeysandUNSAFE_ARGUMENT_KEYS, module-private to server.ts, applied at exactly two call sites insideparseArguments::1301(the url-args road) and:1327(return stripUnsafeArgumentKeys(decoded), one line afterconst decoded = await extractBody(...)at:1316). This is the defect's provenance: a neighbouring fix created it.47995412(2026-09-02, "fix(web): harden server transport boundaries") added the walk for__proto__(Decoded arguments keep __proto__ as an own key, so an ordinary Object.assign merge in a handler re-prototypes the result #3168) and attached it to those two call sites;f4e490b9(2026-09-02, "fix(web): judge arguments, results and redirect targets by what they are") widened it to the full key set (#3168's __proto__ strip is bypassed by wrapping the payload in a codec-revived Error #3200/constructor is not stripped alongside __proto__, so a recursive merge of a decoded argument pollutes Object.prototype #3202) and left the attachment where it was. Both landed one frame too low — on a caller ofextractBodyinstead of onextractBody.packages/web/server-functions/src/flash.ts:157-171—decodeFlashCookie:JSON.parse(match)at:161, thenpayload.inputandpayload.resultcopied onto the returnedFlashSubmissionat:165-168. No strip, and no import of one. Introduced by71821959.grep -c "__proto__" server-functions/dist/client.js→0, andstripUnsafeArgumentKeysappears only inserver.ts.Why it matters
The realistic path needs no attacker in the transport and no unusual integration:
JSON.parseover content a user authored — an imported document, a webhook body, a stored config blob, a pasted payload.async raw => JSON.parse(raw)is the whole handler.__proto__on the wire verbatim, on both the JSON fast path and the codec frame.Object.assign({}, result)re-prototypes the copy (verbatim Decoded arguments keep __proto__ as an own key, so an ordinary Object.assign merge in a handler re-prototypes the result #3168, one leg over); a recursive merge walksconstructor.prototypeand reachesObject.prototypeitself (constructor is not stripped alongside __proto__, so a recursive merge of a decoded argument pollutes Object.prototype #3202).Step 4 is where this leg is worse than the argument leg it mirrors: the write now lands in the browser, on the page's single shared
Object.prototype, which every framework internal and every third-party script on that page reads through. The single-flight path is the sharpest destination — a data slice is written into the router's cache before the caller'sawaitresolves, so every later reader picks it up.Honest limits on reachability:
constructor. The codec's encode half refuses an object with an ownconstructor— the call answers 500 before anything is decoded (measured:serializeString(JSON.parse('{"constructor":{...}}'))throwsSeroval Error). So from an honest server the codec road carries__proto__andprototypeonly. A peer writing the frame by hand can carry all three; a frame is just text, and that cell is covered in the spec throughdecodeResponse.f0f7531b(FLASH_COOKIE = "flash"), so a sibling subdomain can write one — that is a separate finding and should not be leaned on here.Options
A. Mirror the walk in the client bundle. Copy
stripUnsafeArgumentKeysinto the client's decode path. Closes the response leg, adds a second copy of a guard that must stay byte-identical with the first, ships the walk into every browser bundle as new code, and does nothing for the flash road — three decode roads, two copies, one still open.B. Move the strip to
extractBody, and have flash call the same export. One call site covers the argument body and the response body by construction, because they are the same two encodings through the same function; flash imports the same symbol. The walk moves rather than duplicates:shared.tsalready ships to both bundles, andserver.tsloses the private copy. This is what the tree here does —stripUnsafeKeysexported fromshared.ts, applied on the two structured cases ofextractBody, withserver.ts:1402-1442deleted andflash.tsimporting from./shared.js.C. Strip at each outermost consumer — the client stub after decode, the flight-slice write, the flash render seam. Keeps the walk off roads nobody merges on, at the cost of one enumeration of call sites that has to stay complete forever;
decodeResponseis public, so integrations decoding by hand would be outside it. This is the shape that produced the current defect.D. Document rather than guard the response leg. "Treat a decoded result as untrusted; freeze or sanitize before merging." Defensible in isolation — the peer is the app's own server. But the argument leg already declined this answer for the identical sink, and a boundary that guards one direction and documents the other is the asymmetry a reader will not expect. This is a maintainer call: it is a real position, not an oversight, and if it is the chosen one the argument-leg guard deserves the same re-examination rather than the two staying split.
E. Revive with a null prototype instead of deleting keys. Removes the class rather than the keys, but it is a shape change the codec owns, and it breaks
instanceof, structural equality and every consumer that assumes a plain object. Much larger blast radius than the defect.F. Cost. B walks every structured decode, including responses nobody merges. If that is unwelcome on a hot path, the walk can be gated on a cheap presence check before recursing. Also a maintainer call — whether the always-on walk or the conditional one better fits Solid's tolerance for per-decode work.
Recommendation: B. It is the minimalist answer in Solid's own terms — the guard stops being a thing attached to one leg's plumbing and becomes a property of the decode boundary, which is where the seam already makes decisions of exactly this class (the decode depth cap, the argument-count bound, the RegExp exclusion). It deletes code rather than adding it: one function, one call site, three roads covered, and the sync hazard in A never comes into existence.
One consequence worth stating plainly, because it is the fragile edge of B: with the strip at
extractBody, the by-hand call on the structured argument road becomes redundant and is removed, so the argument leg's guarantee now depends on arguments continuing to route throughextractBody. That is true today and is measured (the pre-existing argument-leg specs stay green, below), but a future refactor that decodes arguments some other way would silently drop the guard. The url-args road is the case that already decodes elsewhere — it goes throughdeserializeString/JSON.parseand never passesextractBody— so it keeps the one remaining hand-applied call, with a comment saying why. Whether to leave that asymmetry or route the url road through the boundary too is a judgement for the maintainer.Regression test
packages/web/test/server/server-functions-flash-decode-hygiene.spec.tsx— the two strip tests (the file's third test, "never hands the render a url that is not a string", belongs to a separate falsy-outcome finding):packages/web/test/server/server-functions-response-proto-keys.spec.tsx— seven tests over both wire roads, the revived collections, the single-flight slice anddecodeResponseitself. The three that carry the core of it:Both files run against the built bundles (
vite.config.server.mjs), so they check the artifacts the package publishes. Measured, on the two files together:They go red against exactly the defect: a decoded result or flash payload reaching its consumer with
__proto__/constructor/prototypeas own keys, on either wire road, through the stub, throughdecodeResponse, through the flight slice, and through the cookie. The pre-existing argument-leg specs —server-functions-proto-keys.spec.tsxandserver-functions-open-gaps.spec.tsx— stay green untouched on the fixed build (48 passed), which is what pins the claim that moving the strip toextractBodydoes not change the argument leg's behaviour.