From 92abe278ed0011190f04cd017d108972f9c65c60 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Fri, 11 Sep 2026 08:14:14 -0400 Subject: [PATCH 1/2] fix: keep decoded __proto__ keys as own properties Both loader-data decoders wrote object keys with `result[key] = value`. In browsers, assigning to `__proto__` sets the object's prototype instead of creating an own property, so a loader returning `{"__proto__": {"isAdmin": true}}` reached the client with the key gone and `isAdmin` inherited. Keys are now written with Object.defineProperty on the page-load, data-request, streamed, and deferred decode paths. The regression test decodes in a child `deno eval` that installs the browser `__proto__` accessor, since Deno assigns it as an ordinary property and the test process must not mutate globals. Closes #132 Co-Authored-By: Claude Opus 5 --- src/_serialization.test.ts | 98 ++++++++++++++++++++++++++++++++++++++ src/_serialization.ts | 21 +++++++- 2 files changed, 117 insertions(+), 2 deletions(-) diff --git a/src/_serialization.test.ts b/src/_serialization.test.ts index fd65869..6d959d2 100644 --- a/src/_serialization.test.ts +++ b/src/_serialization.test.ts @@ -820,3 +820,101 @@ describe("Serialization Module", () => { }); }); }); + +describe("decoding an own __proto__ key where assignment sets the prototype", () => { + const browserDecodeScript = ` +const { + createStreamingLoaderData, + deserializeHydrationData, + deserializeLoaderData, + deserializeStreamingLoaderData, + serializeHydrationData, + serializeLoaderData, +} = await import(${ + JSON.stringify(new URL("./_serialization.ts", import.meta.url).href) + }); + +const untrusted = () => + JSON.parse('{"__proto__":{"isAdmin":true},"name":"guest"}'); + +const hydration = await serializeHydrationData({ + matches: [{ id: "route" }], + loaderData: { route: untrusted() }, +}); +const loaderBytes = await serializeLoaderData(untrusted()); +const streamBytes = new Uint8Array( + await new Response( + createStreamingLoaderData({ + prefs: untrusted(), + later: Promise.resolve(untrusted()), + }), + ).arrayBuffer(), +); + +Object.defineProperty(Object.prototype, "__proto__", { + get() { + return Object.getPrototypeOf(this); + }, + set(prototype) { + Object.setPrototypeOf(this, prototype); + }, + configurable: true, +}); +const probe = {}; +probe["__proto__"] = { viaAccessor: true }; + +const report = (value) => ({ + ownKeys: Object.keys(value), + hasOwnProto: Object.hasOwn(value, "__proto__"), + ownProtoValue: Object.getOwnPropertyDescriptor(value, "__proto__")?.value, + protoIsObjectPrototype: Object.getPrototypeOf(value) === Object.prototype, + isAdmin: "isAdmin" in value, +}); + +const streamed = await deserializeStreamingLoaderData(new Response(streamBytes)); +console.log(JSON.stringify({ + accessorSetsPrototype: probe.viaAccessor === true && + !Object.hasOwn(probe, "__proto__"), + "page load": report(deserializeHydrationData(hydration).loaderData.route), + "data request": report(deserializeLoaderData(loaderBytes)), + "streamed data": report(streamed.prefs), + "deferred data": report(await streamed.later), +})); +`; + + async function decodeInBrowserLikeRuntime(): Promise< + Record + > { + const { code, stdout, stderr } = await new Deno.Command(Deno.execPath(), { + args: ["eval", browserDecodeScript], + cwd: new URL(".", import.meta.url), + stdout: "piped", + stderr: "piped", + }).output(); + const decoder = new TextDecoder(); + assertEquals(code, 0, decoder.decode(stderr)); + return JSON.parse(decoder.decode(stdout)); + } + + it("keeps the key as an own property on every decode path", async () => { + const { accessorSetsPrototype, ...paths } = + await decodeInBrowserLikeRuntime(); + assert( + accessorSetsPrototype, + "the child must assign __proto__ through the accessor, as browsers do", + ); + const ownProtoKept = { + ownKeys: ["__proto__", "name"], + hasOwnProto: true, + ownProtoValue: { isAdmin: true }, + protoIsObjectPrototype: true, + isAdmin: false, + }; + assertEquals(paths, { + "page load": ownProtoKept, + "data request": ownProtoKept, + "streamed data": ownProtoKept, + "deferred data": ownProtoKept, + }); + }); +}); diff --git a/src/_serialization.ts b/src/_serialization.ts index e1363fb..99c327b 100644 --- a/src/_serialization.ts +++ b/src/_serialization.ts @@ -302,6 +302,19 @@ async function processValue(value: unknown): Promise { return value; } +function defineOwnValue( + target: Record, + key: string, + value: unknown, +): void { + Object.defineProperty(target, key, { + value, + enumerable: true, + writable: true, + configurable: true, + }); +} + function restoreValue(value: unknown): unknown { if (value === null || value === undefined) { return value; @@ -351,7 +364,7 @@ function restoreValue(value: unknown): unknown { if (typeof value === "object") { const result: Record = {}; for (const [key, val] of Object.entries(value)) { - result[key] = restoreValue(val); + defineOwnValue(result, key, restoreValue(val)); } return result; } @@ -627,7 +640,11 @@ function restoreValueWithPendingPromises( if (typeof value === "object") { const result: Record = {}; for (const [key, val] of Object.entries(value)) { - result[key] = restoreValueWithPendingPromises(val, promiseResolvers); + defineOwnValue( + result, + key, + restoreValueWithPendingPromises(val, promiseResolvers), + ); } return result; } From 20a2355782727f83dc983d28bd8a6d62e8b388a7 Mon Sep 17 00:00:00 2001 From: Kyle June Date: Sat, 12 Sep 2026 13:49:38 -0400 Subject: [PATCH 2/2] fix: preserve own keys before serialization --- src/_serialization.test.ts | 101 ++++++++++++++++++++++++------------- src/_serialization.ts | 10 ++-- 2 files changed, 72 insertions(+), 39 deletions(-) diff --git a/src/_serialization.test.ts b/src/_serialization.test.ts index 6d959d2..d4f6a5e 100644 --- a/src/_serialization.test.ts +++ b/src/_serialization.test.ts @@ -837,6 +837,22 @@ const { const untrusted = () => JSON.parse('{"__proto__":{"isAdmin":true},"name":"guest"}'); +function installBrowserAccessor() { + Object.defineProperty(Object.prototype, "__proto__", { + get() { + return Object.getPrototypeOf(this); + }, + set(prototype) { + Object.setPrototypeOf(this, prototype); + }, + configurable: true, + }); +} + +if (Deno.args.includes("--encode-with-accessor")) { + installBrowserAccessor(); +} + const hydration = await serializeHydrationData({ matches: [{ id: "route" }], loaderData: { route: untrusted() }, @@ -851,15 +867,7 @@ const streamBytes = new Uint8Array( ).arrayBuffer(), ); -Object.defineProperty(Object.prototype, "__proto__", { - get() { - return Object.getPrototypeOf(this); - }, - set(prototype) { - Object.setPrototypeOf(this, prototype); - }, - configurable: true, -}); +installBrowserAccessor(); const probe = {}; probe["__proto__"] = { viaAccessor: true }; @@ -867,26 +875,38 @@ const report = (value) => ({ ownKeys: Object.keys(value), hasOwnProto: Object.hasOwn(value, "__proto__"), ownProtoValue: Object.getOwnPropertyDescriptor(value, "__proto__")?.value, + ownProtoWritable: Object.getOwnPropertyDescriptor(value, "__proto__")?.writable, + ownProtoConfigurable: Object.getOwnPropertyDescriptor(value, "__proto__")?.configurable, protoIsObjectPrototype: Object.getPrototypeOf(value) === Object.prototype, isAdmin: "isAdmin" in value, }); const streamed = await deserializeStreamingLoaderData(new Response(streamBytes)); -console.log(JSON.stringify({ - accessorSetsPrototype: probe.viaAccessor === true && - !Object.hasOwn(probe, "__proto__"), +const paths = { "page load": report(deserializeHydrationData(hydration).loaderData.route), "data request": report(deserializeLoaderData(loaderBytes)), "streamed data": report(streamed.prefs), "deferred data": report(await streamed.later), +}; +console.log(JSON.stringify({ + accessorSetsPrototype: probe.viaAccessor === true && + !Object.hasOwn(probe, "__proto__"), + objectPrototypeUnaffected: ({}).isAdmin === undefined && !("isAdmin" in {}), + paths, })); `; - async function decodeInBrowserLikeRuntime(): Promise< + async function decodeInBrowserLikeRuntime( + encodeWithAccessor: boolean, + ): Promise< Record > { const { code, stdout, stderr } = await new Deno.Command(Deno.execPath(), { - args: ["eval", browserDecodeScript], + args: [ + "eval", + browserDecodeScript, + ...(encodeWithAccessor ? ["--encode-with-accessor"] : []), + ], cwd: new URL(".", import.meta.url), stdout: "piped", stderr: "piped", @@ -896,25 +916,38 @@ console.log(JSON.stringify({ return JSON.parse(decoder.decode(stdout)); } - it("keeps the key as an own property on every decode path", async () => { - const { accessorSetsPrototype, ...paths } = - await decodeInBrowserLikeRuntime(); - assert( - accessorSetsPrototype, - "the child must assign __proto__ through the accessor, as browsers do", + for (const encodeWithAccessor of [false, true]) { + it( + `keeps the key as an own property on every decode path (${ + encodeWithAccessor ? "encode with accessor" : "decode with accessor" + })`, + async () => { + const { accessorSetsPrototype, objectPrototypeUnaffected, paths } = + await decodeInBrowserLikeRuntime(encodeWithAccessor); + assert( + accessorSetsPrototype, + "the child must assign __proto__ through the accessor, as browsers do", + ); + assert( + objectPrototypeUnaffected, + "Object.prototype must remain unaffected", + ); + const ownProtoKept = { + ownKeys: ["__proto__", "name"], + hasOwnProto: true, + ownProtoValue: { isAdmin: true }, + ownProtoWritable: true, + ownProtoConfigurable: true, + protoIsObjectPrototype: true, + isAdmin: false, + }; + assertEquals(paths, { + "page load": ownProtoKept, + "data request": ownProtoKept, + "streamed data": ownProtoKept, + "deferred data": ownProtoKept, + }); + }, ); - const ownProtoKept = { - ownKeys: ["__proto__", "name"], - hasOwnProto: true, - ownProtoValue: { isAdmin: true }, - protoIsObjectPrototype: true, - isAdmin: false, - }; - assertEquals(paths, { - "page load": ownProtoKept, - "data request": ownProtoKept, - "streamed data": ownProtoKept, - "deferred data": ownProtoKept, - }); - }); + } }); diff --git a/src/_serialization.ts b/src/_serialization.ts index 99c327b..c56e026 100644 --- a/src/_serialization.ts +++ b/src/_serialization.ts @@ -294,7 +294,7 @@ async function processValue(value: unknown): Promise { if (typeof value === "object") { const result: Record = {}; for (const [key, val] of Object.entries(value)) { - result[key] = await processValue(val); + defineOwnValue(result, key, await processValue(val)); } return result; } @@ -477,10 +477,10 @@ function processValueForStreaming( if (typeof value === "object") { const result: Record = {}; for (const [key, val] of Object.entries(value)) { - result[key] = processValueForStreaming( - val, - pendingPromises, - `${idPrefix}${key}_`, + defineOwnValue( + result, + key, + processValueForStreaming(val, pendingPromises, `${idPrefix}${key}_`), ); } return result;