Files
nodeagent/tests/unit/net.test.mjs
T
jkooe 0d59d6032b feat(v21): 第一批安全加固 —— 证书指纹钉住 + 来源网段白名单
对应审查建议的 P0 两条:这两项是**真实可利用的攻击路径**,其余(发现最小化/失败封禁等)
留待后续批次。

## ① 证书指纹钉住(TOFU)—— 挡同网段中间人
自签证书没有 CA 链,历史上客户端只能 `insecure` 接受任意证书 → 攻击者可**透明中继**:
拿不到钥匙(挑战-应答挡得住冒名),但能读到截屏/剪贴板/键入/exec 输出。

- 被控端:`install.ps1` 打印证书指纹(sha256 of DER)供**带外核对**;
  `system.info.build.cert_sha256` 与 `auth_ok.build.cert_sha256` 上报
- 控制端:首次连接自动钉进 `nodes.<名>.cert_sha256`;之后**严格比对**,
  不一致即 `E_CERT_MISMATCH`(-32422) 拒连,并提示 `--forget-cert` 重钉
- 额外做**交叉确认**:实际握手证书 vs auth_ok 自报,不一致同样拒绝(防自报被篡改)
- 指纹基于 **DER**(不是 PEM 字节),保证与 Windows .NET `GetRawCertData()` 同值

**踩坑记录(写进代码注释,避免后人重走)**:
1. `ws` 客户端的 `upgrade` 事件**只传 IncomingMessage**,拿不到 socket
2. socket 要在 `open` 之后从 `ws._socket` 取(TLSSocket)
3. Node ≥15 的 `getPeerCertificate()` 返回**对象**,DER 在 `.raw` 上(老版本才是 Buffer)
   —— 现已双兼容

## ② 来源网段白名单 `allow_from` —— 收敛暴露面
被控端默认监听 `0.0.0.0`(远程接入所需),但**换到不可信网络就自动暴露**。

- 支持 CIDR / 单 IP / `*`;**未配置 = 放行全部**(保持向后兼容,不打断现有部署)
- 拒绝发生在**业务握手之前**(不做 TLS/挑战应答,省资源),并记 `net.deny` 审计
- 判定按**前缀比特**而非字符串相等:Node 在 IPv6 监听上把 IPv4 客户端报成
  `::ffff:a.b.c.d`,已归一化(单测覆盖)
- `system.info.network.allow_from` 上报状态;`nodeagent info` 一眼看出是否已收敛

## 验证(本地真跑,非纸面)
- 白名单不含本机 → 连接被拒(agent 侧 WARN + 审计留痕)✓
- 白名单含本机 → 连上,并**自动钉住**指纹 ✓
- **换掉被控端证书后重连 → `E_CERT_MISMATCH` 拒绝**(期望 2ed4957b… / 实际 ca8dc20c…)✓
- 新增 e2e 用例(自起 TLS agent)覆盖上述四种情形 + agent 自报与实际证书一致性 ✓
- 新增单测 9 例(CIDR 前缀匹配 / IPv4-mapped 归一 / IPv6 zone / PEM≡DER / 篡改即变)
- 全量:单测 **159**(150→159)· e2e **29**(28→29)全绿

## 文档
新增 `docs/SECURITY.md`:信任模型(钥匙=root)、认证现状与**已知限制**(ACL 身份维度仅在
ed25519 有效)、本次两项加固的用法、尚未收敛的 6 项、应急处置表。
2026-10-05 02:25:19 +08:00

86 lines
4.4 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.
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { X509Certificate } from 'node:crypto';
import { ipInAllowlist, certFingerprint, shortFingerprint } from '../../packages/protocol/dist/index.js';
// ---------- v21:来源网段白名单 ----------
test('ipInAllowlist:未配置 = 放行全部(向后兼容)', () => {
assert.equal(ipInAllowlist('1.2.3.4'), true);
assert.equal(ipInAllowlist('1.2.3.4', []), true);
assert.equal(ipInAllowlist('1.2.3.4', ['*']), true);
});
test('ipInAllowlist:CIDR 按前缀比特匹配(不是字符串相等)', () => {
assert.equal(ipInAllowlist('192.168.0.5', ['192.168.0.0/24']), true);
assert.equal(ipInAllowlist('192.168.1.5', ['192.168.0.0/24']), false);
assert.equal(ipInAllowlist('10.1.2.3', ['10.0.0.0/8']), true);
assert.equal(ipInAllowlist('11.1.2.3', ['10.0.0.0/8']), false);
assert.equal(ipInAllowlist('192.168.0.200', ['192.168.0.128/25']), true);
assert.equal(ipInAllowlist('192.168.0.100', ['192.168.0.128/25']), false);
});
test('ipInAllowlist:单 IP 与多条混合', () => {
assert.equal(ipInAllowlist('127.0.0.1', ['127.0.0.1']), true);
assert.equal(ipInAllowlist('127.0.0.2', ['127.0.0.1', '10.0.0.0/8']), false);
assert.equal(ipInAllowlist('10.9.9.9', ['127.0.0.1', '10.0.0.0/8']), true);
});
test('ipInAllowlist:IPv4-mapped IPv6 必须与纯 IPv4 判等(Node 的监听器会这么报)', () => {
// 关键:Node 在 IPv6 监听上把 IPv4 客户端报成 ::ffff:a.b.c.d
assert.equal(ipInAllowlist('::ffff:192.168.0.5', ['192.168.0.0/24']), true);
assert.equal(ipInAllowlist('::ffff:10.0.0.1', ['192.168.0.0/24']), false);
});
test('ipInAllowlist:IPv6 前缀与 zone 后缀', () => {
assert.equal(ipInAllowlist('fe80::1', ['fe80::/10']), true);
assert.equal(ipInAllowlist('fe80::1%en0', ['fe80::/10']), true, '带 zone 后缀应归一化');
assert.equal(ipInAllowlist('2001:db8::1', ['fe80::/10']), false);
});
test('ipInAllowlist:非法来源地址在配了白名单时保守拒绝', () => {
assert.equal(ipInAllowlist('not-an-ip', ['10.0.0.0/8']), false);
assert.equal(ipInAllowlist('999.1.1.1', ['10.0.0.0/8']), false);
});
// ---------- v21:证书指纹 ----------
test('certFingerprint:PEM 与 DER 得到同一值(Windows 侧用 .NET 算 DER)', () => {
// 固定的自签证书夹具(不引第三方依赖,保证测试确定性)
const der = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw);
assert.equal(certFingerprint(FIXTURE_CERT), certFingerprint(der), 'PEM 与 DER 必须算出同一指纹');
assert.match(certFingerprint(FIXTURE_CERT), /^[0-9a-f]{64}$/, '应为 64 位小写 hex');
});
test('certFingerprint:不同证书得到不同指纹(改一个字节即变)', () => {
const raw = new Uint8Array(new X509Certificate(FIXTURE_CERT).raw);
raw[raw.length - 1] = (raw[raw.length - 1] + 1) & 0xff; // 篡改末字节
assert.notEqual(certFingerprint(FIXTURE_CERT), certFingerprint(raw));
});
test('shortFingerprint:可读格式且保留足够位数', () => {
const fp = 'a'.repeat(64);
const s = shortFingerprint(fp);
assert.ok(s.includes(':'));
assert.ok(s.replace(/:/g, '').length >= 20, '至少保留 20 位十六进制便于人工比对');
});
/** 固定的自签证书(仅测试用,与任何真实部署无关)。 */
const FIXTURE_CERT = `-----BEGIN CERTIFICATE-----
MIIDCjCCAfKgAwIBAgIJVXL5GdEwCNb0MA0GCSqGSIb3DQEBCwUAMCExHzAdBgNV
BAMTFm5vZGVhZ2VudC10ZXN0LWZpeHR1cmUwHhcNMjYxMDA0MTgxOTQxWhcNMzYx
MDAxMTgxOTQxWjAhMR8wHQYDVQQDExZub2RlYWdlbnQtdGVzdC1maXh0dXJlMIIB
IjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAuxJei6y331/yEUAq71AcsXfV
tSOe2s3wKb2zqR2JKCJBSEmIIOZbDGfu5UE8pPkVmyOElQhipOQu5vFhjhbVE7Vs
WTFMCwu2uxpaJdYJ+BJqnPyo/gZMLGRzjL+FC5VWgZz4akP05vx4J7mMDksF+2qH
5aNGTVxMwRX3AoFh+kRjngv/bO5P7w+17VTY7TkC29Fa5fNSIcuFABQa1ToL1CCW
iX38nZR9iLaPAnNMGhjZTwgbTSGIVi06EIeK+2CVnlPt6eaUDLNPxGPME/3Fs5PK
xYTfDqOT9lfeKGD/AFK4RbqSpLlMm9QDT2TVDp/s9rMRjD9sTJQLEI/NiJHKDQID
AQABo0UwQzAMBgNVHRMEBTADAQH/MAsGA1UdDwQEAwIC9DAmBgNVHREEHzAdhhto
dHRwOi8vZXhhbXBsZS5vcmcvd2ViaWQjbWUwDQYJKoZIhvcNAQELBQADggEBAFJa
7lpph2qGIy8k7BlC2FMbxoC7/m5ieIKv1csTPiWlxHXGp414TX7oX1Z1t2UOl4Un
M0v1OkI9IPU2hcfZLHp8H02/xsp8VxElYiosZU4tzjce4oN/Rqlk9EUT4mc9OpdH
2CwVzXoDub5980VlAF7Ujg/jCii+WIDVSkEqGHfi/QAuZKmNa6itYS9E8TCQDTA4
K9hC6qcRROVd4upaXxkQQ7EWYTFjujur3JKNqof+LHei8rb88NrVCEUDJshwn4zi
q8v1k2VO4R7E2PnYRdFdYeQTzc/PxeJmFM7LKYVK+oLy6GgmecdQKKy0Bs+RDG4a
LXRExo5k4XnxHRJ2TP4=
-----END CERTIFICATE-----`;