feat(agent): route 加 resolve action,堵住 ip_cidr 目的地的域名形式绕过

sing-box route 的 ip_cidr 匹配的是已解析的连接目的地地址;客户端若发的是域名
形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr + port 的拒绝规则不命中,
请求会绕过 ACL 直接落到 final:direct——DSM/RDP/SSH 因而对任何持有有效 dp_uuid
的 pangolin 用户可达,不受白名单约束。攻击者只需让客户端连接目的地时使用域名
而非 IP 即可绕过。

用户已拍板方案:在 sniff 之后紧跟加一条 {"action":"resolve"},让节点自己解析
目的地,使域名形式收敛到 ip_cidr 规则上(已用本机 sing-box 1.13.13 确认接受
[{"action":"sniff"},{"action":"resolve"},...] 这个形状,TestRenderedConfig_
PassesSingBoxCheck 也覆盖了这条产物)。route 规则顺序变为:
sniff → resolve → ACL 放行 → ACL 拒绝 → WARP。resolve 与 sniff 同条件——只在
产出 route 块时才出现,唯一且紧跟 sniff。

代价(用户已知情接受):节点从此会对连接目的地做 DNS 解析,是对"节点只见不透明
dp_uuid、不知道目的地"这条隐私姿态的一次主动后退,详见设计文档 §12。

TDD:更新 TestBuildRoute_Matrix 的四态断言(规则数 2/3/4 → 3/4/5,新增 resolve
计数/位置断言),新增 TestBuildRoute_ResolvePositionedRightAfterSniff(RED:此前
resolve 不存在)与 TestBuildRoute_NeverConfigured_NoResolve(未配置态不受影响)。
确认既有 TestRender_Warp_InjectsEndpointAndRoute(只断言首条 sniff、末条 warp,
不断言精确条数)未受影响,原样绿。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
wangjia
2026-07-23 07:44:58 +08:00
parent f147ffbe9e
commit aaa3384e3b
2 changed files with 85 additions and 31 deletions
+11 -3
View File
@@ -71,9 +71,16 @@ func renderSingboxConfig(creds []Cred, reality *agentv1.RealityInbound, hy2 *age
// 规则顺序是安全语义的一部分:
// 1. {"action":"sniff"} 唯一且最先。域名匹配依赖它取 TLS SNI(客户端多半发的是
// 已解析 IP),WARP 与 ACL 都需要,故在此统一产出一次,不由各自重复追加。
// 2. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远
// 2. {"action":"resolve"} 紧跟 sniff 之后,同样唯一且只在产出 route 块时才出现。
// ACL 的 ip_cidr 目的地(DSM/RDP/SSH 等独占端口服务)匹配的是已解析的连接
// 目的地地址——若客户端发的是域名形式(如 nas.51yanmei.com:5001)而节点不主动
// 解析,ip_cidr 规则不命中,请求会绕过 ACL 直接落到 final:direct。resolve 让
// 节点自己解析目的地,使域名形式收敛到 ip_cidr 规则上,堵住这个绕过口子
// (I3;这也是唯一一处从"节点不知道目的地"这条不变式主动后退的地方,是知情
// 接受的取舍,见 docs/private-dest-acl-design.html §12)。
// 3. ACL 规则(放行在前、拒绝在后)整体排在 WARP 之前:被 ACL 拒绝的目的地永远
// 不该还有机会被路由到 warp 出口。
// 3. final 恒为 direct。
// 4. final 恒为 direct。
func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
aclRules := acl.rules()
warpActive := warp.active()
@@ -81,8 +88,9 @@ func buildRoute(acl *ACLConfig, warp *WarpConfig) map[string]any {
return nil
}
rules := make([]any, 0, len(aclRules)+2)
rules := make([]any, 0, len(aclRules)+3)
rules = append(rules, map[string]any{"action": "sniff"})
rules = append(rules, map[string]any{"action": "resolve"})
rules = append(rules, aclRules...)
if warpActive {
rules = append(rules, map[string]any{