架构审查两个 P0:
A-P0-2 限速桶 key 用错值(server.ts:364):
原实现 `allowByRate(clientId, limit, String(limit))` 把**限速值**
当桶名——ACL 支持按能力限速(精确 > glob > 全局),配
「screen.capture ≤5/min、app.install ≤5/min」两条同值规则时
两者 key 都是 `clientId|5` 共用一个桶:截图打满后装软件被误伤,
且两者总量仍按 5/min(漏过)。同时 rateBuckets 只增不删 +
psk 模式 client_id 自报 → 外部可枚举任意 client_id 造键 →
长期运行内存无界增长。
- 抽到 util/rate-limit.ts:key = `${认证身份 ?? 远端地址}:${能力名}`
(未认证时退化按远端地址,不用空串把所有人混为一桶)
- 过期键清扫(滑动窗口边界与旧实现逐字节一致:now - t < 60_000
才算窗口内)+ 容量上限淘汰最旧(Map 插入序 = 时间序)
- e2e limited 客户端改配两个同限速值能力(各 2/min),
打满 system.info 后 system.audit.list 必须仍有完整配额
A-P0-4 describeArgs 大参数同步序列化阻塞事件循环(audit.ts:172):
原实现每次 invoke 全量 JSON.stringify + sha256。fs.write 的 data
可达数 MB——同步序列化 + 哈希 ≈ 数十毫秒级 CPU 且阻塞事件循环,
期间 GUI 注入、事件推送、保活 ping 全部延迟。
- 超过 64KB 只哈希前 64KB(按**字节**切:多字节字符下按字符切
会得到非确定性边界),总长记 args_bytes、截断记 args_truncated
- 口径从「全量哈希」变为「前 64KB 哈希」只影响审计摘要,
不影响任何安全属性(摘要用途是比对与防泄密,不是完整性证明)
测试(均故障注入实测会红):
- rate-limit 6 例:桶名是能力名 / 身份进 key / 不限速不建桶 /
过期键清扫(59.999s 窗口内、恰好 60s 出窗)/ 容量上限 /
同值多能力各占一桶
- audit-args-digest 6 例:小参数口径不回退 / 大参数截断口径 /
按字节切(字符切口径必须不同)/ 尾部不进摘要 / 端到端入链
校验通过 / logArgs 默认关不产预览
- e2e 注入验证:桶名退回 String(rateLimit) → v3 场景红;恢复 → 绿
单测 445/445、e2e 33/33、typecheck 全绿
90 lines
4.7 KiB
JavaScript
90 lines
4.7 KiB
JavaScript
/**
|
||
* 限速器守卫(`apps/agent/src/util/rate-limit.ts`,A-P0-2,2026-10-25)。
|
||
*
|
||
* ⚠️ 背景:原实现 `allowByRate(clientId, limit, String(limit))` 把**限速值**当桶名。
|
||
* ACL 支持按能力限速(`rateLimitFor`,精确 > glob > 全局),当某 client 配了
|
||
* 「screen.capture ≤ 5/min、app.install ≤ 5/min」两条同值规则时,两者 key 都是
|
||
* `clientId|5` → 共用一个桶:截图 5 次后装软件也被限速(误伤),且两者总量
|
||
* 仍按 5/min(漏过)。
|
||
* 同时 `rateBuckets` 只增不删 + psk 模式 client_id 自报 → 外部可枚举任意
|
||
* client_id 造键 → 长期运行内存无界增长。
|
||
*
|
||
* 本文件钉死四条(均可用故障注入打红):
|
||
* ① 桶名是**能力名**:同限速值的不同能力互不挤占;
|
||
* ② 身份进 key:不同调用方互不挤占;
|
||
* ③ 过期键被清扫(假时钟推进 61s,桶数归零、重新放行);
|
||
* ④ 容量上限:自报 client_id 枚举造键 → 桶数有界。
|
||
*/
|
||
import { test } from 'node:test';
|
||
import assert from 'node:assert/strict';
|
||
import { createRateLimiter } from '../../apps/agent/dist/util/rate-limit.js';
|
||
|
||
test('A-P0-2①:桶名是能力名 —— 同限速值的不同能力互不挤占', () => {
|
||
const rl = createRateLimiter();
|
||
// 同一 client、同一限速值、两个能力
|
||
assert.equal(rl.allow('c1', 2, 'screen.capture'), true);
|
||
assert.equal(rl.allow('c1', 2, 'screen.capture'), true);
|
||
assert.equal(rl.allow('c1', 2, 'screen.capture'), false, '第 3 次截图应被限');
|
||
// 关键:app.install 不受截图影响(旧实现此处共桶 → 误伤)
|
||
assert.equal(rl.allow('c1', 2, 'app.install'), true, '同限速值的另一能力不得被挤占');
|
||
assert.equal(rl.allow('c1', 2, 'app.install'), true);
|
||
assert.equal(rl.allow('c1', 2, 'app.install'), false, 'app.install 自己也按 2/min 限');
|
||
// 两个能力各占一桶
|
||
assert.equal(rl.size(), 2);
|
||
});
|
||
|
||
test('A-P0-2②:身份进 key —— 不同调用方互不挤占', () => {
|
||
const rl = createRateLimiter();
|
||
assert.equal(rl.allow('c1', 1, 'screen.capture'), true);
|
||
assert.equal(rl.allow('c1', 1, 'screen.capture'), false);
|
||
assert.equal(rl.allow('c2', 1, 'screen.capture'), true, '另一调用方有独立配额');
|
||
assert.equal(rl.size(), 2);
|
||
});
|
||
|
||
test('A-P0-2③:不限速时不建桶(与旧行为一致:直接放行)', () => {
|
||
const rl = createRateLimiter();
|
||
for (let i = 0; i < 100; i += 1) assert.equal(rl.allow('c1', undefined, 'screen.capture'), true);
|
||
assert.equal(rl.allow('c1', 0, 'screen.capture'), true, 'limit=0 同样视为不限速');
|
||
assert.equal(rl.allow('c1', -1, 'screen.capture'), true);
|
||
assert.equal(rl.size(), 0, '不限速的调用不得留下桶');
|
||
});
|
||
|
||
test('A-P0-2③:过期键被清扫(假时钟推进 61s → 桶清空且重新放行)', () => {
|
||
let t = 1_000_000;
|
||
const rl = createRateLimiter({ now: () => t });
|
||
assert.equal(rl.allow('c1', 1, 'screen.capture'), true);
|
||
assert.equal(rl.allow('c1', 1, 'screen.capture'), false, '窗口内第 2 次被限');
|
||
assert.equal(rl.size(), 1);
|
||
|
||
// 滑动窗口边界,与旧实现逐字节同语义:`now - t < 60_000` 才算窗口内。
|
||
t += 60_000 - 1; // 59.999s:仍在窗口内,第 2 次被限
|
||
assert.equal(rl.allow('c1', 1, 'screen.capture'), false, '59.999s 仍算窗口内');
|
||
|
||
t += 1; // 恰好 60s:`60_000 < 60_000` 为 false → 旧时间戳出窗 → 重新放行
|
||
assert.equal(rl.allow('c1', 1, 'screen.capture'), true, '恰好 60s 出窗(沿用旧实现边界)');
|
||
assert.equal(rl.size(), 1, '自己的桶重建');
|
||
// 再来一个不相关的旧桶,它应在下次 allow 时被顺手清掉
|
||
rl.allow('c2', 1, 'other');
|
||
t += 61_000;
|
||
rl.allow('c3', 1, 'x');
|
||
assert.equal(rl.size(), 1, `过期键必须被清扫(只增不删 = 内存无界增长),实际 ${rl.size()}`);
|
||
});
|
||
|
||
test('A-P0-2④:容量上限 —— 自报 client_id 枚举造键,桶数有界', () => {
|
||
let t = 0;
|
||
const rl = createRateLimiter({ maxKeys: 100, now: () => (t += 1) });
|
||
for (let i = 0; i < 1000; i += 1) {
|
||
rl.allow(`attacker-${i}`, 5, 'screen.capture');
|
||
}
|
||
assert.ok(rl.size() <= 100, `桶数必须被容量上限约束,实际 ${rl.size()}`);
|
||
// 最早的键已被淘汰:它若再来,配额从零开始(可接受:枚举者本就拿不到稳定配额)
|
||
assert.equal(rl.allow('attacker-0', 5, 'screen.capture'), true);
|
||
});
|
||
|
||
test('A-P0-2:限速值与桶名解耦的结构证据(同值多能力各占一桶)', () => {
|
||
const rl = createRateLimiter();
|
||
const caps = ['a.x', 'b.x', 'c.x', 'd.x'];
|
||
for (const c of caps) rl.allow('c1', 3, c);
|
||
assert.equal(rl.size(), caps.length, '每个能力一个桶(key 里没有限速值)');
|
||
});
|