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。
This commit is contained in:
JianFeeeee
2026-10-22 23:27:51 +08:00
parent aae3fb7065
commit cd108f8093
2 changed files with 211 additions and 1 deletions

View File

@ -313,9 +313,24 @@ func requireAdminToken(w http.ResponseWriter, r *http.Request) bool {
return false
}
// requireToken 校验 Bearer token(fail-closed)。
//
// 历史背景:早期实现在 expected 为空时直接 return true,等于「没配 token 就没有任何
// 鉴权」。今天它打不到(唯一调用点 requireAdminToken 有 adminToken != "" 守卫),
// 但那个空分支就是审计第 1 条点名的形状:任何人新写一行
// requireToken(w, r, someOptionalToken) 就会把 fail-open 复活。
//
// 现在与 history-server 的 requireToken 完全对齐:
// - 配了 token → 必须带对的 Bearer token
// - expected 为空 → **一律拒绝**(401 + JSON),绝不 return true
// 放行「没有 token 也允许访问」的决策只允许出现在明确语义的函数里
// (requireAdminToken 的 allowUnauthenticated 分支),不允许藏在通用校验函数里。
func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool {
if expected == "" {
return true // 未配置 token 则不校验
jsonErr(w, http.StatusUnauthorized,
"API is locked: this endpoint requires a configured token "+
"(set NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN and send Authorization: Bearer <token>)")
return false
}
auth := r.Header.Get("Authorization")
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected {

View File

@ -0,0 +1,195 @@
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)
}
}