对应审查建议的 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 项、应急处置表。
86 lines
4.4 KiB
JavaScript
86 lines
4.4 KiB
JavaScript
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-----`;
|