diff --git a/packages/protocol/src/validate.ts b/packages/protocol/src/validate.ts index c3b9549..2d9a9fd 100644 --- a/packages/protocol/src/validate.ts +++ b/packages/protocol/src/validate.ts @@ -66,7 +66,13 @@ export function validate(value: unknown, schema: JsonSchema, path = '$'): string } if (schema.additionalProperties === false) { for (const key of Object.keys(value)) { - if (!(key in schema.properties)) { + // ⚠️ A-P0-1(2026-10-25):必须用 **own-property** 判断,不能用 `key in schema.properties`。 + // `in` 沿原型链查找,而 `schema.properties` 是普通对象字面量(继承 Object.prototype)—— + // 调用方传 `{"constructor":1}` / `{"toString":1}` / `{"valueOf":1}` 时 + // `key in properties` 为 true → **不判 additional property** → 校验放行(fail-open)。 + // 与 server.ts B-03(`client_id:"constructor"` 沿原型链取到函数当密钥)是 + // 同一错误模式在不同文件复发;此处修的是「守卫方向」的失效。 + if (!Object.prototype.hasOwnProperty.call(schema.properties, key)) { errors.push(`${path}.${key}: additional property not allowed`); } } diff --git a/tests/unit/validate.test.mjs b/tests/unit/validate.test.mjs index db739dc..6e995f0 100644 --- a/tests/unit/validate.test.mjs +++ b/tests/unit/validate.test.mjs @@ -80,3 +80,46 @@ test('applyDefaults:填补缺失字段,不覆盖已有值', () => { assert.equal(out.limit, 5, '已有值不被覆盖'); assert.equal(out.recursive, false, '缺失字段填默认值'); }); + +// --------------------------------------------------------------------------- +// A-P0-1(2026-10-25):原型键绕过 additionalProperties:false —— fail-open 方向的守卫缺陷 +// +// 根因:`key in schema.properties` 沿**原型链**查找,而 schema.properties 是普通 +// 对象字面量(继承 Object.prototype)。调用方传 {"constructor":1} 等键时 +// `key in properties` 为 true → 不判 additional property → **校验放行**。 +// 与 server.ts B-03(client_id:"constructor" 取到函数当密钥打死进程)是同一错误 +// 模式在不同文件复发。修法:Object.prototype.hasOwnProperty.call。 +// +// 本用例即「注入即红」:把实现改回 `in`,下面三条必须全部失败。 +// --------------------------------------------------------------------------- + +test('A-P0-1:原型键(constructor/toString/valueOf/hasOwnProperty)必须被判 additional property', () => { + const schema = { + type: 'object', + properties: { name: { type: 'string' } }, + additionalProperties: false, + }; + for (const protoKey of ['constructor', 'toString', 'valueOf', 'hasOwnProperty', '__proto__', 'isPrototypeOf']) { + const errs = validate({ [protoKey]: 1 }, schema); + assert.ok( + errs.length > 0, + `传入原型键 ${protoKey} 必须被判 additional property(in 运算符会沿原型链放行它 —— fail-open)`, + ); + assert.match(errs.join(';'), new RegExp(protoKey), `错误应指出是哪个键:${errs.join('; ')}`); + } + // 正常键不受影响(防「修过头把合法字段也拒了」) + assert.deepEqual(validate({ name: 'ok' }, schema), []); +}); + +test('A-P0-1:真实 manifest 能力(system.agent.update)同样拒原型键', async () => { + const { findCapability } = await import('../../packages/protocol/dist/index.js'); + const cap = findCapability('system.agent.update'); + assert.ok(cap, '能力应存在'); + // 合法调用必须照过(回归保护:修守卫不能误伤正常请求) + assert.deepEqual(validate({ url: 'http://x/y', sha256: 'a'.repeat(64) }, cap.params_schema), []); + // 原型键必须被拒 + for (const protoKey of ['constructor', 'toString']) { + const errs = validate({ url: 'http://x/y', sha256: 'a'.repeat(64), [protoKey]: 'x' }, cap.params_schema); + assert.ok(errs.length > 0, `${protoKey} 必须被拒`); + } +});