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(