架构审查 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 例红;恢复 → 全绿 安全属性收紧(原行为是漏放),无任何放宽。
126 lines
5.6 KiB
JavaScript
126 lines
5.6 KiB
JavaScript
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} 必须被拒`);
|
||
}
|
||
});
|