From 83ca537778d8a29136e5cf6a6641f2789fc86ea9 Mon Sep 17 00:00:00 2001 From: Yuval Dinodia <102706514+yetval@users.noreply.github.com> Date: Mon, 20 Jul 2026 22:33:31 -0400 Subject: [PATCH] fix(shared): block prototype-polluting keys in JSON schema default hydration (#107978) A plugin manifest configSchema declaring a __proto__ property polluted Object.prototype process-wide during ordinary plugin config validation. applyObjectPropertyDefaults iterated schema-controlled property keys and wrote into value[key] with no blocked-key guard; because manifests are parsed JSON, __proto__ is an own key and value["__proto__"] dereferences the inherited Object.prototype, onto which nested defaults were written. Skip __proto__, prototype, and constructor at each write site in applyObjectPropertyDefaults, reusing the existing isBlockedObjectKey helper already used at other untrusted object-write boundaries. --- src/shared/json-schema-defaults.test.ts | 50 ++++++++++++++++++++++++- src/shared/json-schema-defaults.ts | 12 +++++- 2 files changed, 58 insertions(+), 4 deletions(-) diff --git a/src/shared/json-schema-defaults.test.ts b/src/shared/json-schema-defaults.test.ts index 179c188a5680..9658cfc5343b 100644 --- a/src/shared/json-schema-defaults.test.ts +++ b/src/shared/json-schema-defaults.test.ts @@ -1,5 +1,9 @@ -import { describe, expect, it } from "vitest"; -import { findJsonSchemaShapeError, normalizeJsonSchemaForTypeBox } from "./json-schema-defaults.js"; +import { afterEach, describe, expect, it } from "vitest"; +import { + applyJsonSchemaDefaults, + findJsonSchemaShapeError, + normalizeJsonSchemaForTypeBox, +} from "./json-schema-defaults.js"; describe("normalizeJsonSchemaForTypeBox", () => { it("combines pattern properties that collide after unicode repair", () => { @@ -47,3 +51,45 @@ describe("normalizeJsonSchemaForTypeBox", () => { ).toBeUndefined(); }); }); + +describe("applyJsonSchemaDefaults prototype safety", () => { + const readPollution = () => (Object.prototype as Record).polluted; + + afterEach(() => { + delete (Object.prototype as Record).polluted; + }); + + it("does not pollute Object.prototype through a __proto__ property schema", () => { + const schema = JSON.parse( + '{"type":"object","properties":{"__proto__":{"type":"object","properties":{"polluted":{"default":"yes"}}}}}', + ); + + const result = applyJsonSchemaDefaults(schema, {}); + + expect(readPollution()).toBeUndefined(); + expect(({} as Record).polluted).toBeUndefined(); + expect(Object.hasOwn(result, "polluted")).toBe(false); + }); + + it("does not pollute Object.prototype through a __proto__ pattern property schema", () => { + const schema = JSON.parse( + '{"type":"object","patternProperties":{".*":{"type":"object","properties":{"polluted":{"default":"yes"}}}}}', + ); + const value = JSON.parse('{"__proto__":{}}'); + + applyJsonSchemaDefaults(schema, value); + + expect(readPollution()).toBeUndefined(); + }); + + it("does not pollute Object.prototype through a __proto__ additional property schema", () => { + const schema = JSON.parse( + '{"type":"object","additionalProperties":{"type":"object","properties":{"polluted":{"default":"yes"}}}}', + ); + const value = JSON.parse('{"__proto__":{}}'); + + applyJsonSchemaDefaults(schema, value); + + expect(readPollution()).toBeUndefined(); + }); +}); diff --git a/src/shared/json-schema-defaults.ts b/src/shared/json-schema-defaults.ts index 20e57cb70a5c..5a30c5cd7ab8 100644 --- a/src/shared/json-schema-defaults.ts +++ b/src/shared/json-schema-defaults.ts @@ -1,6 +1,7 @@ import { isRecord } from "@openclaw/normalization-core/record-coerce"; // JSON schema default helpers fill object values from TypeBox schema defaults. import { Compile } from "typebox/compile"; +import { isBlockedObjectKey } from "../infra/prototype-keys.js"; import type { JsonSchemaObject } from "./json-schema.types.js"; type JsonSchemaValue = JsonSchemaObject | boolean; @@ -926,6 +927,9 @@ function applyObjectPropertyDefaults( ): Record { const properties = isRecord(schema.properties) ? schema.properties : {}; for (const [key, propertySchema] of Object.entries(properties)) { + if (isBlockedObjectKey(key)) { + continue; + } const currentValue = value[key]; const defaultedValue = applySchemaDefaults( propertySchema as JsonSchemaValue, @@ -951,7 +955,7 @@ function applyObjectPropertyDefaults( continue; } for (const key of Object.keys(value)) { - if (!regex.test(key)) { + if (isBlockedObjectKey(key) || !regex.test(key)) { continue; } patternMatchedKeys.add(key); @@ -969,7 +973,11 @@ function applyObjectPropertyDefaults( if (isRecord(schema.additionalProperties)) { const additionalSchema = schema.additionalProperties as JsonSchemaValue; for (const key of Object.keys(value)) { - if (Object.hasOwn(properties, key) || patternMatchedKeys.has(key)) { + if ( + isBlockedObjectKey(key) || + Object.hasOwn(properties, key) || + patternMatchedKeys.has(key) + ) { continue; } value[key] = applySchemaDefaults(