fix(protocol): validate 拒检原型链键(A-P0-1,in → Object.hasOwn)

架构审查 A-P0-1:validate.ts:69 用 `key in schema.properties` 判
「附加属性是否允许」,而 `in` 沿原型链查找——JSON 里写
`"constructor"` / `"toString"` / `"__proto__"` 等键会命中
Object.prototype 成员被判为「schema 已声明」从而跳过拒绝,
fail-open(与 server.ts B-03 同源复发:那个修的是 ACL clients,
这个修的是 returns_schema 校验)。

- 改为 `Object.prototype.hasOwnProperty.call(schema.properties, key)`,
  只认真实声明的属性
- 补 2 个「注入即红」用例:原型键必须被判 additional property;
  真实 manifest 能力 system.agent.update 同样拒原型键、合法调用照过
- 故障注入实测:改回 `in` → 2 例红;恢复 → 全绿

安全属性收紧(原行为是漏放),无任何放宽。
This commit is contained in:
jkooe committed 2026-10-26 00:14:00 +08:00
1 parent cb468df7da
commit 68ea3cd20e
2 files changed
+50 -1

No files matched your search

+7 -1
View File
@@ -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`);
}
}
+43
View File
@@ -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} 必须被拒`);
}
});