Files
jkooe 68ea3cd20e 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 例红;恢复 → 全绿

安全属性收紧(原行为是漏放),无任何放宽。
2026-10-26 00:14:00 +08:00

126 lines
5.6 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { validate, applyDefaults } from '../../packages/protocol/dist/index.js';
test('validate:基本类型', () => {
assert.deepEqual(validate('x', { type: 'string' }), []);
assert.deepEqual(validate(1, { type: 'integer' }), []);
assert.deepEqual(validate(1.5, { type: 'number' }), []);
assert.deepEqual(validate(true, { type: 'boolean' }), []);
assert.deepEqual(validate([], { type: 'array' }), []);
assert.deepEqual(validate({}, { type: 'object' }), []);
assert.ok(validate(1, { type: 'string' }).length > 0);
assert.ok(validate('x', { type: 'integer' }).length > 0);
assert.ok(validate(1.5, { type: 'integer' }).length > 0, '非整数应报错');
});
test('validate:enum', () => {
const schema = { type: 'string', enum: ['a', 'b'] };
assert.deepEqual(validate('a', schema), []);
assert.ok(validate('c', schema).length > 0, '不在枚举内应报错');
});
test('validate:数值范围', () => {
const schema = { type: 'number', minimum: 0, maximum: 10 };
assert.deepEqual(validate(5, schema), []);
assert.deepEqual(validate(0, schema), []);
assert.ok(validate(-1, schema).length > 0);
assert.ok(validate(11, schema).length > 0);
});
test('validate:字符串长度与 pattern', () => {
const schema = { type: 'string', minLength: 2, maxLength: 5, pattern: '^[a-z]+$' };
assert.deepEqual(validate('abc', schema), []);
assert.ok(validate('a', schema).length > 0, '过短');
assert.ok(validate('abcdef', schema).length > 0, '过长');
assert.ok(validate('ab1', schema).length > 0, '不匹配 pattern');
});
test('validate:数组长度与 items 类型', () => {
const schema = { type: 'array', minItems: 1, maxItems: 3, items: { type: 'integer' } };
assert.deepEqual(validate([1, 2], schema), []);
assert.ok(validate([], schema).length > 0, '空数组');
assert.ok(validate([1, 2, 3, 4], schema).length > 0, '超长');
assert.ok(validate([1, 'x'], schema).length > 0, 'item 类型错');
});
test('validate:required 与 additionalProperties', () => {
const schema = {
type: 'object',
properties: { name: { type: 'string' }, age: { type: 'integer' } },
required: ['name'],
additionalProperties: false,
};
assert.deepEqual(validate({ name: 'x', age: 1 }, schema), []);
assert.ok(validate({ age: 1 }, schema).length > 0, '缺 required');
assert.ok(validate({ name: 'x', extra: 1 }, schema).length > 0, '多余字段');
});
test('validate:嵌套路径正确', () => {
const schema = {
type: 'object',
properties: { filter: { type: 'object', properties: { pattern: { type: 'string' } } } },
};
assert.deepEqual(validate({ filter: { pattern: 'x' } }, schema), []);
const errs = validate({ filter: { pattern: 1 } }, schema);
assert.equal(errs.length, 1);
assert.ok(errs[0].includes('$.filter.pattern'), '错误应带嵌套路径');
});
test('applyDefaults:填补缺失字段,不覆盖已有值', () => {
const schema = {
type: 'object',
properties: {
limit: { type: 'integer', default: 20 },
recursive: { type: 'boolean', default: false },
},
};
const out = applyDefaults({ limit: 5 }, schema);
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} 必须被拒`);
}
});