Files
HomeDash/admin-server/token_failclosed_test.go
JianFeeeee cd108f8093 fix(admin): requireToken 去掉 fail-open 死分支,空 expected 一律拒绝
审计第 1 条的「另一半」。第 1 步把 history-server 的 requireToken 改成了
fail-closed,但 admin-server 的同类函数还留着原来的形状:

    if expected == "" {
        return true // 未配置 token 则不校验
    }

它今天打不到(唯一调用点 requireAdminToken 开头有 adminToken != "" 守卫),
但形状本身就是风险:任何人新写一行 requireToken(w, r, someOptionalToken)
就会把 fail-open 复活,且没有任何测试会报红 —— 第 1 步的静态护栏只钉了
history-server 一半仓库。

现在 admin-server 的 requireToken 与 history-server 完全对齐:
- expected 为空 → 401 + JSON {"error": "...set NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN..."},
  绝不 return true;拒绝信息带修复指引(全站 401 时运维最需要的就是这句),
  且不回显任何 token 值。
- 「没有 token 也允许访问」的决策只允许出现在明确语义的函数里
  (requireAdminToken 的 allowUnauthenticated 分支),不再藏在通用校验函数里。

requireAdminToken 的三段判定顺序一行未动:
① 配了 adminToken → requireToken;② 未配 + allowUnauthenticated → 放行;
③ 其它 → 拒绝。security_test.go 的既有用例(TestAdminAPIFailsClosedByDefault、
TestAdminAPIRejectsWithoutTokenEvenIfFlagUnset 等)全部继续绿。

新增 admin-server/token_failclosed_test.go:
1. 直接调 requireToken(rec, req, "") 的四种请求形态 → 全部 false + 401 +
   JSON + 含修复指引 + 不回显 token;
2. 配了 token 时正常路径不变(无头/错 token/缺 Bearer 前缀/对 token),
   外加逐条断言 requireAdminToken 的三段顺序(含「配了 token 时不因
   allowUnauthenticated 而放行错误 token」这条最容易改坏的);
3. 静态护栏:admin-server 的 main.go / gateway.go / security_test.go 都不得再出现
   「未配置 token 则不校验」或 `expected == "" { return true`,并正向断言
   requireToken 函数体里有 StatusUnauthorized。

反向验证:把空 expected 分支改回 `return true` 后,
TestRequireTokenRejectsEmptyExpected(4 个子用例全红)与
TestNoFailOpenShortCircuitAcrossServers 报红;恢复后 admin-server 全集 ok。
2026-10-22 23:27:51 +08:00

196 lines
7.4 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package main
import (
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
)
// 本文件把审计第 1 条的「另一半」钉死:admin-server 自己的 requireToken
// 也不能留着「expected 为空就放行」的死分支。
//
// 现状是那个分支今天打不到(唯一调用点 requireAdminToken 有 adminToken != "" 守卫),
// 但形状本身就是风险:任何人新写一行 requireToken(w, r, someOptionalToken)
// 就会把 fail-open 复活,而这里不会有任何测试报红。
//
// 语义与 history-server 的 requireToken 对齐:空 expected 一律拒绝(401 + JSON)。
// 用例 1(反向验证的咬点):直接调 requireToken(rec, req, "") → 必须 false 且 401。
func TestRequireTokenRejectsEmptyExpected(t *testing.T) {
cases := []struct {
name string
header string
}{
{"无 Authorization 头", ""},
{"带任意 Bearer", "Bearer anything"},
{"带空 Bearer", "Bearer "},
{"带 raw token(无 Bearer 前缀)", "anything"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodPost, "/api/gateway/routes", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
if requireToken(rec, req, "") {
t.Fatal("expected 为空时 requireToken 必须返回 false(fail-closed);" +
"返回 true 意味着任何「可选 token」调用点都会变成免鉴权后门")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("拒绝时应返回 401,实际 %d", rec.Code)
}
// 拒绝响应必须是 JSON(客户端/部署脚本靠它区分「打到错误服务」)
if ct := rec.Header().Get("Content-Type"); !strings.Contains(ct, "application/json") {
t.Fatalf("拒绝响应的 Content-Type 应为 application/json,实际 %q(body=%s)",
ct, rec.Body.String())
}
body := rec.Body.String()
if !strings.Contains(body, `"error"`) {
t.Fatalf("拒绝响应应带 error 字段,实际: %s", body)
}
// 修复指引:告诉运维该配哪个变量(这正是「全站 401」时最需要的信息)
if !strings.Contains(body, "NAVI_ADMIN_TOKEN") && !strings.Contains(body, "NAVI_SYNC_TOKEN") {
t.Fatalf("拒绝响应应给出修复指引(提示配置 NAVI_ADMIN_TOKEN),实际: %s", body)
}
// 不得把 expected 的值(哪怕是空)或任何令牌内容回显
if tc.header != "" && strings.Contains(body, strings.TrimPrefix(tc.header, "Bearer ")) && tc.header != "Bearer " {
t.Fatalf("拒绝响应不得回显客户端提交的 token 值,实际: %s", body)
}
})
}
}
// 用例 2:配了 adminToken 时正常路径不变 —— 对 token 放行、错 token 拒绝。
// (改 fail-closed 不允许把这条路径改坏;security_test.go 里的同名意图用例也仍在跑。)
func TestRequireTokenNormalPathUnchanged(t *testing.T) {
const secret = "admin-secret-value"
oldAdmin := adminToken
adminToken = secret
t.Cleanup(func() { adminToken = oldAdmin })
cases := []struct {
name string
header string
want bool
}{
{"无头", "", false},
{"错误 token", "Bearer wrong", false},
{"缺 Bearer 前缀", secret, false},
{"正确 token", "Bearer " + secret, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/history", nil)
if tc.header != "" {
req.Header.Set("Authorization", tc.header)
}
rec := httptest.NewRecorder()
got := requireToken(rec, req, secret)
if got != tc.want {
t.Fatalf("requireToken = %v; want %v (status %d)", got, tc.want, rec.Code)
}
if !tc.want && rec.Code != http.StatusUnauthorized {
t.Fatalf("拒绝时应 401,实际 %d", rec.Code)
}
if !tc.want && strings.Contains(rec.Body.String(), secret) {
t.Fatalf("拒绝响应不得回显 token 值: %s", rec.Body.String())
}
})
}
// requireAdminToken 的三段判定顺序一行都不能变:
// ① 配了 adminToken → 走 requireToken(不管 allowUnauthenticated)
// ② 没配 + allowUnauthenticated → 放行
// ③ 其它 → 拒绝
t.Run("顺序①:配了 token 时不因 allowUnauthenticated 而放行错误 token", func(t *testing.T) {
oldAllow := allowUnauthenticated
allowUnauthenticated = true
t.Cleanup(func() { allowUnauthenticated = oldAllow })
rec := httptest.NewRecorder()
if requireAdminToken(rec, httptest.NewRequest(http.MethodGet, "/api/history", nil)) {
t.Fatal("配了 adminToken 时必须校验:不能因为 allowUnauthenticated 就放行无 token 请求")
}
if rec.Code != http.StatusUnauthorized {
t.Fatalf("应 401,实际 %d", rec.Code)
}
})
t.Run("顺序②:无 token + allowUnauthenticated → 放行", func(t *testing.T) {
oldAdmin2, oldAllow2 := adminToken, allowUnauthenticated
adminToken, allowUnauthenticated = "", true
t.Cleanup(func() { adminToken, allowUnauthenticated = oldAdmin2, oldAllow2 })
rec := httptest.NewRecorder()
if !requireAdminToken(rec, httptest.NewRequest(http.MethodGet, "/api/history", nil)) {
t.Fatalf("显式允许匿名时应放行,实际 %d: %s", rec.Code, rec.Body.String())
}
})
t.Run("顺序③:无 token + 不允许匿名 → 拒绝且带修复指引", func(t *testing.T) {
oldAdmin3, oldAllow3 := adminToken, allowUnauthenticated
adminToken, allowUnauthenticated = "", false
t.Cleanup(func() { adminToken, allowUnauthenticated = oldAdmin3, oldAllow3 })
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)
}
if !strings.Contains(rec.Body.String(), "NAVI_ADMIN_TOKEN") {
t.Fatalf("拒绝信息应提示配置 NAVI_ADMIN_TOKEN,实际: %s", rec.Body.String())
}
})
}
// 用例 3:静态护栏 —— 两个模块的源码都不得再出现「未配置 token 则不校验」这一类
// fail-open 注释/写法。第 1 步只在 history-server 里钉了这条,等于护栏只钉了一半仓库。
func TestNoFailOpenShortCircuitAcrossServers(t *testing.T) {
// 被禁止的字串:注释形态与代码形态都算
forbidden := []string{
"未配置 token 则不校验",
"expected == \"\" {\n\t\treturn true",
}
targets := []string{
"main.go", // admin-server 自己
"gateway.go", // 同模块里也可能有人写
"security_test.go", // 防止有人把护栏改成「允许放行」
}
for _, f := range targets {
src, err := os.ReadFile(f)
if err != nil {
t.Fatalf("读取 %s 失败: %v", f, err)
}
for _, bad := range forbidden {
if strings.Contains(string(src), bad) {
t.Fatalf("%s 里仍有 fail-open 形态 %q:空 token 必须拒绝,不能 return true", f, bad)
}
}
}
// 正例:requireToken 的空 expected 分支里必须有拒绝(401),而不是放行。
src, err := os.ReadFile("main.go")
if err != nil {
t.Fatal(err)
}
i := strings.Index(string(src), "func requireToken(")
if i < 0 {
t.Fatal("main.go 里找不到 requireToken")
}
fn := string(src)[i:]
if j := strings.Index(fn, "\n}"); j > 0 {
fn = fn[:j]
}
if !strings.Contains(fn, "StatusUnauthorized") {
t.Fatalf("requireToken 必须在拒绝路径上写 401,实际函数体:\n%s", fn)
}
if strings.Contains(fn, "return true // 未配置") {
t.Fatalf("requireToken 里仍有「未配置就放行」的短路:\n%s", fn)
}
}