From 1906a8a1f622326c86b45f20cbc217130e649039 Mon Sep 17 00:00:00 2001 From: Om Singhal Date: Sat, 22 Aug 2026 00:11:59 -0400 Subject: [PATCH] fix(compiler,openapi): keep object value members named __proto__ When the compiler marshals an object value for a decorator, objectValueToJs in the JS marshaller added each member with a plain assignment. For a member named __proto__ that assignment does not create a property; it invokes the Object.prototype.__proto__ setter. The member silently disappeared, and when its value was an object or an array that value became the prototype of the marshalled object, so the decorator could read names the .tsp author never declared as members. Nothing reported a diagnostic. The checker already stores object value members in a Map, and unmarshalJsToValue in the same file builds its Map from entries, so the marshalling loop was the only place in the compiler where the member was lost. objectValueToJs now builds the result with Object.fromEntries, which defines every member as an own data property and never consults the setter. The same pattern existed one step further along the issue's own path. convertRemainingValuesToExtensions in @typespec/openapi copies the object it receives from the compiler with Object.entries followed by assignment, so after the compiler fix it would have dropped the member again and polluted the prototype of the stored extension in the same way. That copy now goes through Object.fromEntries as well, keeping its existing behaviour of skipping undefined values. Regression tests cover both packages: the compiler tests check the object a decorator receives, and the openapi tests check what @extension stores, for a member named __proto__ holding a string and holding an object. Each test asserts the member is an own enumerable property and that the prototype of the result is still Object.prototype. Fixes #11743 --- .../fix-extension-proto-member-2026-8-22.md | 7 +++++ .../fix-marshal-proto-member-2026-8-21.md | 7 +++++ packages/compiler/src/core/js-marshaller.ts | 10 +++---- .../compiler/test/checker/decorators.test.ts | 23 ++++++++++++++ packages/openapi/src/decorators.ts | 15 +++++----- packages/openapi/test/decorators.test.ts | 30 ++++++++++++++++++- 6 files changed, 78 insertions(+), 14 deletions(-) create mode 100644 .chronus/changes/fix-extension-proto-member-2026-8-22.md create mode 100644 .chronus/changes/fix-marshal-proto-member-2026-8-21.md diff --git a/.chronus/changes/fix-extension-proto-member-2026-8-22.md b/.chronus/changes/fix-extension-proto-member-2026-8-22.md new file mode 100644 index 00000000000..e05925e6da7 --- /dev/null +++ b/.chronus/changes/fix-extension-proto-member-2026-8-22.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/openapi" +--- + +Fix `@extension` dropping a member named `__proto__` from an object value. The member is now kept as a regular own property instead of being assigned through the `Object.prototype.__proto__` setter, which dropped it and, when its value was an object, made that value the prototype of the stored extension. diff --git a/.chronus/changes/fix-marshal-proto-member-2026-8-21.md b/.chronus/changes/fix-marshal-proto-member-2026-8-21.md new file mode 100644 index 00000000000..9fb79af0034 --- /dev/null +++ b/.chronus/changes/fix-marshal-proto-member-2026-8-21.md @@ -0,0 +1,7 @@ +--- +changeKind: fix +packages: + - "@typespec/compiler" +--- + +Fix object values passed to decorators dropping a member named `__proto__`. The member is now marshalled as a regular own property instead of being assigned through the `Object.prototype.__proto__` setter, which dropped it and, when its value was an object, made that value the prototype of the marshalled object. diff --git a/packages/compiler/src/core/js-marshaller.ts b/packages/compiler/src/core/js-marshaller.ts index b15b35729fc..e33cf7e5d35 100644 --- a/packages/compiler/src/core/js-marshaller.ts +++ b/packages/compiler/src/core/js-marshaller.ts @@ -86,11 +86,11 @@ function numericValueToJs(type: NumericValue, valueConstraint: Type | undefined) } function objectValueToJs(type: ObjectValue): Record { - const result: Record = {}; - for (const [key, value] of type.properties) { - result[key] = marshalTypeForJs(value.value, undefined); - } - return result; + // Object.fromEntries defines each member as an own property, so a member named `__proto__` + // is kept instead of going through the Object.prototype setter and being dropped. + return Object.fromEntries( + [...type.properties].map(([key, value]) => [key, marshalTypeForJs(value.value, undefined)]), + ); } function arrayValueToJs(type: ArrayValue) { return type.values.map((x) => marshalTypeForJs(x, undefined)); diff --git a/packages/compiler/test/checker/decorators.test.ts b/packages/compiler/test/checker/decorators.test.ts index 0dad4ab7b20..c1d0bbf295a 100644 --- a/packages/compiler/test/checker/decorators.test.ts +++ b/packages/compiler/test/checker/decorators.test.ts @@ -830,6 +830,29 @@ describe("compiler: checker: decorators", () => { ); deepStrictEqual(arg, { name: { other: "foo" } }); }); + + // Regression tests for https://github.com/microsoft/typespec/issues/11743 + it("keeps a member named __proto__ holding a string as an own property", async () => { + const arg = await testCallDecorator("valueof unknown", `#{__proto__: "written", ok: 1}`); + ok(Object.prototype.hasOwnProperty.call(arg, "__proto__")); + strictEqual(arg.__proto__, "written"); + strictEqual(arg.ok, 1); + deepStrictEqual(Object.keys(arg), ["__proto__", "ok"]); + strictEqual(Object.getPrototypeOf(arg), Object.prototype); + }); + + it("keeps a member named __proto__ holding an object as an own property", async () => { + const arg = await testCallDecorator( + "valueof unknown", + `#{__proto__: #{polluted: true}, ok: 1}`, + ); + ok(Object.prototype.hasOwnProperty.call(arg, "__proto__")); + deepStrictEqual(arg.__proto__, { polluted: true }); + strictEqual(arg.ok, 1); + deepStrictEqual(Object.keys(arg), ["__proto__", "ok"]); + strictEqual(Object.getPrototypeOf(arg), Object.prototype); + strictEqual(arg.polluted, undefined); + }); }); describe("passing an array value", () => { diff --git a/packages/openapi/src/decorators.ts b/packages/openapi/src/decorators.ts index f43d02c6f57..dc4efba3d1a 100644 --- a/packages/openapi/src/decorators.ts +++ b/packages/openapi/src/decorators.ts @@ -97,14 +97,13 @@ function convertRemainingValuesToExtensions(program: Program, value: unknown): u if (isTypeSpecValue(value)) { return serializeValueAsJson(program, value, value.type); } else { - const result: Record = {}; - for (const [key, val] of Object.entries(value)) { - if (val === undefined) { - continue; - } - result[key] = convertRemainingValuesToExtensions(program, val); - } - return result; + // Object.fromEntries defines each member as an own property, so a member named + // `__proto__` is kept instead of going through the Object.prototype setter. + return Object.fromEntries( + Object.entries(value) + .filter(([, val]) => val !== undefined) + .map(([key, val]) => [key, convertRemainingValuesToExtensions(program, val)]), + ); } default: return value; diff --git a/packages/openapi/test/decorators.test.ts b/packages/openapi/test/decorators.test.ts index 5739379883b..7fa8d543344 100644 --- a/packages/openapi/test/decorators.test.ts +++ b/packages/openapi/test/decorators.test.ts @@ -1,5 +1,5 @@ import { expectDiagnostics, t } from "@typespec/compiler/testing"; -import { deepStrictEqual } from "assert"; +import { deepStrictEqual, ok, strictEqual } from "assert"; import { describe, it } from "vitest"; import { getExtensions, @@ -38,6 +38,34 @@ describe("@extension", () => { }); }); + // Regression tests for https://github.com/microsoft/typespec/issues/11743 + it("keeps a member named __proto__ holding a string as an own property", async () => { + const { program, Foo } = await Tester.compile(t.code` + @extension("x-custom", #{__proto__: "written", ok: 1}) + model ${t.model("Foo")} {} + `); + + const value = getExtensions(program, Foo).get("x-custom"); + ok(Object.prototype.hasOwnProperty.call(value, "__proto__")); + deepStrictEqual(Object.keys(value), ["__proto__", "ok"]); + strictEqual(value.__proto__, "written"); + strictEqual(Object.getPrototypeOf(value), Object.prototype); + }); + + it("keeps a member named __proto__ holding an object as an own property", async () => { + const { program, Foo } = await Tester.compile(t.code` + @extension("x-custom", #{__proto__: #{polluted: true}, ok: 1}) + model ${t.model("Foo")} {} + `); + + const value = getExtensions(program, Foo).get("x-custom"); + ok(Object.prototype.hasOwnProperty.call(value, "__proto__")); + deepStrictEqual(Object.keys(value), ["__proto__", "ok"]); + deepStrictEqual(value.__proto__, { polluted: true }); + strictEqual(Object.getPrototypeOf(value), Object.prototype); + strictEqual(value.polluted, undefined); + }); + it.each([ { value: `#{ name: "foo" }`, expected: { name: "foo" } }, { value: `#{ items: #[ #{foo: "bar" }]}`, expected: { items: [{ foo: "bar" }] } },