From 41be9e7aa885ee9078511cde399d549e02b6cf80 Mon Sep 17 00:00:00 2001 From: cliffhall Date: Wed, 26 Aug 2026 04:48:54 -0400 Subject: [PATCH] =?UTF-8?q?fix:=20address=20Copilot=20review=20round=2012?= =?UTF-8?q?=20=E2=80=94=20prototype-safe=20default=20seeding?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit collectSchemaDefaults assigned by name, so a legal field called `__proto__` went to the legacy prototype setter instead of the result. A required one pinned by `const` would then render read-only and be seeded with nothing, leaving submit permanently disabled. Seeded through defineProperty now, the way the other schema-record paths already build their maps. Signed-off-by: cliffhall --- clients/web/src/utils/jsonUtils.test.ts | 12 ++++++++++++ clients/web/src/utils/jsonUtils.ts | 17 ++++++++++++++--- 2 files changed, 26 insertions(+), 3 deletions(-) diff --git a/clients/web/src/utils/jsonUtils.test.ts b/clients/web/src/utils/jsonUtils.test.ts index ff8bf1b3..1d2fd8ae 100644 --- a/clients/web/src/utils/jsonUtils.test.ts +++ b/clients/web/src/utils/jsonUtils.test.ts @@ -445,6 +445,18 @@ describe("root composition (#2123)", () => { }); }); + it("seeds a field named __proto__", () => { + // A plain assignment would invoke the legacy prototype setter, leaving a + // required pinned field displayed read-only and seeded with nothing. + const seeded = collectSchemaDefaults({ + type: "object", + properties: Object.fromEntries([ + ["__proto__", { type: "string", const: "kept" }], + ]), + }); + expect(Object.hasOwn(seeded, "__proto__")).toBe(true); + }); + it("re-applies a nested object's constants", () => { // The overlay replaces the whole nested object rather than merging into it, // so a link naming `{ config: { kind: "sms" } }` would otherwise slip past diff --git a/clients/web/src/utils/jsonUtils.ts b/clients/web/src/utils/jsonUtils.ts index a60387a9..823e4f33 100644 --- a/clients/web/src/utils/jsonUtils.ts +++ b/clients/web/src/utils/jsonUtils.ts @@ -145,6 +145,17 @@ export function collectSchemaDefaults( const selected = selectBranchIndex(branches, knownValues) ?? 0; const properties = (branches[selected]?.schema ?? base).properties ?? {}; const result: Record = {}; + // `properties` is a JSON record, so `__proto__` is a legal field name that a + // plain assignment would drop into the legacy prototype setter rather than + // keep — a required one pinned by `const` would then display read-only and + // be seeded with nothing, leaving submit permanently disabled (#2123). + const seed = (name: string, value: unknown) => + Object.defineProperty(result, name, { + value, + writable: true, + enumerable: true, + configurable: true, + }); for (const [fieldName, rawSchema] of Object.entries(properties)) { // Collapse a nullable union first, for the same reason `SchemaForm` does: // a nested object's `properties` live on the union's surviving branch, so @@ -161,13 +172,13 @@ export function collectSchemaDefaults( // `const` rejects, and seeding that would submit an invalid argument // through a field rendered read-only, leaving the user no way to correct // it. - result[fieldName] = fieldSchema.const; + seed(fieldName, fieldSchema.const); } else if (fieldSchema.default !== undefined) { - result[fieldName] = fieldSchema.default; + seed(fieldName, fieldSchema.default); } else if (fieldSchema.type === "object" && fieldSchema.properties) { const nested = collectSchemaDefaults(fieldSchema); if (Object.keys(nested).length > 0) { - result[fieldName] = nested; + seed(fieldName, nested); } } }