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:
@@ -299,68 +299,114 @@ func TestBuildRoute_Matrix(t *testing.T) {
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("仅 WARP → sniff + warp 规则(与改动前逐字节一致)", func(t *testing.T) {
|
||||
t.Run("仅 WARP → sniff + resolve + warp 规则", func(t *testing.T) {
|
||||
r := buildRoute(nil, warp)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) != 2 {
|
||||
t.Fatalf("规则数 = %d, want 2", len(rules))
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Error("首条不是 sniff")
|
||||
}
|
||||
if rules[1].(map[string]any)["outbound"] != warpOutboundTag {
|
||||
t.Error("次条不是 warp 分流")
|
||||
}
|
||||
if r["final"] != directOutboundTag {
|
||||
t.Errorf("final = %v, want %q", r["final"], directOutboundTag)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("仅 ACL → sniff + 放行 + 拒绝", func(t *testing.T) {
|
||||
r := buildRoute(acl, nil)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) != 3 {
|
||||
t.Fatalf("规则数 = %d, want 3", len(rules))
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Error("首条不是 sniff")
|
||||
}
|
||||
if _, ok := rules[1].(map[string]any)["user"]; !ok {
|
||||
t.Error("第二条不是放行规则")
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Error("第二条不是 resolve(域名形式的目的地需要先解析才能命中后续 ip_cidr 规则)")
|
||||
}
|
||||
if rules[2].(map[string]any)["action"] != "reject" {
|
||||
t.Error("第三条不是拒绝规则")
|
||||
if rules[2].(map[string]any)["outbound"] != warpOutboundTag {
|
||||
t.Error("第三条不是 warp 分流")
|
||||
}
|
||||
if r["final"] != directOutboundTag {
|
||||
t.Errorf("final = %v, want %q", r["final"], directOutboundTag)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("都开 → sniff + ACL(放行,拒绝) + warp,且 sniff 只出现一次", func(t *testing.T) {
|
||||
r := buildRoute(acl, warp)
|
||||
t.Run("仅 ACL → sniff + resolve + 放行 + 拒绝", func(t *testing.T) {
|
||||
r := buildRoute(acl, nil)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) != 4 {
|
||||
t.Fatalf("规则数 = %d, want 4", len(rules))
|
||||
}
|
||||
sniffs := 0
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Error("首条不是 sniff")
|
||||
}
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Error("第二条不是 resolve")
|
||||
}
|
||||
if _, ok := rules[2].(map[string]any)["user"]; !ok {
|
||||
t.Error("第三条不是放行规则")
|
||||
}
|
||||
if rules[3].(map[string]any)["action"] != "reject" {
|
||||
t.Error("第四条不是拒绝规则")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("都开 → sniff + resolve + ACL(放行,拒绝) + warp,且 sniff/resolve 各只出现一次", func(t *testing.T) {
|
||||
r := buildRoute(acl, warp)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) != 5 {
|
||||
t.Fatalf("规则数 = %d, want 5", len(rules))
|
||||
}
|
||||
sniffs, resolves := 0, 0
|
||||
for _, x := range rules {
|
||||
if x.(map[string]any)["action"] == "sniff" {
|
||||
switch x.(map[string]any)["action"] {
|
||||
case "sniff":
|
||||
sniffs++
|
||||
case "resolve":
|
||||
resolves++
|
||||
}
|
||||
}
|
||||
if sniffs != 1 {
|
||||
t.Errorf("sniff 出现 %d 次, want 1", sniffs)
|
||||
}
|
||||
if resolves != 1 {
|
||||
t.Errorf("resolve 出现 %d 次, want 1", resolves)
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Error("sniff 必须最先")
|
||||
}
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Error("resolve 必须紧跟 sniff 之后")
|
||||
}
|
||||
// ACL 全部规则必须排在 warp 之前:被拒绝的目的地不该有机会走 warp 出口
|
||||
if rules[3].(map[string]any)["outbound"] != warpOutboundTag {
|
||||
if rules[4].(map[string]any)["outbound"] != warpOutboundTag {
|
||||
t.Error("warp 规则必须排在最后")
|
||||
}
|
||||
if rules[2].(map[string]any)["action"] != "reject" {
|
||||
if rules[3].(map[string]any)["action"] != "reject" {
|
||||
t.Error("ACL 拒绝规则必须排在 warp 之前")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// I3:ip_cidr 目的地能被"域名形式"的请求绕过——ip_cidr 匹配的是已解析的目的地
|
||||
// 地址,客户端若发送域名形式(如 nas.51yanmei.com:5001)而不先解析,ip_cidr 规则
|
||||
// 不命中,请求穿透到 final:direct。route 块加 {"action":"resolve"} 让节点自己
|
||||
// 解析目的地,使域名形式收敛到 ip_cidr 规则上。resolve 必须紧跟 sniff 之后、且
|
||||
// 只在产出 route 块时才出现(与 sniff 同条件)。
|
||||
func TestBuildRoute_ResolvePositionedRightAfterSniff(t *testing.T) {
|
||||
acl := &ACLConfig{
|
||||
Enabled: true,
|
||||
AllowDpUUIDs: []string{"uuid-me"},
|
||||
Targets: []ACLTarget{{IPCIDR: []string{"182.92.213.171/32"}, Port: []int{5001}}},
|
||||
}
|
||||
r := buildRoute(acl, nil)
|
||||
rules := r["rules"].([]any)
|
||||
if len(rules) < 2 {
|
||||
t.Fatalf("规则数 = %d, 至少要有 sniff+resolve", len(rules))
|
||||
}
|
||||
if rules[0].(map[string]any)["action"] != "sniff" {
|
||||
t.Fatal("sniff 必须最先")
|
||||
}
|
||||
if rules[1].(map[string]any)["action"] != "resolve" {
|
||||
t.Fatal("resolve 必须紧跟 sniff 之后")
|
||||
}
|
||||
}
|
||||
|
||||
// 未配置 ACL 也未启用 WARP → 仍不产出 route 块(resolve 不该单独出现)。
|
||||
func TestBuildRoute_NeverConfigured_NoResolve(t *testing.T) {
|
||||
if got := buildRoute(nil, nil); got != nil {
|
||||
t.Errorf("buildRoute(nil,nil) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
// 成功加载后必须把快照落盘,否则 agent 一重启 fail-closed 就失效。
|
||||
func TestACL_PersistsLastGoodOnLoad(t *testing.T) {
|
||||
cfg := testConfig(t)
|
||||
|
||||
@@ -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{
|
||||
|
||||
Reference in New Issue
Block a user