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.
This commit is contained in:
Yuval Dinodia
2026-07-20 22:33:31 -04:00
committed by GitHub
parent b73600b0cb
commit 83ca537778
2 changed files with 58 additions and 4 deletions

View File

@@ -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<string, unknown>).polluted;
afterEach(() => {
delete (Object.prototype as Record<string, unknown>).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<string, unknown>).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();
});
});

View File

@@ -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<string, unknown> {
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(