diff --git a/src/_serialization.test.ts b/src/_serialization.test.ts index fd65869..d4f6a5e 100644 --- a/src/_serialization.test.ts +++ b/src/_serialization.test.ts @@ -820,3 +820,134 @@ 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"}'); + +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() }, +}); +const loaderBytes = await serializeLoaderData(untrusted()); +const streamBytes = new Uint8Array( + await new Response( + createStreamingLoaderData({ + prefs: untrusted(), + later: Promise.resolve(untrusted()), + }), + ).arrayBuffer(), +); + +installBrowserAccessor(); +const probe = {}; +probe["__proto__"] = { viaAccessor: true }; + +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)); +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( + encodeWithAccessor: boolean, + ): Promise< + Record + > { + const { code, stdout, stderr } = await new Deno.Command(Deno.execPath(), { + args: [ + "eval", + browserDecodeScript, + ...(encodeWithAccessor ? ["--encode-with-accessor"] : []), + ], + 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)); + } + + 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, + }); + }, + ); + } +}); diff --git a/src/_serialization.ts b/src/_serialization.ts index e1363fb..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; } @@ -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; } @@ -464,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; @@ -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; }