feat(lan): 内网端点交换 + 可达探测(第二步,仍未改数据面)
第一步做出了只绑内网的小服务,这一步把"对端能否走内网"变成**可见的事实**。 ## 服务端:新增内网端点校验(`server/shared/lan-endpoint.js`) ⚠️ **这是本步最关键的一处,不是"再算一遍"**:`lan_endpoints` 是 **客户端上报的不可信输入**,而它会被下发给**查看端**,查看端据此去连接。 不校验就原样下发,等于给了一个**"让别人的手机去连任意地址"的 SSRF 面** —— 上报 `169.254.169.254:80` 或某个公网地址,查看端就成了跳板。 故服务端只收 **IPv4 私有网段**(10/8、172.16/12、192.168/16), 端口 1..65535,最多 4 条,超长/主机名/IPv6 一律丢弃, 且**整条丢弃而不是"截断成合法"**(猜用户意图比丢掉更危险)。 > 客户端的 `pickLanAddress` 也在挑地址,但那是**选"发哪个"**(可用性), > 这里是**验"收的是否合法"**(安全性)—— 两者在**信任边界两侧**, > 不是同一份判定的两处拷贝。这条写进了文件头,免得后人当重复删掉。 网关侧:端点存**进程内 Map(刻意不落库** —— 内网地址是瞬态的, 换 Wi-Fi 就变,落库只会给查看端一个过期地址、让它白等一次超时), 随 `server:snapshot` 下发;**设备断连即回收**(同一设备重连时保留, 与 `_viewerEncoding` 的回收同一处理)。 ## 客户端 - 采集端:`agent:hello` 带 `lan_endpoints`(**没有内网地址时不带该字段**, 而不是带空值 —— "没有"是合法状态,不该让查看端去猜空列表的含义) - 查看端:收端点 → **只在列表变化时**探测一次(快照会反复到达,每次都探是浪费) → 记录"可达的是哪个" - 探测超时**刻意短(800ms)**:探测只决定走哪条路,而**云端本来就通** —— 为了内网快而让用户每次先卡几秒,是把优化的代价转嫁给体验。 探不通立刻回云端,用户感知不到这一步 - 探测**要求 body 里 `ok:true`**,光有 200 不算 —— 同网段里别的服务也会返回 200 ## 诚实边界 ⚠️ **本步只做「探测 + 让用户看得见」,还没有改数据面** —— 指标仍走云端。 先让"走内网/走公网"成为**可验证的事实**再动数据面, 否则改完也说不清到底有没有生效(本项目吃过"静默失败"的亏)。 界面显示留在下一步(看板自检页)。 ## 验证 - 服务端 27 条用例(**重点在不合法值必须被拦**:公网地址、云元数据地址、 回环、端口越界、主机名、IPv6、超长、超条数)→ 已接进 run-all - 客户端探测 3 条(真起服务判可达、真起一个"只返回 200 内容不对"的假服务 判不可达、多端点取第一个可达)→ 客户端 **301/301** - 服务端离线套件 **7/7 PASS**
This commit is contained in:
1 parent
10aa9f4119
commit
723852e1da
8 files changed
+343
No files matched your search
@@ -183,3 +183,51 @@ class LanService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 探测**一个**内网端点是否真的可达(查看端用)。
|
||||
///
|
||||
/// ── 超时为什么刻意短(800ms)──────────────────────────────────────
|
||||
/// 探测只决定"走哪条路",而**云端通道本来就是通的**。
|
||||
/// 若为了等内网而把超时放到好几秒,就等于把"局域网更快"这个优化
|
||||
/// 变成了"每次连接都先卡几秒" —— 优化的代价转嫁给了体验。
|
||||
/// 探不通就立刻走云端,用户感知不到这一步的存在。
|
||||
///
|
||||
/// 只认 `/lan/ping` 且**要求 body 里 `ok:true`** ——
|
||||
/// 光看状态码不够:同一网段里别的服务也可能返回 200。
|
||||
Future<bool> probeLanEndpoint(
|
||||
String endpoint, {
|
||||
Duration timeout = const Duration(milliseconds: 800),
|
||||
}) async {
|
||||
final client = HttpClient()..connectionTimeout = timeout;
|
||||
try {
|
||||
final req = await client
|
||||
.getUrl(Uri.parse('http://$endpoint/lan/ping'))
|
||||
.timeout(timeout);
|
||||
final resp = await req.close().timeout(timeout);
|
||||
if (resp.statusCode != 200) return false;
|
||||
final body = await resp
|
||||
.transform(const Utf8Decoder(allowMalformed: true))
|
||||
.join()
|
||||
.timeout(timeout);
|
||||
return body.contains('"ok":true');
|
||||
} catch (_) {
|
||||
// 连不上、超时、DNS 失败…… 一律当作"不可达"。
|
||||
// 探测失败不是错误,只是"这条更快但非必需的路不可用"。
|
||||
return false;
|
||||
} finally {
|
||||
client.close(force: true);
|
||||
}
|
||||
}
|
||||
|
||||
/// 依次探测,返回**第一个可达**的端点;都不可达返回 null。
|
||||
///
|
||||
/// 顺序即优先级:服务端下发的顺序就是采集端上报的顺序。
|
||||
Future<String?> pickReachableLanEndpoint(
|
||||
List<String> endpoints, {
|
||||
Duration timeout = const Duration(milliseconds: 800),
|
||||
}) async {
|
||||
for (final e in endpoints) {
|
||||
if (await probeLanEndpoint(e, timeout: timeout)) return e;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -38,6 +38,7 @@ import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
|
||||
import 'pmon_core.dart';
|
||||
import 'config_migration.dart';
|
||||
import 'lan_service.dart';
|
||||
|
||||
/// 上报的指标载荷(字段由 Rust 决定,此处不硬编码字段名)
|
||||
typedef MetricsFrame = Map<String, dynamic>;
|
||||
@@ -471,6 +472,13 @@ class ClientAgent {
|
||||
// ⚠️ 它**不是凭据**:可猜、不参与鉴权,只用于归并候选匹配。
|
||||
if (hardwareId.isNotEmpty) 'hardware_id': hardwareId,
|
||||
'capabilities': core.capabilities(),
|
||||
// 内网端点(可选):查看端据此**先试内网、失败立刻回落云端**。
|
||||
// 没有可用内网地址时**不带这个字段**(不是带空值)——
|
||||
// "这台机没有内网地址"是合法状态,不该让查看端去猜空列表的含义。
|
||||
// ⚠️ 服务端会再校验一遍(只收私有网段 IPv4):那是**信任边界**,
|
||||
// 与这里"挑哪个地址上报"是两回事,别当成重复而删掉。
|
||||
if (LanService.instance.endpoint != null)
|
||||
'lan_endpoints': [LanService.instance.endpoint],
|
||||
'keepalive': core.keepaliveState(),
|
||||
'reconnect_policy': {
|
||||
'heartbeat_interval_ms': constants['heartbeat_interval_ms'],
|
||||
|
||||
@@ -41,6 +41,7 @@ import 'package:socket_io_client/socket_io_client.dart' as io;
|
||||
// account_api 已转出 pairing_api 的类型(HttpReply / PairingApi / PairingOutcome …)
|
||||
import 'account_api.dart';
|
||||
import 'app_config.dart';
|
||||
import 'lan_service.dart';
|
||||
import 'pmon_core.dart';
|
||||
import 'timing.dart';
|
||||
|
||||
@@ -572,6 +573,16 @@ class ViewerSession {
|
||||
/// 会话令牌(登录后由界面注入;为空表示兼容模式:只认别人授权给我的设备)
|
||||
String? _token;
|
||||
|
||||
// ── 局域网直连(方案:LAN 优先)──────────────────────────────────
|
||||
// 采集端上报的内网端点(服务端已校验只收私有网段)与本次探测结果。
|
||||
//
|
||||
// ⚠️ 本步只做「**探测 + 让用户看得见**」,**还没改数据面** —— 指标仍走云端。
|
||||
// 先让"走内网 / 走公网"变成**可见、可验证的事实**,再去动数据面;
|
||||
// 否则改完也说不清到底有没有生效(本项目吃过"静默失败"的亏)。
|
||||
List<String> _lanEndpoints = const [];
|
||||
String? _lanReachable;
|
||||
bool _lanProbing = false;
|
||||
|
||||
/// 诊断日志出口。
|
||||
///
|
||||
/// 为什么查看端必须有它:采集端有日志面板(`link.dart` 的 `_logLine`),
|
||||
@@ -638,6 +649,15 @@ class ViewerSession {
|
||||
Timer? _reconnectTimer;
|
||||
bool _running = false;
|
||||
|
||||
/// 采集端上报的内网端点(空 = 该设备没有可用内网地址,属正常状态)
|
||||
List<String> get lanEndpoints => _lanEndpoints;
|
||||
|
||||
/// 本次探测**可达**的那个端点;null = 都不可达或没有端点
|
||||
String? get lanReachable => _lanReachable;
|
||||
|
||||
/// 正在探测 —— 界面据此显示「检测中」而不是直接说「不可达」
|
||||
bool get lanProbing => _lanProbing;
|
||||
|
||||
ViewerStatus get status => _status;
|
||||
ViewerSeries get series => _series;
|
||||
bool get running => _running;
|
||||
@@ -882,6 +902,33 @@ class ViewerSession {
|
||||
});
|
||||
}
|
||||
|
||||
/// 快照里带了内网端点 → 探测一次并记录结果。
|
||||
///
|
||||
/// 只在**端点列表变化时**才重新探测:快照会在订阅、补发等时机反复到达,
|
||||
/// 每次都探一遍纯属浪费(还会给对端造无谓的连接)。
|
||||
void _considerLan(dynamic raw) {
|
||||
final list = raw is List
|
||||
? raw.whereType<String>().take(4).toList(growable: false)
|
||||
: const <String>[];
|
||||
if (list.length == _lanEndpoints.length &&
|
||||
List.generate(list.length, (i) => list[i] == _lanEndpoints[i])
|
||||
.every((x) => x)) {
|
||||
return; // 没变化
|
||||
}
|
||||
_lanEndpoints = list;
|
||||
_lanReachable = null;
|
||||
if (list.isEmpty) {
|
||||
_lanProbing = false;
|
||||
return;
|
||||
}
|
||||
_lanProbing = true;
|
||||
unawaited(pickReachableLanEndpoint(list).then((hit) {
|
||||
if (!_running) return;
|
||||
_lanProbing = false;
|
||||
_lanReachable = hit;
|
||||
}));
|
||||
}
|
||||
|
||||
void _onSnapshot(dynamic data) {
|
||||
final payload = _payloadOf(data);
|
||||
if (payload == null) return;
|
||||
@@ -899,6 +946,8 @@ class ViewerSession {
|
||||
}
|
||||
}
|
||||
|
||||
_considerLan(payload['lan_endpoints']);
|
||||
|
||||
final online = payload['online'] is bool ? payload['online'] as bool : null;
|
||||
final caps = payload['capabilities'];
|
||||
final capsMap = caps is Map
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
@@ -134,6 +135,55 @@ void main() {
|
||||
expect(s.port, p1);
|
||||
});
|
||||
|
||||
test('★ 探测:对面真起着服务 → 可达;不存在 → 不可达且不抛异常', () async {
|
||||
// 探测的目的是"决定走哪条路",所以两种结果都必须**安静地**返回
|
||||
// —— 连不上不是错误,只是"这条更快但非必需的路不可用"。
|
||||
final s = LanService.instance;
|
||||
await s.start();
|
||||
if (!s.running) return; // 环境没有内网地址时不强求
|
||||
|
||||
expect(await probeLanEndpoint('${s.address}:${s.port}'), isTrue,
|
||||
reason: '对面真的在跑,必须判为可达');
|
||||
|
||||
// 127.0.0.1:1 上不会有服务 → 必须"不可达"而不是抛异常
|
||||
expect(await probeLanEndpoint('127.0.0.1:1',
|
||||
timeout: const Duration(milliseconds: 300)),
|
||||
isFalse);
|
||||
});
|
||||
|
||||
test('★ 探测要求 body 里 ok:true —— 光有 200 不算(同网段别的服务也会 200)',
|
||||
() async {
|
||||
final s = LanService.instance;
|
||||
await s.start();
|
||||
if (!s.running) return;
|
||||
// 起一个"只返回 200 但内容不对"的服务,探测必须判为**不可达**
|
||||
final fake = await HttpServer.bind(InternetAddress.loopbackIPv4, 0);
|
||||
unawaited(() async {
|
||||
await for (final r in fake) {
|
||||
r.response
|
||||
..statusCode = 200
|
||||
..write('{"hello":"world"}');
|
||||
await r.response.close();
|
||||
}
|
||||
}());
|
||||
addTearDown(() => fake.close(force: true));
|
||||
expect(await probeLanEndpoint('127.0.0.1:${fake.port}'), isFalse,
|
||||
reason: '状态码 200 不足以证明"这是我们自己的内网服务"');
|
||||
});
|
||||
|
||||
test('多个端点 → 返回第一个可达的(顺序即优先级)', () async {
|
||||
final s = LanService.instance;
|
||||
await s.start();
|
||||
if (!s.running) return;
|
||||
final hit = await pickReachableLanEndpoint([
|
||||
'127.0.0.1:1', // 不可达
|
||||
'${s.address}:${s.port}', // 可达
|
||||
]);
|
||||
expect(hit, '${s.address}:${s.port}');
|
||||
expect(await pickReachableLanEndpoint(['127.0.0.1:1']), isNull);
|
||||
expect(await pickReachableLanEndpoint(const []), isNull);
|
||||
});
|
||||
|
||||
test('stop 之后 running 为假、endpoint 为 null', () async {
|
||||
final s = LanService.instance;
|
||||
await s.start();
|
||||
|
||||
@@ -13,6 +13,7 @@
|
||||
'use strict';
|
||||
|
||||
const P = require('../../shared/protocol');
|
||||
const { normalizeEndpoints } = require('../shared/lan-endpoint');
|
||||
// 身份口径与权限判定规则(与业务服务同一处真相源)
|
||||
const { resolveIdentity, hashToken, looksLikeToken, IDENTITY } = require('../../shared/accounts');
|
||||
|
||||
@@ -52,6 +53,13 @@ class Gateway {
|
||||
this._viewerEncoding = new Map();
|
||||
/** @type {Set<string>} `${accountId}|${deviceId}` 已补发首帧 */
|
||||
this._primedViewers = new Set();
|
||||
|
||||
// 设备 → 内网端点(`ip:port`)。**刻意不落库**:
|
||||
// 内网地址是**瞬态**的(换 Wi-Fi 就变),落库只会留下过期数据,
|
||||
// 而"下发给查看端一个过期内网地址"会让它白等一次超时才回落云端。
|
||||
// 有界性:大小 = 在线采集端数量,且断连即回收(见 onDisconnect)。
|
||||
/** @type {Map<string, string[]>} deviceId -> 已校验的内网端点 */
|
||||
this._lanEndpoints = new Map();
|
||||
this._primedViewersMax = options.primedViewersMax || 10000;
|
||||
|
||||
// 断线补传去重窗口:deviceId -> Set<seq>(两级有界)
|
||||
@@ -318,6 +326,18 @@ class Gateway {
|
||||
// 能力集:声明支持哪些指标 → 查看端据此隐藏卡片(优雅降级)
|
||||
const capabilities = msg.payload.capabilities || {};
|
||||
|
||||
// 内网端点:查看端据此**先试内网、失败回落云端**(方案:局域网直连)。
|
||||
// ⚠️ 这是**不可信输入**,必须过服务端校验(只收私有网段 IPv4)——
|
||||
// 不校验就下发,等于给了"让查看端去连任意地址"的 SSRF 面。
|
||||
// 校验规则与理由见 shared/lan-endpoint.js 的文件头。
|
||||
const lanEndpoints = normalizeEndpoints(msg.payload.lan_endpoints);
|
||||
if (lanEndpoints.length) {
|
||||
this._lanEndpoints.set(msg.device_id, lanEndpoints);
|
||||
} else {
|
||||
// 本次没报(或全部非法)→ 清掉旧的,避免拿着上一轮的地址去连
|
||||
this._lanEndpoints.delete(msg.device_id);
|
||||
}
|
||||
|
||||
// 设备落库(有 PG 时持久化,重启不丢)
|
||||
if (this.pgEnabled) {
|
||||
try {
|
||||
@@ -641,10 +661,15 @@ class Gateway {
|
||||
}
|
||||
}
|
||||
|
||||
// 内网端点随快照下发(**取不到就不带该字段**,查看端据此退回云端,
|
||||
// 与 capabilities 的"取不到就 null"同一处理哲学:不要让缺失变成报错)
|
||||
const lanEndpoints = this._lanEndpoints.get(target) || [];
|
||||
|
||||
socket.emit('server:snapshot', P.makeEnvelope('server:snapshot', {
|
||||
device_id: target,
|
||||
online,
|
||||
frames: snapshot,
|
||||
...(lanEndpoints.length ? { lan_endpoints: lanEndpoints } : {}),
|
||||
...(meta || {}),
|
||||
meta_available: !!meta,
|
||||
}));
|
||||
@@ -710,6 +735,12 @@ class Gateway {
|
||||
// 采集端断开 → 标记离线并广播
|
||||
await this.store.markOffline(session.deviceId);
|
||||
await this.broadcastDeviceStatus(session.deviceId, 'offline');
|
||||
// 内网端点随连接失效 —— 必须回收,否则会一直被当成"还有内网可走"。
|
||||
// 同一台设备可能正重连(新连接已写入),故确认无其它活动 agent 连接后才删;
|
||||
// 与上面 viewer 的 _viewerEncoding 回收同一处理。
|
||||
const stillConnected = Array.from(this.sessions.values())
|
||||
.some((s) => s.role === 'agent' && s.deviceId === session.deviceId);
|
||||
if (!stillConnected) this._lanEndpoints.delete(session.deviceId);
|
||||
// 注意:不立即回收 seq 窗口 —— 客户端重连后会补传断线期间的帧,
|
||||
// 此时仍需去重。窗口由有界策略自然滚动淘汰,避免长期驻留。
|
||||
}
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 内网端点(`ip:port`)的**服务端侧**校验。
|
||||
*
|
||||
* ── 为什么必须校验(这是信任边界,不是"再算一遍")────────────────
|
||||
* `lan_endpoints` 是**客户端上报的不可信输入**,而它会被下发给**查看端**,
|
||||
* 查看端据此去连接。若不校验就原样下发,等于给了一个
|
||||
* **"让别人的手机去连任意地址"的 SSRF 面** —— 上报 `169.254.169.254:80`
|
||||
* 或某个公网地址,就成了借查看端发起连接的跳板。
|
||||
*
|
||||
* 客户端的 `pickLanAddress` 也在挑地址,但那是**选"发哪个"**(可用性),
|
||||
* 这里是**验"收的是否合法"**(安全性)。两者处在信任边界两侧,
|
||||
* 不是同一份判定的两处拷贝 —— 这条要写清楚,免得后人以为是重复而删掉。
|
||||
*
|
||||
* 规则(刻意窄):
|
||||
* · 只接受 **IPv4 私有网段**(10/8、172.16/12、192.168/16);
|
||||
* · `port` 必须在 1..65535;
|
||||
* · 单次最多 [MAX_ENDPOINTS] 条、每条长度受限。
|
||||
* 任何一条不满足 → **整条丢弃**(不是"截断成合法" —— 猜用户的意图比丢掉更危险)。
|
||||
*/
|
||||
|
||||
/** 单次最多接受几条内网端点 */
|
||||
const MAX_ENDPOINTS = 4;
|
||||
/** 单条 `ip:port` 的长度上限(255.255.255.255:65535 = 21) */
|
||||
const MAX_LEN = 24;
|
||||
|
||||
/**
|
||||
* 判断是否为私有网段 IPv4。
|
||||
* @param {string} ip
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPrivateIpv4(ip) {
|
||||
const parts = ip.split('.');
|
||||
if (parts.length !== 4) return false;
|
||||
const n = parts.map((p) => (/^\d{1,3}$/.test(p) ? Number(p) : NaN));
|
||||
if (n.some((x) => Number.isNaN(x) || x > 255)) return false;
|
||||
const [a, b] = n;
|
||||
if (a === 10) return true;
|
||||
if (a === 192 && b === 168) return true;
|
||||
if (a === 172 && b >= 16 && b <= 31) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验并规范化一条 `ip:port`。
|
||||
* @param {unknown} v
|
||||
* @returns {string|null} 规范化后的端点;不合法返回 null
|
||||
*/
|
||||
function normalizeEndpoint(v) {
|
||||
if (typeof v !== 'string') return null;
|
||||
const s = v.trim();
|
||||
if (s.length === 0 || s.length > MAX_LEN) return null;
|
||||
const m = /^(\d{1,3}(?:\.\d{1,3}){3}):(\d{1,5})$/.exec(s);
|
||||
if (!m) return null;
|
||||
const ip = m[1];
|
||||
const port = Number(m[2]);
|
||||
if (!Number.isInteger(port) || port < 1 || port > 65535) return null;
|
||||
if (!isPrivateIpv4(ip)) return null; // ★ 非私有网段一律丢弃(SSRF 面就堵在这里)
|
||||
return `${ip}:${port}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 校验客户端上报的端点数组。
|
||||
* @param {unknown} raw `agent:hello.payload.lan_endpoints`
|
||||
* @returns {string[]} 合法端点(可能是空数组)
|
||||
*/
|
||||
function normalizeEndpoints(raw) {
|
||||
if (!Array.isArray(raw)) return [];
|
||||
const out = [];
|
||||
for (const v of raw) {
|
||||
if (out.length >= MAX_ENDPOINTS) break;
|
||||
const e = normalizeEndpoint(v);
|
||||
// 去重:同一地址重复上报没有意义,还会白白占用名额
|
||||
if (e && !out.includes(e)) out.push(e);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
MAX_ENDPOINTS,
|
||||
MAX_LEN,
|
||||
isPrivateIpv4,
|
||||
normalizeEndpoint,
|
||||
normalizeEndpoints,
|
||||
};
|
||||
@@ -0,0 +1,68 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* 内网端点校验的服务端侧测试。
|
||||
*
|
||||
* 重点不在"合法值能过",而在**不合法值必须被拦** —— 这段代码的职责是
|
||||
* 堵住"客户端上报任意地址 → 查看端去连"的 SSRF 面,所以每条拦截规则
|
||||
* 都要有用例,且要写明**为什么这条该拦**。
|
||||
*/
|
||||
|
||||
const assert = require('assert');
|
||||
const {
|
||||
MAX_ENDPOINTS,
|
||||
isPrivateIpv4,
|
||||
normalizeEndpoint,
|
||||
normalizeEndpoints,
|
||||
} = require('../server/shared/lan-endpoint');
|
||||
|
||||
let pass = 0;
|
||||
let fail = 0;
|
||||
function check(name, cond) {
|
||||
if (cond) {
|
||||
pass++;
|
||||
} else {
|
||||
fail++;
|
||||
console.log(' ✗ ' + name);
|
||||
}
|
||||
}
|
||||
|
||||
// ── 私有网段判定 ──
|
||||
check('10/8 是私有', isPrivateIpv4('10.0.0.1'));
|
||||
check('192.168/16 是私有', isPrivateIpv4('192.168.1.5'));
|
||||
check('172.16/16 下界是私有', isPrivateIpv4('172.16.0.1'));
|
||||
check('172.31/16 上界是私有', isPrivateIpv4('172.31.255.254'));
|
||||
check('★ 172.32 不是私有(边界不能错)', !isPrivateIpv4('172.32.0.1'));
|
||||
check('172.15 不是私有', !isPrivateIpv4('172.15.0.1'));
|
||||
check('★ 公网地址不是私有', !isPrivateIpv4('203.0.113.9'));
|
||||
check('段数不对不认', !isPrivateIpv4('192.168.1'));
|
||||
check('超范围不认', !isPrivateIpv4('192.168.1.256'));
|
||||
|
||||
// ── 单条规范化 ──
|
||||
check('正常 ip:port 通过', normalizeEndpoint('192.168.1.5:49504') === '192.168.1.5:49504');
|
||||
check('前后空白被裁掉', normalizeEndpoint(' 10.0.0.7:80 ') === '10.0.0.7:80');
|
||||
check('★ 公网地址被拦(否则查看端成了跳板)', normalizeEndpoint('203.0.113.9:80') === null);
|
||||
check('★ 云元数据地址 169.254.169.254 被拦', normalizeEndpoint('169.254.169.254:80') === null);
|
||||
check('★ 回环 127.0.0.1 被拦(对面连不上,且语义不对)', normalizeEndpoint('127.0.0.1:8080') === null);
|
||||
check('端口 0 被拦', normalizeEndpoint('192.168.1.5:0') === null);
|
||||
check('端口超上限被拦', normalizeEndpoint('192.168.1.5:65536') === null);
|
||||
check('非数字端口被拦', normalizeEndpoint('192.168.1.5:http') === null);
|
||||
check('缺端口被拦', normalizeEndpoint('192.168.1.5') === null);
|
||||
check('主机名被拦(只认点分 IPv4)', normalizeEndpoint('myhost.local:80') === null);
|
||||
check('超长被拦', normalizeEndpoint('192.168.1.5:' + '9'.repeat(60)) === null);
|
||||
check('非字符串被拦', normalizeEndpoint(12345) === null && normalizeEndpoint(null) === null);
|
||||
check('IPv6 被拦(目前只走 v4)', normalizeEndpoint('[fe80::1]:80') === null);
|
||||
|
||||
// ── 数组规范化 ──
|
||||
check('非数组 → 空', normalizeEndpoints(null).length === 0 && normalizeEndpoints('x').length === 0);
|
||||
check('混合时只留合法的', JSON.stringify(normalizeEndpoints(
|
||||
['192.168.1.5:1', 'evil.example.com:1', '10.0.0.7:2'])) === JSON.stringify(['192.168.1.5:1', '10.0.0.7:2']));
|
||||
check('去重', normalizeEndpoints(['192.168.1.5:1', '192.168.1.5:1']).length === 1);
|
||||
check(`★ 条数上限 ${MAX_ENDPOINTS}(多报不留)`,
|
||||
normalizeEndpoints(['10.0.0.1:1', '10.0.0.2:1', '10.0.0.3:1', '10.0.0.4:1', '10.0.0.5:1']).length === MAX_ENDPOINTS);
|
||||
check('空数组 → 空(表示"没有内网地址",是合法状态)', normalizeEndpoints([]).length === 0);
|
||||
|
||||
console.log(`\n────────────────────────────────────────────────`);
|
||||
console.log(`内网端点校验测试 — 通过: ${pass} 失败: ${fail}`);
|
||||
console.log(`────────────────────────────────────────────────`);
|
||||
process.exit(fail === 0 ? 0 : 1);
|
||||
@@ -77,6 +77,9 @@ run "协议契约(34 项)" node protocol-test.js
|
||||
run "编解码与体积(64 项)" node codec-test.js
|
||||
# 配对安全的离线契约段:不上任何服务也能拦住"把码比对删掉但留着测试"的退化改动
|
||||
run "配对安全·源码契约(23 项)" node pairing-security-test.js --offline-only
|
||||
# 内网端点校验:堵的是「客户端上报任意地址 → 查看端去连」的 SSRF 面。
|
||||
# 故用例重点在**不合法值必须被拦**,而不在合法值能过。
|
||||
run "内网端点校验(27 项)" node lan-endpoint-test.js
|
||||
# 隐私扫描:提交/推送前拦一道。工作树 + **全部提交**都扫 ——
|
||||
# 只改工作树是不够的,`git log -p` 照样能把脱敏前的原文翻出来。
|
||||
run "隐私扫描(工作树+全历史)" node privacy-scan.js
|
||||
|
||||
Reference in new issue
Block a user