Files
nodeagent/tests/unit/rate-limit.test.mjs
T
jkooe 560b3c44e4 fix(agent): 限速桶 key 改能力名 + describeArgs 截断(A-P0-2 / A-P0-4)
架构审查两个 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 全绿
2026-10-26 00:14:17 +08:00

90 lines
4.7 KiB
JavaScript
Raw 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.
/**
* 限速器守卫(`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 里没有限速值)');
});