From 857ec074aa35fd06a247487ff1506e57b167db96 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 23:08:32 +0800 Subject: [PATCH] =?UTF-8?q?fix(history):=20=E9=89=B4=E6=9D=83=E6=94=B9?= =?UTF-8?q?=E4=B8=BA=20fail-closed=EF=BC=8C=E6=9C=AA=E9=85=8D=E7=BD=AE=20t?= =?UTF-8?q?oken=20=E6=97=B6=E6=8B=92=E7=BB=9D=E4=B8=80=E5=88=87=E8=AF=B7?= =?UTF-8?q?=E6=B1=82?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计发现的方式与 admin-server 同源:requireToken 在 expected 为空时 直接 return true,等于「没配 NAVI_SYNC_TOKEN / NAVI_ADMIN_TOKEN 就没有 任何鉴权」。nginx 的 location /api/history/ 没有 auth_request,线上因此 可匿名读取全量浏览历史、DELETE 清空、逐条删除。 改法与 admin-server 的 requireAdminToken 保持同一套语义(不另起风格): - 配了 token → 必须带对的 Bearer token - 未配 token → 401 + JSON {"error":...},并在日志打出 「未配置,已按 fail-closed 拒绝请求」的可诊断信息(只报事实,不打印 token 值, 且按 key 去重避免刷日志) - 只有显式 NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1(1/true/yes/on)才放行, 专用于本机调试,并会打警告日志 顺带收掉同一文件里等价的鉴权短路:新增 requireAdminToken 统一管理类入口 (DELETE / 清空、/item 逐条删除、/rebuild 重建)。它不再把「adminToken 为空」 当成免校验的短路:adminToken 优先,未配置但配了 syncToken 时接受 syncToken (兼容旧部署,插件本来就用它写入),两者都没有则回到 requireToken 的 fail-closed 分支。 测试(history-server/auth_test.go)覆盖: - 未配置 token → 401 且不写入任何数据,诊断日志含「未配置/拒绝」且不含 token 值 - 配置了 token:无头/错误 token/缺 Bearer 前缀 → 401;正确 token → 放行 - requireAdminToken 三种配置组合的判定顺序 - 显式放开开关可用但有警告 - 源码静态护栏:不得残留「未配置 token 则不校验」 反向验证过:把 requireToken 改回 fail-open 后,上述用例全部 FAIL。 --- history-server/auth_test.go | 295 ++++++++++++++++++++++++++++++++++++ history-server/main.go | 93 ++++++++++-- 2 files changed, 379 insertions(+), 9 deletions(-) create mode 100644 history-server/auth_test.go diff --git a/history-server/auth_test.go b/history-server/auth_test.go new file mode 100644 index 0000000..0f28d9b --- /dev/null +++ b/history-server/auth_test.go @@ -0,0 +1,295 @@ +package main + +import ( + "bytes" + "encoding/json" + "log" + "net/http" + "net/http/httptest" + "os" + "strings" + "testing" +) + +// 本文件是 history-server 鉴权「默认拒绝(fail-closed)」的回归护栏。 +// +// 被审计出的问题:requireToken 在 expected 为空时直接 return true, +// 于是「没配 NAVI_SYNC_TOKEN / NAVI_ADMIN_TOKEN」= 历史接口对公网完全开放 +// (nginx 的 location /api/history/ 没有 auth_request,任何人都能读全量浏览历史、 +// 甚至 DELETE 清空)。 +// +// 这里钉死三件事: +// 1. 未配置 token → 拒绝(401),且不留任何放行旁路; +// 2. 配置了 token → 错误 token 拒绝; +// 3. 配置了 token → 正确 token 放行。 +// +// 语义与 admin-server 的 requireAdminToken 保持一致(401 + JSON error + 诊断日志, +// 日志只报「未配置」不报 token 值)。 + +// withAuth 临时设置鉴权相关全局变量,测试结束自动还原。 +func withAuth(t *testing.T, sync, admin string, allowUnauth bool) { + t.Helper() + oldSync, oldAdmin, oldAllow, oldWarned := syncToken, adminToken, allowUnauthenticated, loggedOnce + syncToken, adminToken, allowUnauthenticated = sync, admin, allowUnauth + loggedOnce = map[string]bool{} + t.Cleanup(func() { + syncToken, adminToken, allowUnauthenticated, loggedOnce = oldSync, oldAdmin, oldAllow, oldWarned + }) +} + +// captureLog 抓取 log 包输出,用于断言「诊断信息存在且不含 token 值」。 +func captureLog(t *testing.T) *bytes.Buffer { + t.Helper() + var buf bytes.Buffer + oldOut, oldFlags, oldPrefix := log.Writer(), log.Flags(), log.Prefix() + log.SetOutput(&buf) + log.SetFlags(0) + log.SetPrefix("") + t.Cleanup(func() { + log.SetOutput(oldOut) + log.SetFlags(oldFlags) + log.SetPrefix(oldPrefix) + }) + return &buf +} + +func bearer(token string) *http.Request { + req := httptest.NewRequest(http.MethodGet, "/", nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + return req +} + +// 用例 1:未配置任何 token → 拒绝,且日志里能看到「未配置 + 拒绝」的诊断信息。 +func TestRequireTokenFailsClosedWhenUnconfigured(t *testing.T) { + withAuth(t, "", "", false) + logs := captureLog(t) + + rec := httptest.NewRecorder() + if requireToken(rec, bearer(""), "") { + t.Fatal("未配置 token 时 requireToken 必须返回 false(fail-closed)," + + "否则历史接口会对公网裸奔") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("拒绝时应返回 401(与 admin-server 对齐),实际 %d", rec.Code) + } + + // 错误 token 也不能绕过「未配置」这条路径 + rec = httptest.NewRecorder() + if requireToken(rec, bearer("anything"), "") { + t.Fatal("未配置 token 时,任何 Authorization 头都不应被当作有效凭据") + } + + out := logs.String() + if !strings.Contains(out, "未配置") || !strings.Contains(out, "拒绝") { + t.Fatalf("应打印可诊断的「未配置 token,已拒绝」日志,实际: %q", out) + } + if strings.Contains(out, "anything") { + t.Fatalf("日志不得包含客户端提交的 token 值,实际: %q", out) + } + // 诊断日志按 key 去重(避免刷满容器日志) + if n := strings.Count(out, "拒绝请求"); n != 1 { + t.Fatalf("「未配置」诊断日志应只打一次,实际出现 %d 次: %q", n, out) + } +} + +// 用例 1b:日志里不得出现真实 token 值(用「未配置」以外的路径也验证一遍)。 +func TestDiagnosticLogNeverLeaksToken(t *testing.T) { + const secret = "sync-secret-0123456789abcdef" + withAuth(t, secret, "", false) + logs := captureLog(t) + + rec := httptest.NewRecorder() + if requireToken(rec, bearer("wrong"), secret) { + t.Fatal("错误 token 必须被拒绝") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("应返回 401,实际 %d", rec.Code) + } + out := logs.String() + if strings.Contains(out, secret) { + t.Fatalf("日志泄露了 token 值: %q", out) + } + var body map[string]string + if err := json.Unmarshal(rec.Body.Bytes(), &body); err != nil { + t.Fatalf("拒绝时的响应必须是 JSON(便于客户端识别): %v (%s)", err, rec.Body.String()) + } + if body["error"] == "" { + t.Fatalf("拒绝响应应带 error 字段,实际: %s", rec.Body.String()) + } +} + +// 用例 2:配了 token,错误 token / 不带头 → 拒绝;正确 token → 放行。 +func TestRequireTokenRejectsWrongAcceptsRight(t *testing.T) { + const secret = "expected-secret-value" + withAuth(t, secret, "", false) + + cases := []struct { + name string + header string + want bool + }{ + {"无 Authorization 头", "", false}, + {"错误 token", "Bearer wrong", false}, + {"前缀不完整(raw token)", secret, false}, + {"正确 token", "Bearer " + secret, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/", 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) + } + }) + } +} + +// 用例 3:handler 级别 —— /sync 未配置 token 时不得写入任何数据。 +func TestHandleSyncRejectsWhenTokenUnconfigured(t *testing.T) { + withTempHistory(t) + withAuth(t, "", "", false) + _ = captureLog(t) + + body, _ := json.Marshal(SyncRequest{Items: []SyncItem{{ + URL: "https://ex.com/leak", Title: "Leak", Device: "laptop", + Timestamp: 1700000000, VisitCount: 1, + }}}) + req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body)) + rec := httptest.NewRecorder() + handleSync(rec, req) + + if rec.Code != http.StatusUnauthorized { + t.Fatalf("未配置 NAVI_SYNC_TOKEN 时 /sync 应 401,实际 %d", rec.Code) + } + if len(history) != 0 { + t.Fatalf("被拒绝的请求不得写入历史,实际写入 %d 条", len(history)) + } +} + +// 用例 3b:配了 token 后 /sync:错 token 拒绝,对 token 放行。 +func TestHandleSyncAcceptsOnlyCorrectToken(t *testing.T) { + withTempHistory(t) + withAuth(t, "sync-secret", "", false) + _ = captureLog(t) + + item := SyncItem{URL: "https://ex.com/ok", Title: "OK", Device: "laptop", + Timestamp: 1700000000, VisitCount: 1} + body, _ := json.Marshal(SyncRequest{Items: []SyncItem{item}}) + + // 错 token + req := httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer wrong") + rec := httptest.NewRecorder() + handleSync(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("错误 token 应 401,实际 %d", rec.Code) + } + if len(history) != 0 { + t.Fatalf("错误 token 不得写入数据,实际 %d 条", len(history)) + } + + // 对 token + req = httptest.NewRequest(http.MethodPost, "/sync", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer sync-secret") + rec = httptest.NewRecorder() + handleSync(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("正确 token 应放行(200),实际 %d: %s", rec.Code, rec.Body.String()) + } + if history["https://ex.com/ok"] == nil { + t.Fatal("正确 token 的同步应写入历史") + } +} + +// 用例 4:requireAdminToken —— 管理类接口(清空/逐条删除/重建)同样 fail-closed。 +func TestRequireAdminTokenFailsClosed(t *testing.T) { + // 什么都没配 + withAuth(t, "", "", false) + _ = captureLog(t) + rec := httptest.NewRecorder() + if requireAdminToken(rec, bearer("")) { + t.Fatal("未配置任何 token 时管理接口必须拒绝") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("应返回 401,实际 %d", rec.Code) + } + + // 只配了 syncToken:接受 syncToken(旧部署兼容),仍拒绝空/错 token + withAuth(t, "sync-secret", "", false) + _ = captureLog(t) + rec = httptest.NewRecorder() + if !requireAdminToken(rec, bearer("sync-secret")) { + t.Fatal("只配了 NAVI_SYNC_TOKEN 时,管理接口应接受 syncToken") + } + rec = httptest.NewRecorder() + if requireAdminToken(rec, bearer("wrong")) { + t.Fatal("错误 token 不得通过管理接口") + } + + // 配了 adminToken:必须以 adminToken 为准 + withAuth(t, "sync-secret", "admin-secret", false) + _ = captureLog(t) + rec = httptest.NewRecorder() + if !requireAdminToken(rec, bearer("admin-secret")) { + t.Fatal("配了 adminToken 时应接受 adminToken") + } + rec = httptest.NewRecorder() + if requireAdminToken(rec, bearer("sync-secret")) { + t.Fatal("配了 adminToken 后,syncToken 不应再被当作管理凭据") + } +} + +// 用例 5:显式放开开关仍然可用(本机调试路径),但诊断日志必须警告。 +func TestAllowUnauthenticatedOptIn(t *testing.T) { + withAuth(t, "", "", true) + logs := captureLog(t) + + rec := httptest.NewRecorder() + if !requireToken(rec, bearer(""), "") { + t.Fatal("显式设 NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1 时应放行(仅限本机调试)") + } + if out := logs.String(); !strings.Contains(out, "放行") || !strings.Contains(out, "绝不要用在线上") { + t.Fatalf("放开鉴权时必须打印警告日志,实际: %q", out) + } +} + +// 用例 6:清空/删除历史的管理入口在未配置 token 时被拒绝(数据不被删)。 +func TestClearHistoryRequiresToken(t *testing.T) { + withTempHistory(t) + withAuth(t, "", "", false) + _ = captureLog(t) + history["https://ex.com/keep"] = &Entry{URL: "https://ex.com/keep", TotalVisits: 3} + + // 模拟 main() 里 "/" DELETE 分支的守卫 + rec := httptest.NewRecorder() + if requireAdminToken(rec, httptest.NewRequest(http.MethodDelete, "/", nil)) { + t.Fatal("未配置 token 时 DELETE / 必须被拒绝") + } + if rec.Code != http.StatusUnauthorized { + t.Fatalf("应 401,实际 %d", rec.Code) + } + if len(history) != 1 { + t.Fatalf("被拒绝的删除请求不得清除数据,实际剩 %d 条", len(history)) + } +} + +// 用例 7:确认没有残留「expected 为空就放行」的短路写法。 +// 这是对源码的静态护栏:删掉它会漏掉第 3 步要求的「顺手收掉等价短路」。 +func TestSourceHasNoFailOpenShortCircuit(t *testing.T) { + src, err := os.ReadFile("main.go") + if err != nil { + t.Fatalf("读取 main.go 失败: %v", err) + } + if strings.Contains(string(src), "未配置 token 则不校验") { + t.Fatal("main.go 里仍残留「未配置 token 则不校验」的放行短路,必须收掉") + } +} diff --git a/history-server/main.go b/history-server/main.go index e81c895..d25ceab 100644 --- a/history-server/main.go +++ b/history-server/main.go @@ -20,8 +20,18 @@ var ( historyFile = filepath.Join(dataDir, "history.json") syncToken = os.Getenv("NAVI_SYNC_TOKEN") adminToken = os.Getenv("NAVI_ADMIN_TOKEN") - mu sync.RWMutex - history = map[string]*Entry{} + // allowUnauthenticated:既没有 admin token、也没有显式要求时是否放行。 + // 默认 false(拒绝);只有明确设置 NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1 才放行。 + // 语义对齐 admin-server 的 NAVI_ADMIN_ALLOW_UNAUTHENTICATED(见 admin-server/main.go + // requireAdminToken)。变量名带 HISTORY 前缀是刻意的:要放行必须明确知道自己在放开 + // 哪个服务,而不是顺手把 admin 的开关复制过来连带放开历史数据。 + allowUnauthenticated = isTruthy(os.Getenv("NAVI_HISTORY_ALLOW_UNAUTHENTICATED")) + // loggedOnce:控制诊断日志只打一次,避免把容器日志刷满。用独立锁, + // 不与 history 的数据锁耦合(logOnce 可能在 handler 持锁前后被调用)。 + loggedOnceMu sync.Mutex + loggedOnce = map[string]bool{} + mu sync.RWMutex + history = map[string]*Entry{} ) type Entry struct { @@ -91,20 +101,85 @@ func cors(next http.HandlerFunc) http.HandlerFunc { } } +// isTruthy 与 admin-server 的同名函数保持一致(1/true/yes/on 视为真)。 +func isTruthy(v string) bool { + switch strings.ToLower(strings.TrimSpace(v)) { + case "1", "true", "yes", "on": + return true + } + return false +} + +// requireToken 校验 Bearer token(fail-closed)。 +// +// 历史背景:早期实现在 expected 为空时直接 return true,等于「没配 NAVI_SYNC_TOKEN / +// NAVI_ADMIN_TOKEN 就没有任何鉴权」。2026-10-22 的渗透测试已证明历史接口确实被匿名读取/ +// 删除过(见 admin-server/security_test.go 里的同类修法),这里改成同样的「默认拒绝」: +// - 配了 token → 必须带对的 Bearer token +// - 没配 token 且显式设 NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1 → 放行(仅限本机调试) +// - 其它 → 拒绝 +// +// 注意:expected 为空时**绝不**放行;只接受跨模块传进来的 admin token 作为等价凭据 +// (见 requireAdminToken)。token 值不写日志,只报「未配置」这一事实。 func requireToken(w http.ResponseWriter, r *http.Request, expected string) bool { if expected == "" { - return true // 未配置 token 则不校验 + if allowUnauthenticated { + logOnce("allow-unauth", "WARN: history token 未配置,且已设 NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1,"+ + "鉴权被放行(仅限本机调试,绝不要用在线上)") + return true + } + logOnce("no-token", "WARN: history token 未配置,已按 fail-closed 拒绝请求(配 NAVI_SYNC_TOKEN / "+ + "NAVI_ADMIN_TOKEN,或本地调试设 NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1)") + jsonErr(w, http.StatusUnauthorized, + "history API is locked: set NAVI_SYNC_TOKEN / NAVI_ADMIN_TOKEN "+ + "(and send Authorization: Bearer ), "+ + "or set NAVI_HISTORY_ALLOW_UNAUTHENTICATED=1 for local-only use") + return false } auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected { return true } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusUnauthorized) - json.NewEncoder(w).Encode(map[string]string{"error": "invalid or missing token"}) + jsonErr(w, http.StatusUnauthorized, "invalid or missing token") return false } +// logOnce 每个 key 只打一次诊断日志,避免把容器日志刷满;不打印任何 token 值。 +func logOnce(key, msg string) { + loggedOnceMu.Lock() + if loggedOnce[key] { + loggedOnceMu.Unlock() + return + } + loggedOnce[key] = true + loggedOnceMu.Unlock() + log.Print(msg) +} + +// requireAdminToken 对需要管理权限的接口(清空/逐条删除/重建)做鉴权。 +// +// 判定顺序与 admin-server 的 requireAdminToken 一致: +// 1. 配了 adminToken → 必须带对的 Bearer token +// 2. 没配 adminToken 但配了 syncToken → 接受 syncToken +// (兼容旧部署:历史记录本来就是插件用 syncToken 写进去的) +// 3. 两者都没配但显式允许匿名 → 放行(仅本机调试) +// 4. 其它 → 拒绝(fail-closed) +func requireAdminToken(w http.ResponseWriter, r *http.Request) bool { + if adminToken != "" { + return requireToken(w, r, adminToken) + } + if syncToken != "" { + return requireToken(w, r, syncToken) + } + return requireToken(w, r, "") +} + +func jsonErr(w http.ResponseWriter, status int, msg string) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(map[string]string{"error": msg}) +} + func handleSync(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", 405) @@ -669,7 +744,7 @@ func main() { } handleGet(w, r) case http.MethodDelete: - if !requireToken(w, r, adminToken) { + if !requireAdminToken(w, r) { return } handleClear(w, r) @@ -685,7 +760,7 @@ func main() { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) return } - if !requireToken(w, r, adminToken) { + if !requireAdminToken(w, r) { return } rebuildDashySections() @@ -698,7 +773,7 @@ func main() { http.Error(w, "method not allowed", 405) return } - if !requireToken(w, r, adminToken) { + if !requireAdminToken(w, r) { return } handleDeleteItem(w, r)