package main import ( "encoding/json" "net/http" "net/http/httptest" "net/url" "strings" "testing" ) // 本文件是 2026-10-22 渗透测试报告的回归护栏。 // // 被审计出的核心问题:管理 API 的鉴权是「默认放行」—— // requireAdminToken 在 NAVI_ADMIN_REQUIRE_TOKEN 未设置时直接 return true。 // 而线上 login 域(kind=auth)的外层 server 块没有任何 auth_request, // 于是全部管理接口(改路由表、改门口气令、写门户 HTML、读浏览历史)对公网裸奔。 // // 下面这些用例把「默认拒绝」钉死:任何把默认值改回放行的改动都会让它们失败。 // withSecurityDefaults 临时恢复「生产默认」(无 token、不允许匿名、不允许探测回环)。 func withSecurityDefaults(t *testing.T) { t.Helper() oldAdmin, oldAllow, oldLoop := adminToken, allowUnauthenticated, allowLoopbackFrameable adminToken = "" allowUnauthenticated = false allowLoopbackFrameable = false t.Cleanup(func() { adminToken, allowUnauthenticated, allowLoopbackFrameable = oldAdmin, oldAllow, oldLoop }) } // TestAdminAPIFailsClosedByDefault 钉死最关键的回归点: // 未配置 NAVI_ADMIN_TOKEN 时,管理接口必须拒绝,不能放行。 func TestAdminAPIFailsClosedByDefault(t *testing.T) { withSecurityDefaults(t) rec := httptest.NewRecorder() if requireAdminToken(rec, httptest.NewRequest(http.MethodPost, "/api/gateway/routes", nil)) { t.Fatal("未配置 token 时 requireAdminToken 必须返回 false(fail-closed)," + "否则管理 API 会对公网裸奔") } if rec.Code != http.StatusUnauthorized { t.Fatalf("拒绝时应返回 401,实际 %d", rec.Code) } } // TestAdminAPIRejectsWithoutTokenEvenIfFlagUnset 覆盖原缺陷的准确形态: // 即使 NAVI_ADMIN_REQUIRE_TOKEN 没设置,只要配了 token 就必须校验。 func TestAdminAPIRejectsWithoutTokenEvenIfFlagUnset(t *testing.T) { withSecurityDefaults(t) adminToken = "expected-secret" // 无 Authorization 头 rec := httptest.NewRecorder() if requireAdminToken(rec, httptest.NewRequest(http.MethodGet, "/api/history", nil)) { t.Fatal("配了 token 就必须校验,不能因为开关未设置而放行") } if rec.Code != http.StatusUnauthorized { t.Fatalf("应返回 401,实际 %d", rec.Code) } // 错误 token req := httptest.NewRequest(http.MethodGet, "/api/history", nil) req.Header.Set("Authorization", "Bearer wrong") rec = httptest.NewRecorder() if requireAdminToken(rec, req) { t.Fatal("错误 token 必须被拒绝") } // 正确 token req = httptest.NewRequest(http.MethodGet, "/api/history", nil) req.Header.Set("Authorization", "Bearer expected-secret") rec = httptest.NewRecorder() if !requireAdminToken(rec, req) { t.Fatalf("正确 token 应放行,实际 %d: %s", rec.Code, rec.Body.String()) } } // TestSyncInfoDoesNotLeakToken 钉死 §2① 的令牌泄露: // /api/sync-info 不得回显 syncToken 本体。 func TestSyncInfoDoesNotLeakToken(t *testing.T) { old := syncToken syncToken = "0499c44917c9c0396167d7f9192c2a7597c8d160e0ba42db" t.Cleanup(func() { syncToken = old }) allowUnauthenticated = true // 放行鉴权,专测「即使能进去也不该回显令牌」 t.Cleanup(func() { allowUnauthenticated = false }) rec := httptest.NewRecorder() handleSyncInfo(rec, httptest.NewRequest(http.MethodGet, "/api/sync-info", nil)) body := rec.Body.String() if strings.Contains(body, syncToken) { t.Fatalf("sync-info 不得回显 syncToken 本体,实际响应: %s", body) } var got map[string]any if err := json.Unmarshal([]byte(body), &got); err != nil { t.Fatalf("响应不是合法 JSON: %v", err) } if _, exists := got["syncToken"]; exists { t.Fatal("响应里不应再有 syncToken 字段") } } // TestSyncInfoRequiresAdmin 钉死:sync-info 自身也必须鉴权。 func TestSyncInfoRequiresAdmin(t *testing.T) { withSecurityDefaults(t) rec := httptest.NewRecorder() handleSyncInfo(rec, httptest.NewRequest(http.MethodGet, "/api/sync-info", nil)) if rec.Code != http.StatusUnauthorized { t.Fatalf("未鉴权访问 sync-info 应 401,实际 %d", rec.Code) } } // TestHistoryListRequiresAdmin 钉死 §4:浏览历史 GET 不得匿名可读。 func TestHistoryListRequiresAdmin(t *testing.T) { withSecurityDefaults(t) rec := httptest.NewRecorder() handleHistoryList(rec, httptest.NewRequest(http.MethodGet, "/api/history", nil)) if rec.Code != http.StatusUnauthorized { t.Fatalf("未鉴权读取浏览历史应 401,实际 %d", rec.Code) } } // TestFrameableRequiresAdmin 钉死 §3:SSRF 探测接口必须鉴权。 func TestFrameableRequiresAdmin(t *testing.T) { withSecurityDefaults(t) req := httptest.NewRequest(http.MethodGet, "/api/frameable?url=http://example.com/", nil) rec := httptest.NewRecorder() handleFrameable(rec, req) if rec.Code != http.StatusUnauthorized { t.Fatalf("未鉴权调用 frameable 应 401,实际 %d", rec.Code) } } // TestFrameableBlocksInternalTargets 钉死 §3 的内网探测能力: // 即使管理员在用,也不允许把本服务当成内网扫描器。 func TestFrameableBlocksInternalTargets(t *testing.T) { withSecurityDefaults(t) allowUnauthenticated = true targets := []string{ "http://127.0.0.1:3005/api/schema", "http://127.0.0.1:3000/", "http://localhost:6379/", "http://192.168.2.60:8081/", "http://192.168.2.30:80/", "http://10.0.0.1/", "http://172.16.0.1/", "http://169.254.169.254/latest/meta-data/", // 云元数据,经典 SSRF 目标 "http://[::1]:3005/", } for _, target := range targets { got := frameableVerdict(target, "") if got.Conclusive { t.Errorf("%s: 内网/回环目标应被判为不可确定(拒绝探测),实际 %+v", target, got) } if !strings.Contains(got.Reason, "回环") && !strings.Contains(got.Reason, "内网") { t.Errorf("%s: 拒绝原因应说明是内网地址,实际 %q", target, got.Reason) } } } // TestIsPrivateOrLoopbackURL 单测地址分类,覆盖用域名指向内网的绕过方式。 func TestIsPrivateOrLoopbackURL(t *testing.T) { internal := []string{ "http://127.0.0.1/", "http://127.0.0.1:8080/x", "http://localhost/", "http://0.0.0.0/", "http://[::1]/", "http://10.1.2.3/", "http://192.168.2.106/", "http://172.20.0.1/", "http://169.254.1.1/", "http://localhost.localdomain/", } for _, raw := range internal { u := mustParseURL(t, raw) if !isPrivateOrLoopbackURL(u) { t.Errorf("%s 应被判为内网/回环", raw) } } // 公网字面量(不做网络解析,避免测试依赖 DNS) u := mustParseURL(t, "http://8.8.8.8/") if isPrivateOrLoopbackURL(u) { t.Error("8.8.8.8 不应被判为内网") } } // mustParseURL 是测试辅助:解析失败直接 Fatal。 func mustParseURL(t *testing.T, raw string) *url.URL { t.Helper() u, err := url.Parse(raw) if err != nil { t.Fatalf("解析 %q 失败: %v", raw, err) } return u } // TestAuthKindWithAdminGuardProtectsAPI 钉死部署层防护: // login 域一旦开启 AdminGuard,/api/ 与 /admin/ 必须经过 auth_request, // 而登录流程本身(/_portal/login 等)仍要匿名可达。 // // 这正是渗透测试 §2 的根因:auth 域全站 requireLogin=false, // 而 /api/、/admin/ 被原样反代到 3005,外层没有任何认证。 func TestAuthKindWithAdminGuardProtectsAPI(t *testing.T) { out, err := RenderGatewayConf(&GatewayConfig{ ListenPort: 8081, AuthURL: "http://127.0.0.1:3005/_portal/verify", LoginURL: "https://login.example.com/login", Routes: []Route{ {Host: "login.example.com", Kind: "auth", AdminGuard: true}, }, }) if err != nil { t.Fatalf("渲染失败: %v", err) } // 必须声明 auth_request 端点 if !contains(out, "location = /_auth {") { t.Fatalf("AdminGuard 应生成 /_auth 内部端点:\n%s", out) } // 取 login.example.com 的 server 块,单独断言 block := serverBlockFor(t, out, "login.example.com") // /api/ 与 /admin/ 必须不被 "auth_request off" 豁免。 // // nginx 的 auth_request 是继承式:server 级写一次,所有 location 默认继承; // 只有显式写 auth_request off 的 location 才豁免。因此「受保护」的正确断言是 // 「该 location 里没有 auth_request off」,而不是「该 location 里出现 auth_request」。 if !contains(block, "auth_request /_auth;") { t.Fatalf("AdminGuard 应在 server 级声明 auth_request /_auth;,实际:\n%s", block) } for _, p := range []string{"/api/", "/admin/"} { loc := locationBlockFor(t, block, p) if contains(loc, "auth_request off;") { t.Errorf("location %s 不得用 auth_request off 豁免认证,实际:\n%s", p, loc) } } // 登录端点必须显式豁免(否则没人能登录) for _, p := range []string{"/_portal/login", "/_portal/verify"} { loc := locationBlockFor(t, block, p) if !contains(loc, "auth_request off;") { t.Errorf("登录端点 %s 必须 auth_request off,实际:\n%s", p, loc) } } } // TestAuthKindWithoutGuardKeepsLegacyBehavior 记录默认行为, // 避免「默默改变既有部署」——只有显式开启 adminGuard 才收紧。 func TestAuthKindWithoutGuardKeepsLegacyBehavior(t *testing.T) { out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: []Route{ {Host: "login.example.com", Kind: "auth"}, }}) if err != nil { t.Fatalf("渲染失败: %v", err) } if contains(out, "auth_request /_auth;") { t.Fatalf("未开启 adminGuard 时不应生成 auth_request:\n%s", out) } } // serverBlockFor 截取指定 host 的 server 块文本。 func serverBlockFor(t *testing.T, conf, host string) string { t.Helper() idx := strings.Index(conf, "server_name "+host+";") if idx < 0 { t.Fatalf("找不到 server_name %s:\n%s", host, conf) } // 往前回到 "server {" start := strings.LastIndex(conf[:idx], "server {") if start < 0 { start = idx } return conf[start:] } // locationBlockFor 截取某个 location 头之后的片段(到下一个 location 或块尾)。 func locationBlockFor(t *testing.T, block, path string) string { t.Helper() markers := []string{ "location " + path + " {", "location ^~ " + path + " {", "location = " + path + " {", } for _, m := range markers { if i := strings.Index(block, m); i >= 0 { rest := block[i:] // 截到下一个 location 或 server 结束 end := len(rest) for _, stop := range []string{"\n location ", "\n}"} { if j := strings.Index(rest[len(m):], stop); j >= 0 { if cand := len(m) + j; cand < end { end = cand } } } return rest[:end] } } t.Fatalf("找不到 location %s,块内容:\n%s", path, block) return "" }