From 857ec074aa35fd06a247487ff1506e57b167db96 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 23:08:32 +0800 Subject: [PATCH 1/5] =?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) From 5562d584d378d9841cd686dcd48961e94617c5b0 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 23:14:32 +0800 Subject: [PATCH 2/5] =?UTF-8?q?fix(gateway):=20https=20=E4=B8=8A=E6=B8=B8?= =?UTF-8?q?=E4=B8=8D=E5=86=8D=E6=97=A0=E6=9D=A1=E4=BB=B6=E5=85=B3=E9=97=AD?= =?UTF-8?q?=E8=AF=81=E4=B9=A6=E6=A0=A1=E9=AA=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计第 2 条:writeProxyLocation(主 location / bypass 段)与 writeRouteServer 的 favicon 段原本都是 if r.InsecureTLS || strings.HasPrefix(strings.ToLower(upstream), "https://") { proxy_ssl_verify off; } 即「只要上游是 https 就关掉上游证书校验」,对所有 https 上游一律不验签 —— 中间人可冒充任意 https 后端,用户提交的凭据与 cookie 全部可截。 现在条件收敛为 `r.InsecureTLS && isHTTPSUpstream(upstream)`: 「上游是 https」不再是关闭校验的理由,只有**显式勾选 insecureTls** 的路由 才会输出 proxy_ssl_verify off。两处(writeProxyLocation 用 r.Upstream、 favicon 段用局部变量 up)都改到了,并抽出 isHTTPSUpstream 消除重复判断。 ⚠️ 运维需知的行为变化(有意为之,不要偷偷改回去): - 普通 https 上游现在走 nginx 默认:**校验上游证书**,使用系统 CA 库。 公共 CA(Let's Encrypt 等)签发的上游不受影响,无需任何配置。 - 仓库内没有 proxy_ssl_trusted_certificate 的配置,因此**自签证书的上游现在会 502**。 这是预期的 fail-closed 结果:要接自签上游,请在该路由上显式勾选 insecureTls (等价于确认「我知道这条链路不验签」)。 - http 上游行为不变(本来就不会有 TLS 握手);本次也顺带保证不会给它写这行噪声配置。 测试(admin-server/gateway_tls_verify_test.go,沿用 gateway_test.go 的 RenderGatewayConf + section 取值风格): 1. https 上游 + InsecureTLS=false → 主 location 不含 proxy_ssl_verify off; 2. https 上游 + InsecureTLS=true → 含; 3. http 上游(两种情况)→ 都不含; 4. favicon 段(location = /favicon.ico 与 /favicon.svg)逐条断言 1/2 两种行为, 防止只改主 location 漏掉 favicon 段; 5. 计数断言:整份配置里 proxy_ssl_verify off 的出现次数 == 勾了 insecureTls 的 https 路由贡献的 location 数(主/bypass + 2 个 favicon),兜住别处同类短路; 6. 源码护栏:不得再出现两种旧写法,且注释必须保留「自签默认 502 是预期」的说明。 反向验证:把两处判断改回 `|| https://` 后,1/3/4/5/6 全部报红(详见 `go test -run 'TestHTTPSUpstream|TestHTTPUpstream|TestFaviconPassthrough|TestSSLVerifyOff|TestInsecureTLSBehaviour'`), 计数用例给出 13 次 vs 期望 7 次;恢复后全绿。 --- admin-server/gateway.go | 18 +- admin-server/gateway_tls_verify_test.go | 208 ++++++++++++++++++++++++ 2 files changed, 224 insertions(+), 2 deletions(-) create mode 100644 admin-server/gateway_tls_verify_test.go diff --git a/admin-server/gateway.go b/admin-server/gateway.go index 2ddc355..97cf55f 100644 --- a/admin-server/gateway.go +++ b/admin-server/gateway.go @@ -231,6 +231,10 @@ func writeErrorPages(b *strings.Builder, include404, portalFavicon bool) { } } +func isHTTPSUpstream(upstream string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(upstream)), "https://") +} + func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool) { if path == "" { b.WriteString(" location / {\n") @@ -256,7 +260,17 @@ func writeProxyLocation(b *strings.Builder, r Route, path string, authOff bool) b.WriteString(" gzip off;\n chunked_transfer_encoding on;\n") } b.WriteString(" proxy_read_timeout 86400s;\n proxy_send_timeout 86400s;\n") - if r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://") { + // 只在**显式勾选** insecureTls 的 https 上游上关闭证书校验。 + // + // 审计第 2 条:这里原本是 `r.InsecureTLS || 上游是 https://`,等于所有 https 上游 + // 都不校验证书 —— 中间人可冒充任意 https 后端,用户输入与 cookie 全部可截。 + // 现在普通 https 上游走 nginx 默认(校验上游证书、走系统 CA);自签上游会 502, + // 这是**预期行为**,要自签就勾 insecureTls。别改回去。 + // + // 另外仍然带上「必须是 https 上游」这半个条件(而不是完全去掉):http 上游上没有 + // TLS 握手,这行是纯噪声且容易让人误以为已关掉什么安全机制;结果与旧行为一致 + // (http 上游从来不需要它),只是不再把「是 https」当成关闭校验的理由。 + if r.InsecureTLS && isHTTPSUpstream(r.Upstream) { b.WriteString(" proxy_ssl_verify off;\n") } if r.InjectBeian { @@ -359,7 +373,7 @@ func writeRouteServer(b *strings.Builder, cfg *GatewayConfig, r Route) error { b.WriteString(" proxy_set_header X-Real-IP $remote_addr;\n") b.WriteString(" proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;\n") b.WriteString(" proxy_set_header X-Forwarded-Proto $scheme;\n") - if r.InsecureTLS || strings.HasPrefix(strings.ToLower(up), "https://") { + if r.InsecureTLS && isHTTPSUpstream(up) { b.WriteString(" proxy_ssl_verify off;\n") } b.WriteString(" proxy_redirect off;\n }\n") diff --git a/admin-server/gateway_tls_verify_test.go b/admin-server/gateway_tls_verify_test.go new file mode 100644 index 0000000..ac83dc8 --- /dev/null +++ b/admin-server/gateway_tls_verify_test.go @@ -0,0 +1,208 @@ +package main + +import ( + "os" + "strings" + "testing" +) + +// 本文件把审计第 2 条钉死:**只有显式勾选 insecureTls 的上游**才输出 +// `proxy_ssl_verify off;`。 +// +// 原缺陷写法是 `if r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://")`, +// 等于对**所有** https 上游无条件关闭上游证书校验 —— 中间人可冒充任意 https 后端, +// 用户提交的凭据与 cookie 全部可被截获。 +// +// 行为变化(有意为之,别改回去):普通 https 上游现在走 nginx 默认,即校验上游证书 +// 并使用系统 CA 库。公共 CA(Let's Encrypt 等)签发的上游不受影响; +// **自签证书的上游会 502**,要接自签就必须在路由上勾 insecureTls。 + +const sslVerifyOff = "proxy_ssl_verify off;" + +// countOccurrences 数某个子串在整份配置里出现多少次。 +func countOccurrences(hay, needle string) int { + return strings.Count(hay, needle) +} + +// proxyBlockOf 取出某个域名所属的 server 块。 +func proxyBlockOf(t *testing.T, conf, host string) string { + t.Helper() + for _, b := range strings.Split(conf, "server {") { + if strings.Contains(b, "server_name "+host+";") { + return b + } + } + t.Fatalf("找不到 server_name %s 的 server 块:\n%s", host, conf) + return "" +} + +// faviconBlocksOf 取出该 server 块里所有 `location = /favicon.*` 的片段 +// (/favicon.ico 与 /favicon.svg 两条,writeRouteServer 会各写一个)。 +func faviconBlocksOf(t *testing.T, block string) []string { + t.Helper() + var out []string + for _, line := range strings.Split(block, "\n") { + if strings.Contains(line, "location = /favicon") { + out = append(out, line) + } + } + // 同块内紧跟其后的 proxy_ssl_verify 也要带上,才能断言「属于 favicon 段」 + var res []string + for _, b := range out { + if i := indexOf(block, b); i >= 0 { + rest := block[i:] + if j := indexOf(rest, "\n }"); j >= 0 { + res = append(res, rest[:j]) + } + } + } + return res +} + +func renderTLSConf(t *testing.T, routes ...Route) string { + t.Helper() + out, err := RenderGatewayConf(&GatewayConfig{ListenPort: 8081, Routes: routes}) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + return out +} + +// 用例 1(正向):https 上游 + InsecureTLS=false → 主 location 不得有 proxy_ssl_verify off。 +func TestHTTPSUpstreamVerifiesCertByDefault(t *testing.T) { + out := renderTLSConf(t, Route{Host: "secure.example", Upstream: "https://example.com"}) + block := proxyBlockOf(t, out, "secure.example") + if strings.Contains(block, sslVerifyOff) { + t.Fatalf("普通 https 上游不得关闭证书校验(会被中间人截),"+ + "实际 server 块:\n%s", block) + } + // 必须仍然 proxy_pass 到 https 上游(不是干脆不代理了) + if !strings.Contains(block, "proxy_pass https://example.com;") { + t.Fatalf("应保留 proxy_pass 到 https 上游:\n%s", block) + } +} + +// 用例 2(反向):同上游但显式勾了 insecureTls → 必须有 proxy_ssl_verify off。 +func TestHTTPSUpstreamInsecureTLSOptIn(t *testing.T) { + out := renderTLSConf(t, Route{Host: "selfsigned.example", Upstream: "https://example.com", InsecureTLS: true}) + block := proxyBlockOf(t, out, "selfsigned.example") + if !strings.Contains(block, sslVerifyOff) { + t.Fatalf("显式勾选 insecureTls 时必须关闭校验(否则自签上游 502),"+ + "实际 server 块:\n%s", block) + } +} + +// 用例 3:http 上游 —— 两种情况都不该出现 proxy_ssl_verify off +// (对 http 上游写这行是无意义噪声,也说明判断条件又跑偏了)。 +func TestHTTPUpstreamNeverDisablesSSLVerify(t *testing.T) { + for _, insecure := range []bool{false, true} { + out := renderTLSConf(t, Route{Host: "plain.example", Upstream: "http://127.0.0.1:5667", InsecureTLS: insecure}) + block := proxyBlockOf(t, out, "plain.example") + if strings.Contains(block, sslVerifyOff) { + t.Fatalf("http 上游不该出现 proxy_ssl_verify off(insecureTls=%v),server 块:\n%s", + insecure, block) + } + } +} + +// 用例 4:favicon 段(writeRouteServer 里那条 `location = /favicon.ico`) +// 与主 location 行为一致 —— 防止只改了 writeProxyLocation 而漏掉 favicon 段。 +func TestFaviconPassthroughFollowsSameTLSRule(t *testing.T) { + // 4a:https 上游、未勾 insecureTls → favicon 段也不得关校验 + out := renderTLSConf(t, Route{Host: "secure.example", Upstream: "https://example.com"}) + block := proxyBlockOf(t, out, "secure.example") + fav := faviconBlocksOf(t, block) + if len(fav) == 0 { + t.Fatalf("代理块应有 favicon 直通 location:\n%s", block) + } + for _, f := range fav { + if strings.Contains(f, sslVerifyOff) { + t.Fatalf("普通 https 上游的 favicon 段也不得关闭证书校验:\n%s", f) + } + if !strings.Contains(f, "proxy_pass https://example.com;") { + t.Fatalf("favicon 段应直通上游:\n%s", f) + } + } + + // 4b:勾了 insecureTls → favicon 段必须也关校验(否则标签图标抓不到, + // 表现为「站点图标丢失」,实测会让人误以为只有图标坏了) + out = renderTLSConf(t, Route{Host: "selfsigned.example", Upstream: "https://example.com", InsecureTLS: true}) + block = proxyBlockOf(t, out, "selfsigned.example") + fav = faviconBlocksOf(t, block) + if len(fav) == 0 { + t.Fatalf("代理块应有 favicon 直通 location:\n%s", block) + } + for _, f := range fav { + if !strings.Contains(f, sslVerifyOff) { + t.Fatalf("勾了 insecureTls 时 favicon 段也必须关闭校验(否则图标抓不到):\n%s", f) + } + } +} + +// 用例 5:计数断言 —— 整份配置里 proxy_ssl_verify off 的出现次数 +// 必须等于「勾了 insecureTls 的 https 路由数」(每条路由贡献主 location + 2 个 favicon 段)。 +// 这条能兜住「别处还有同类短路」:只要有任何一段无条件关闭校验,计数就会偏大。 +func TestSSLVerifyOffCountMatchesInsecureRoutes(t *testing.T) { + routes := []Route{ + // 普通 https 上游:0 处 + {Host: "secure.example", Upstream: "https://example.com"}, + // 自签 https 上游:主 location + favicon.ico + favicon.svg = 3 处 + {Host: "selfsigned.example", Upstream: "https://192.0.2.10:5667", InsecureTLS: true}, + // http 上游:0 处(即使勾了 insecureTls 也不写) + {Host: "plain.example", Upstream: "http://127.0.0.1:8080"}, + {Host: "plain-insecure.example", Upstream: "http://127.0.0.1:8081", InsecureTLS: true}, + // 带 bypassPaths + websocket 的自签路由:bypass 段与主 location 各 1 处,favicon 2 处 + {Host: "guarded.example", Upstream: "https://192.0.2.20:5667", InsecureTLS: true, + RequireLogin: true, Websocket: true, BypassPaths: []string{"/search"}}, + // 门户类:无上游,0 处 + {Host: "portal.example", Kind: "portal"}, + } + // 计数用例需要认证地址/登录地址才能渲染受保护路由 + out, err := RenderGatewayConf(&GatewayConfig{ + ListenPort: 8081, + AuthURL: "http://127.0.0.1:3005/auth/verify", + LoginURL: "https://login.example/login", + Routes: routes, + }) + if err != nil { + t.Fatalf("渲染失败: %v", err) + } + + // https + insecureTls 的路由:selfsigned(1 主 + 2 favicon)、guarded(1 主 + 1 bypass + 2 favicon) + want := 3 + 4 + if got := countOccurrences(out, sslVerifyOff); got != want { + t.Fatalf("proxy_ssl_verify off 共出现 %d 次,期望 %d 次"+ + "(= 勾了 insecureTls 的 https 路由各自的主/bypass location + favicon 段);"+ + "次数偏大通常意味着又出现了「https 就无条件关校验」的短路。\n完整配置:\n%s", + got, want, out) + } + + // 逐块核对:普通 https 块的计数必须是 0 + if n := countOccurrences(proxyBlockOf(t, out, "secure.example"), sslVerifyOff); n != 0 { + t.Fatalf("secure.example 不该有任何 proxy_ssl_verify off,实际 %d 次", n) + } + if n := countOccurrences(proxyBlockOf(t, out, "guarded.example"), sslVerifyOff); n != 4 { + t.Fatalf("guarded.example(主 + bypass + 2 个 favicon)应恰好 4 次,实际 %d 次", n) + } +} + +// 用例 6:行为变化写进文档/注释 —— 自签上游默认 502 是预期, +// 只允许通过 insecureTls 显式放开。这条防止有人「顺手把默认改回去」还删掉说明。 +func TestInsecureTLSBehaviourIsDocumentedInSource(t *testing.T) { + for _, f := range []string{"gateway.go"} { + src, err := os.ReadFile(f) + if err != nil { + t.Fatalf("读取 %s 失败: %v", f, err) + } + s := string(src) + if strings.Contains(s, `r.InsecureTLS || strings.HasPrefix(strings.ToLower(r.Upstream), "https://")`) { + t.Fatalf("%s 里仍有「https 就无条件关校验」的写法,必须改成只看 InsecureTLS", f) + } + if strings.Contains(s, `r.InsecureTLS || strings.HasPrefix(strings.ToLower(up), "https://")`) { + t.Fatalf("%s 的 favicon 段里仍有「https 就无条件关校验」的写法", f) + } + if !strings.Contains(s, "自签") || !strings.Contains(s, "502") { + t.Fatalf("%s 应写明「自签上游默认 502 是预期,要自签就勾 insecureTls」", f) + } + } +} From aae3fb7065a3db6a7a839af9627b546e91566e6d Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 23:25:26 +0800 Subject: [PATCH 3/5] =?UTF-8?q?fix(deploy):=20=E5=8E=86=E5=8F=B2=E6=9D=A1?= =?UTF-8?q?=E6=95=B0=E4=B8=8D=E5=86=8D=E5=81=87=E6=8A=A5=200=EF=BC=8C?= =?UTF-8?q?=E6=98=8E=E6=96=87=E4=BB=A4=E7=89=8C=E7=9A=84=20/tmp=20env=20?= =?UTF-8?q?=E6=96=87=E4=BB=B6=E5=BF=85=E5=88=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 两处都是「看起来成功、实际给了错误信息/留下凭据」的问题。 A. 历史条数假数据(行为变化) 旧逻辑:ADMIN_TOK=$(grep ... || true) 取不到时仍发一个空的 `Bearer ` 请求;第 1 步把 history-server 改成 fail-closed 后它必然回 401 JSON, 而 `grep -o '"url"' | wc -l` 对错误响应数出 0,再被 HIST=${HIST:-0} 兜一层,于是对外打印「历史条数 0」—— 一个假数据,看起来像历史被清空。 现在抽成 human_history_count(),三条路径分开: · 未配置 NAVI_ADMIN_TOKEN → 「未知(未配置…跳过探测)」,不发任何请求 · 非 200(401/502/连不上的 000) → 「未知(HTTP xxx)」,不数 body · 只有 200 才数 '"url"' 出现次数 ⚠️ 行为变化:token 取不到时历史条数显示「未知」而不是「0」; 真实的 0 条(200 + 空列表)仍然显示 0。仍旧绝不阻断升级。 B. 明文令牌的 env 文件残留 /tmp/navi-host-.env 含 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 明文, chmod 600 只防别的用户读,不防它一直躺在 /tmp。现在: · 统一为 $ENV_FILE(含 .t / .new 中间文件),不再散落字面量路径 · 新增 cleanup_env_file(),同时删三个文件 · 注册 trap ... EXIT(任何 exit 都删)+ fail() 回滚路径显式调用 + 成功收尾显式调用(三处) 删它不影响已起容器:docker run 已在启动时把值注入容器 env。 顺带确认(未改动,仅补注释):重置门户口令后打印一次 $NEWPW 是有意设计 ——那是管理员唯一一次拿到它的机会(数据卷只存哈希),注释已写明 「只打印这一次」,且不写日志文件、不进任何 env。 新增 deploy/zd-host_test.sh(bash deploy/zd-host_test.sh,退出码 0=全绿): 1. bash -n 语法检查; 2. 静态护栏:轮换只打印 ${#VAR} 长度、无 set -x、无 HIST=${HIST:-0}、 无「grep url | wc -l || echo 0」旧写法、必须校验 HTTP 状态码、 env 文件在 fail() 与 EXIT trap 与成功路径上都有删除、中间文件也删; 3. 用 BEGIN/END 标记把 human_history_count 原样抠出来,配 stub curl 单测 空 token / 401 / 502 / 连接失败 / 200 三条 / 200 空列表 六种情形, 并断言空 token 时根本没发请求、有 token 时带了 Authorization 头。 反向验证:把历史条数换回旧写法并删掉三处 env 文件清理后, 测试从 26 通过 / 0 失败变成 10 通过 / 16 失败(列出「仍有 HIST=${HIST:-0}」 「fail() 回滚路径没有删 env 文件」「空 token 时仍然调用了 curl」等)。 另外用第 1 步的真实 history-server + 模拟两层鉴权的反代做了端到端复核, 旧写法在 401/连接失败下都报 0,新写法分别报「未知(HTTP 401)」「未知(HTTP 000)」, 200 + 3 条报 3,200 + 空列表报 0。 --- deploy/zd-host.sh | 93 ++++++++++---- deploy/zd-host_test.sh | 277 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 346 insertions(+), 24 deletions(-) create mode 100755 deploy/zd-host_test.sh diff --git a/deploy/zd-host.sh b/deploy/zd-host.sh index d294c06..938675b 100644 --- a/deploy/zd-host.sh +++ b/deploy/zd-host.sh @@ -118,17 +118,27 @@ if [ "$NET" != "host" ]; then fi OLD_ID=$(docker inspect navi --format '{{.Id}}') + +# 该文件含**明文令牌**(NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 等)。chmod 600 只防别的 +# 用户读,不能防它一直躺在 /tmp 里,所以成功、失败回滚、以及任何 exit 都要删。 +# 删它不影响已起的容器:docker run 已在启动时把值注入容器 env(--env-file 只读一次)。 +ENV_FILE="/tmp/navi-host-${STAMP}.env" +cleanup_env_file() { + rm -f "$ENV_FILE" "$ENV_FILE.t" "$ENV_FILE.new" 2>/dev/null || true +} +trap cleanup_env_file EXIT + docker inspect navi --format '{{range .Config.Env}}{{println .}}{{end}}' \ - | grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND|CERT_)' > "/tmp/navi-host-${STAMP}.env" || true -chmod 600 "/tmp/navi-host-${STAMP}.env" + | grep -E '^(NAVI_ADMIN_TOKEN|NAVI_SYNC_TOKEN|SEARXNG_BASE_URL|PORTAL_|DEBIAN_FRONTEND|CERT_)' > "$ENV_FILE" || true +chmod 600 "$ENV_FILE" # ① 不再把明文门户口令带进新容器(docker inspect 就能看到它)。 # 实际生效的是数据卷里的加盐哈希,所以丢掉 env 不影响登录; # 稍后会在新容器里写入一个强度更高的随机口令并打印一次。 CLEAN_PORTAL_PW=0 -if grep -q '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env"; then - grep -v '^PORTAL_PASSWORD=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.new" && mv "/tmp/navi-host-${STAMP}.env.new" "/tmp/navi-host-${STAMP}.env" -CLEAN_PORTAL_PW=1 +if grep -q '^PORTAL_PASSWORD=' "$ENV_FILE"; then + grep -v '^PORTAL_PASSWORD=' "$ENV_FILE" > "$ENV_FILE.new" && mv "$ENV_FILE.new" "$ENV_FILE" + CLEAN_PORTAL_PW=1 fi # ③ 令牌轮换:渗透报告证明旧 NAVI_ADMIN_TOKEN / NAVI_SYNC_TOKEN 已泄露 @@ -141,9 +151,9 @@ if [ "${#NAVI_ROTATE_ADMIN_TOKEN}" -lt 32 ]; then echo "!! NAVI_ROTATE_ADMIN_TOKEN 太短(${#NAVI_ROTATE_ADMIN_TOKEN} 字符),至少 32" >&2 exit 2 fi -grep -v '^NAVI_ADMIN_TOKEN=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.t" || true -echo "NAVI_ADMIN_TOKEN=${NAVI_ROTATE_ADMIN_TOKEN}" >> "/tmp/navi-host-${STAMP}.env.t" -mv "/tmp/navi-host-${STAMP}.env.t" "/tmp/navi-host-${STAMP}.env" +grep -v '^NAVI_ADMIN_TOKEN=' "$ENV_FILE" > "$ENV_FILE.t" || true +echo "NAVI_ADMIN_TOKEN=${NAVI_ROTATE_ADMIN_TOKEN}" >> "$ENV_FILE.t" +mv "$ENV_FILE.t" "$ENV_FILE" echo " 已轮换 NAVI_ADMIN_TOKEN(新值长度 ${#NAVI_ROTATE_ADMIN_TOKEN})" fi if [ -n "${NAVI_ROTATE_SYNC_TOKEN:-}" ]; then @@ -151,26 +161,26 @@ if [ "${#NAVI_ROTATE_SYNC_TOKEN}" -lt 32 ]; then echo "!! NAVI_ROTATE_SYNC_TOKEN 太短(${#NAVI_ROTATE_SYNC_TOKEN} 字符),至少 32" >&2 exit 2 fi -grep -v '^NAVI_SYNC_TOKEN=' "/tmp/navi-host-${STAMP}.env" > "/tmp/navi-host-${STAMP}.env.t" || true -echo "NAVI_SYNC_TOKEN=${NAVI_ROTATE_SYNC_TOKEN}" >> "/tmp/navi-host-${STAMP}.env.t" -mv "/tmp/navi-host-${STAMP}.env.t" "/tmp/navi-host-${STAMP}.env" +grep -v '^NAVI_SYNC_TOKEN=' "$ENV_FILE" > "$ENV_FILE.t" || true +echo "NAVI_SYNC_TOKEN=${NAVI_ROTATE_SYNC_TOKEN}" >> "$ENV_FILE.t" +mv "$ENV_FILE.t" "$ENV_FILE" echo " 已轮换 NAVI_SYNC_TOKEN(新值长度 ${#NAVI_ROTATE_SYNC_TOKEN})" fi -chmod 600 "/tmp/navi-host-${STAMP}.env" +chmod 600 "$ENV_FILE" # ② 跨子域单点登录所需变量:新版已移除代码里的写死默认值, # 不显式提供的话登录 cookie 会退化成 host-only(登录后换子域就掉线)。 -if ! grep -q '^PORTAL_COOKIE_DOMAIN=' "/tmp/navi-host-${STAMP}.env"; then +if ! grep -q '^PORTAL_COOKIE_DOMAIN=' "$ENV_FILE"; then if [ -n "${NAVI_COOKIE_DOMAIN:-}" ]; then - echo "PORTAL_COOKIE_DOMAIN=${NAVI_COOKIE_DOMAIN}" >> "/tmp/navi-host-${STAMP}.env" + echo "PORTAL_COOKIE_DOMAIN=${NAVI_COOKIE_DOMAIN}" >> "$ENV_FILE" else echo "!! 缺少 PORTAL_COOKIE_DOMAIN:新版已删除代码默认值,直接升级会让跨子域单点登录失效。" >&2 echo " 执行方式:NAVI_COOKIE_DOMAIN=.你的域名 NAVI_HOME_URL=https://你的门户/ NAVI_SSH_PASS=... bash deploy/zd-host.sh $VER" >&2 exit 2 fi fi -if ! grep -q '^PORTAL_HOME_URL=' "/tmp/navi-host-${STAMP}.env" && [ -n "${NAVI_HOME_URL:-}" ]; then - echo "PORTAL_HOME_URL=${NAVI_HOME_URL}" >> "/tmp/navi-host-${STAMP}.env" +if ! grep -q '^PORTAL_HOME_URL=' "$ENV_FILE" && [ -n "${NAVI_HOME_URL:-}" ]; then + echo "PORTAL_HOME_URL=${NAVI_HOME_URL}" >> "$ENV_FILE" fi OLD_IMAGE=$(docker inspect navi --format '{{.Config.Image}}') @@ -178,6 +188,7 @@ PREV="navi-prev-${STAMP}" fail() { rc=$? echo "!! 失败(rc=$rc)→ 回滚到 ${OLD_IMAGE}" >&2 + cleanup_env_file docker rm -f navi-next >/dev/null 2>&1 || true if ! docker inspect "$PREV" >/dev/null 2>&1; then # 还没改名就把原容器起回来 @@ -200,7 +211,7 @@ docker rename navi "$PREV" docker run -d --name navi-next --restart unless-stopped --network host \ -v "${DATA_DIR}:/app/data" -v "${CERT_DIR}:/etc/nginx/certs" \ - --env-file "/tmp/navi-host-${STAMP}.env" "homedash:ui-${VER}" >/dev/null + --env-file "$ENV_FILE" "homedash:ui-${VER}" >/dev/null w200() { for _ in $(seq 1 60); do [ "$(curl -s -o /dev/null -w '%{http_code}' -m 3 "$1" 2>/dev/null || true)" = "200" ] && { echo " ready $1"; return 0; }; sleep 2; done; echo " TIMEOUT $1" >&2; return 1; } w200 "http://127.0.0.1:18080/healthz" @@ -240,7 +251,7 @@ echo " 入口自检通过(3080/3081)" # 让容器按当前路由表重新生成一次网关配置:镜像里的门户模板/错误页可能已更新, # 而线上 gateway.conf 是上一次生成的(本轮真实事故:门户页占位符裸露)。 -ADMIN_TOK=$(grep '^NAVI_ADMIN_TOKEN=' "/tmp/navi-host-${STAMP}.env" | cut -d= -f2- || true) +ADMIN_TOK=$(grep '^NAVI_ADMIN_TOKEN=' "$ENV_FILE" | cut -d= -f2- || true) if [ -n "$ADMIN_TOK" ]; then APPLY=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" http://127.0.0.1:3005/api/gateway/apply || true) echo " 已重新生成网关配置: ${APPLY}" @@ -259,23 +270,57 @@ if [ "${CLEAN_PORTAL_PW:-0}" = "1" ] && [ "${NAVI_ROTATE_PORTAL_PASSWORD:-0}" = RESP=$(curl -s -X POST -H "Authorization: Bearer $ADMIN_TOK" -H 'Content-Type: application/json' \ -d "{\"username\":\"admin\",\"password\":\"$NEWPW\"}" http://127.0.0.1:3005/api/portal-auth || true) case "$RESP" in *error*|*Error*) echo " ! 门户口令重置失败(可在后台改):$RESP" >&2 ;; - *) echo " 已清除明文 env 并重置门户口令 → admin / $NEWPW(请立即记录)" ;; esac + # 这里**有意**把新口令打印一次:这是管理员唯一能拿到它的机会(数据卷里只存哈希)。 + # 打印到此为止 —— 不写日志文件、不写进任何 env/注释,也不在任何其它路径回显。 + *) echo " 已清除明文 env 并重置门户口令 → admin / $NEWPW(请立即记录,只打印这一次)" ;; esac fi fi # 历史条数只用于人工对比,不应成为升级的阻断点。 # 2026-10-22:管理 API 改为默认拒绝后,/admin/api/* 需要登录会话; # 这里用管理员 token 访问(v107+ 支持 Bearer),拿不到就不显示而不报错。 -HIST=$(curl -s -m 8 -H "Authorization: Bearer ${ADMIN_TOK:-}" \ -"http://127.0.0.1:18080/admin/api/history" 2>/dev/null \ -| grep -o '"url"' | wc -l || echo 0) -HIST=${HIST:-0} -echo " 历史条数 ${HIST:-0}(同步接口只累加不删除;与切换前对比由人工确认)" +# +# 2026-10-22 审计补修:以前取不到 token 时仍会发一个空的 `Bearer ` 请求, +# history-server fail-closed 回 401 JSON,`grep -o '"url"' | wc -l` 数出 0, +# 于是对外**打印「历史条数 0」——一个假数据**(看起来像历史被清空了)。 +# 现在三条路径分开:无 token → 「未知(跳过)」;非 200 → 「未知(HTTP xxx)」; +# 只有 200 才数 body。仍然绝不阻断升级。 +# >>> human_history_count BEGIN(deploy/zd-host_test.sh 会抽取这一段做单测,改动请保留标记) +human_history_count() { + # $1 = 管理员 token(可空) + local tok="$1" + if [ -z "$tok" ]; then + echo "未知(未配置 NAVI_ADMIN_TOKEN,跳过探测)" + return 0 + fi + local tmp code body n + tmp=$(mktemp 2>/dev/null || echo "/tmp/navi-hist-${STAMP}") + # curl 连不上时仍会把 -w 的 000 写出来并返回非 0,所以先 `|| true` 再归一化, + # 不能 `|| echo 000`(那会拼成 "000\n000" 打进提示里)。 + code=$(curl -s -m 8 -o "$tmp" -w '%{http_code}' \ + -H "Authorization: Bearer ${tok}" \ + "http://127.0.0.1:18080/admin/api/history" 2>/dev/null || true) + code=$(printf '%s' "$code" | tr -d '[:space:]') + [ -n "$code" ] || code=000 + if [ "$code" != "200" ]; then + rm -f "$tmp" + echo "未知(HTTP ${code};接口要求管理员 token,失败不影响升级)" + return 0 + fi + body=$(cat "$tmp" 2>/dev/null || true) + rm -f "$tmp" + n=$(printf '%s' "$body" | grep -o '"url"' | wc -l || true) + echo "${n:-0}" +} +# <<< human_history_count END +HIST=$(human_history_count "${ADMIN_TOK:-}") +echo " 历史条数 ${HIST}(同步接口只累加不删除;与切换前对比由人工确认)" docker rename navi-next navi trap - ERR docker rm -f "$PREV" >/dev/null 2>&1 || true rm -f "$REMOTE_TAR" +cleanup_env_file # 明文令牌的 /tmp env 文件必须删(成功路径) # 收尾断言:名字必须回到 navi。中途异常中断曾导致容器留在 navi-next, # 而脚本"看起来"跑完了(本轮真实事故),所以这里显式检查。 if ! docker inspect navi >/dev/null 2>&1; then diff --git a/deploy/zd-host_test.sh b/deploy/zd-host_test.sh new file mode 100755 index 0000000..bb9c56b --- /dev/null +++ b/deploy/zd-host_test.sh @@ -0,0 +1,277 @@ +#!/usr/bin/env bash +# deploy/zd-host.sh 的静态护栏 + 逻辑单测。 +# +# 为什么需要它:zd-host.sh 是「一跑就动线上」的部署脚本,没法在 CI 里真跑。 +# 于是把可验证的部分拆成两层: +# ① 静态护栏:对脚本正文做断言(令牌只打印长度、没有假数据路径、env 文件必删) +# ② 逻辑抽取:把 human_history_count 那段函数原样抠出来,用 stub curl 单测 +# +# 用法:bash deploy/zd-host_test.sh (退出码 0 = 全绿) +# 依赖:bash / grep / sed / awk / mktemp,无网络、无 docker。 +set -uo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +TARGET="$HERE/zd-host.sh" + +PASS=0 +FAIL=0 +ok() { PASS=$((PASS+1)); printf ' \033[32m✓\033[0m %s\n' "$1"; } +bad() { FAIL=$((FAIL+1)); printf ' \033[31m✗\033[0m %s\n' "$1"; [ -n "${2:-}" ] && printf ' %s\n' "$2"; } +head_() { printf '\n== %s ==\n' "$1"; } + +[ -f "$TARGET" ] || { echo "找不到 $TARGET" >&2; exit 1; } + +# ──────────────────────────────────────────────────────────── +head_ "1. 语法检查(bash -n)" +# ──────────────────────────────────────────────────────────── +if bash -n "$TARGET" 2>/tmp/zdnsyn.$$; then + ok "bash -n deploy/zd-host.sh 通过" +else + bad "bash -n 失败" "$(cat /tmp/zdnsyn.$$)" +fi +rm -f /tmp/zdnsyn.$$ + +# ──────────────────────────────────────────────────────────── +head_ "2a. 令牌轮换只打印长度,不打印值" +# ──────────────────────────────────────────────────────────── +# 轮换段必须用 ${#VAR}(长度)形式打印 +len_prints=$(grep -c '新值长度 \${#NAVI_ROTATE_' "$TARGET" || true) +if [ "${len_prints:-0}" -ge 2 ]; then + ok "两处轮换都只打印长度(${len_prints} 处)" +else + bad "令牌轮换的长度打印少于 2 处(实际 ${len_prints:-0})" +fi + +# 不得出现「echo ... $NAVI_ROTATE_xxx 的值本身」这种写法: +# 即 echo 行里引用变量却没有用 ${#VAR} 取长度。 +# 例外:`echo "NAVI_ADMIN_TOKEN=..." >> "$ENV_FILE.t"` 是**写进 env 文件**(容器启动要用), +# 不是打到 stdout/日志,因此排除带 `>>` 重定向的行。 +leak=$(grep -nE '^[^#]*echo[^#]*\$\{?NAVI_ROTATE_(ADMIN|SYNC)_TOKEN' "$TARGET" \ + | grep -v '\${#NAVI_ROTATE_' \ + | grep -v '>>' || true) +if [ -z "$leak" ]; then + ok "没有 echo 直接打印 NAVI_ROTATE_*_TOKEN 的值" +else + bad "发现可能打印令牌值的 echo 行" "$leak" +fi + +# 全脚本不得出现 set -x(会把 env/变量回显到日志) +if grep -qE '^[[:space:]]*set[[:space:]]+-[a-z]*x' "$TARGET"; then + bad "脚本里出现 set -x,会把变量值回显到日志" +else + ok "没有 set -x" +fi + +# ──────────────────────────────────────────────────────────── +head_ "2b. ADMIN_TOK 缺失时不得打印「历史条数 0」" +# ──────────────────────────────────────────────────────────── +if grep -q 'if \[ -z "\$tok" \]' "$TARGET"; then + ok "human_history_count 里有 token 为空的分支" +else + bad "human_history_count 缺少「token 为空」分支" +fi + +if grep -q '未知(未配置 NAVI_ADMIN_TOKEN,跳过探测)' "$TARGET"; then + ok "空 token 时输出「未知(…跳过探测)」" +else + bad "空 token 分支没有输出「未知」文案" +fi + +# 假的 0 从哪来:旧写法 `| grep -o '"url"' | wc -l || echo 0` + `HIST=${HIST:-0}`。 +if grep -qE 'HIST=\$\{HIST:-0\}' "$TARGET"; then + bad "仍有 HIST=\${HIST:-0} 兜底,会把「取不到」变成「0 条」" +else + ok "没有 HIST=\${HIST:-0} 假数据兜底" +fi +if grep -nE 'grep -o .\"url\". \| wc -l \|\| echo 0' "$TARGET" >/dev/null 2>&1; then + bad "仍有「grep url | wc -l || echo 0」的历史条数写法" +else + ok "没有「grep url | wc -l || echo 0」的旧写法" +fi + +# 必须校验 HTTP 状态码(非 200 不数 body) +if grep -q "code.*!= \"200\"" "$TARGET"; then + ok "探测时校验 HTTP 状态码,非 200 不数 body" +else + bad "探测没有校验 HTTP 状态码" +fi +if grep -q "未知(HTTP " "$TARGET"; then + ok "非 200 时输出「未知(HTTP xxx)」" +else + bad "非 200 时没有「未知(HTTP xxx)」输出" +fi + +# ──────────────────────────────────────────────────────────── +head_ "2c. 明文令牌的 env 文件在成功与失败路径都必须删除" +# ──────────────────────────────────────────────────────────── +fail_body=$(awk '/^fail\(\) \{/,/^}/' "$TARGET") +if printf '%s' "$fail_body" | grep -q 'cleanup_env_file'; then + ok "fail() 回滚路径会调 cleanup_env_file" +else + bad "fail() 回滚路径没有删 env 文件" +fi + +if grep -qE '^\s*trap cleanup_env_file EXIT' "$TARGET"; then + ok "设置了 EXIT trap(异常退出也会删)" +else + bad "没有 EXIT trap 兜底删除 env 文件" +fi + +# 成功路径:在 rm -f "$REMOTE_TAR" 附近也要有一次显式 cleanup +if grep -qE '^cleanup_env_file' "$TARGET"; then + ok "成功收尾路径显式调 cleanup_env_file" +else + bad "成功收尾路径没有显式删 env 文件" +fi + +if grep -q 'rm -f "\$ENV_FILE" "\$ENV_FILE.t" "\$ENV_FILE.new"' "$TARGET"; then + ok "cleanup 同时删 .t / .new 中间文件" +else + bad "cleanup 漏删中间文件(.t / .new 也含明文令牌)" +fi + +# ──────────────────────────────────────────────────────────── +head_ "3. human_history_count 逻辑单测(stub curl)" +# ──────────────────────────────────────────────────────────── +TMPD=$(mktemp -d) +trap 'rm -rf "$TMPD"' EXIT + +# 抽取函数体(由脚本里的 BEGIN/END 标记界定;没标记说明被人删了标记,直接失败) +awk '/^# >>> human_history_count BEGIN/,/^# <<< human_history_count END/' "$TARGET" > "$TMPD/func.sh" +if [ -s "$TMPD/func.sh" ]; then + ok "成功抽取 human_history_count 函数" +else + bad "抽取 human_history_count 失败(BEGIN/END 标记缺失?)" +fi + +# stub curl:用环境变量控制行为,并记录「是否被调用」 +mkdir -p "$TMPD/bin" +cat > "$TMPD/bin/curl" <<'STUB' +#!/usr/bin/env bash +# 只实现被 human_history_count 用到的那几个参数:-s -m N -o FILE -w '%{http_code}' -H ... +out="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + -H|-m) shift 2 ;; + -w) shift 2 ;; + -*) shift ;; + *) shift ;; + esac +done +echo "called" >> "${STUB_CALLS:-/dev/null}" +case "${STUB_MODE:-ok}" in + ok) [ -n "$out" ] && printf '%s' "${STUB_BODY:-}" > "$out"; printf '200'; exit 0 ;; + 401) [ -n "$out" ] && printf '%s' '{"error":"invalid or missing token"}' > "$out"; printf '401'; exit 0 ;; + 502) [ -n "$out" ] && printf '%s' '502 Bad Gateway' > "$out"; printf '502'; exit 0 ;; + conn) # 连不上:curl 会打印 000 并返回非 0 + [ -n "$out" ] && : > "$out"; printf '000'; exit 7 ;; +esac +exit 0 +STUB +chmod +x "$TMPD/bin/curl" + +run_hist() { # $1=token ; 其余由 STUB_* 控制 + PATH="$TMPD/bin:$PATH" STUB_CALLS="$TMPD/calls" \ + bash -c 'set -uo pipefail; . "$1"; human_history_count "$2"' _ "$TMPD/func.sh" "$1" +} + +BODY3='{"items":[{"url":"https://a.example.com/"},{"url":"https://b.example.com/"},{"url":"https://c.example.com/"}],"total":3}' + +# 3.1 空 token → 跳过探测,输出「未知」,且**不能**调用 curl(更不能打印 0) +rm -f "$TMPD/calls" +got=$(STUB_MODE=ok STUB_BODY="$BODY3" run_hist "" ) +if printf '%s' "$got" | grep -q '未知' && ! printf '%s' "$got" | grep -qx '0'; then + ok "空 token → 「未知」而不是 0(输出: $got)" +else + bad "空 token 时输出了非预期内容" "got=[$got]" +fi +if [ ! -f "$TMPD/calls" ]; then + ok "空 token 时完全没有发请求(没带空 Bearer 打过去)" +else + bad "空 token 时仍然调用了 curl" +fi + +# 3.2 有 token 但接口 401 → 「未知(HTTP 401)」,不是 0 +got=$(STUB_MODE=401 run_hist "some-admin-token") +if printf '%s' "$got" | grep -q '未知(HTTP 401' ; then + ok "401 → 「未知(HTTP 401)」(输出: $got)" +else + bad "401 时没有输出「未知(HTTP 401)」" "got=[$got]" +fi +if printf '%s' "$got" | grep -qx '0'; then + bad "401 时把响应体当数据数出了 0 条" +else + ok "401 时不会数 body" +fi + +# 3.3 502(body 是 HTML)→ 「未知(HTTP 502)」,不得把 HTML 当数据 +got=$(STUB_MODE=502 run_hist "some-admin-token") +if printf '%s' "$got" | grep -q '未知(HTTP 502'; then + ok "502 → 「未知(HTTP 502)」" +else + bad "502 时没有输出「未知(HTTP 502)」" "got=[$got]" +fi + +# 3.4 连不上(curl 打印 000 且返回非 0)→ 「未知(HTTP 000)」,且提示里不出现重复的 000 +got=$(STUB_MODE=conn run_hist "some-admin-token") +if printf '%s' "$got" | grep -q '未知(HTTP 000'; then + ok "连接失败 → 「未知(HTTP 000)」(输出: $got)" +else + bad "连接失败时输出不符合预期" "got=[$got]" +fi +if printf '%s' "$got" | grep -qE '000[[:space:]]*000'; then + bad "状态码被拼成了两个 000" "got=[$got]" +else + ok "状态码已归一化(没有重复 000)" +fi + +# 3.5 200 且 3 条 → 输出 3 +got=$(STUB_MODE=ok STUB_BODY="$BODY3" run_hist "some-admin-token") +if [ "$got" = "3" ]; then + ok "200 + 3 条 → 输出 3" +else + bad "200 时条数不对" "got=[$got] want=[3]" +fi + +# 3.6 200 但 0 条 → 输出 0(这是**真实**的 0,必须能显示出来) +got=$(STUB_MODE=ok STUB_BODY='{"items":[],"total":0}' run_hist "some-admin-token") +if [ "$got" = "0" ]; then + ok "200 + 空列表 → 输出 0(真实的 0 仍然显示)" +else + bad "真实 0 条时输出不对" "got=[$got] want=[0]" +fi + +# 3.7 有 token 时确实带上了 Authorization 头(用 stub 校验参数) +cat > "$TMPD/bin/curl" <<'STUB2' +#!/usr/bin/env bash +args="$*" +echo "$args" >> "$STUB_ARGS" +out="" +while [ $# -gt 0 ]; do + case "$1" in + -o) out="$2"; shift 2 ;; + *) shift ;; + esac +done +[ -n "$out" ] && printf '{"items":[{"url":"x"}]}' > "$out" +printf '200' +STUB2 +chmod +x "$TMPD/bin/curl" +rm -f "$TMPD/args" +PATH="$TMPD/bin:$PATH" STUB_ARGS="$TMPD/args" \ + bash -c 'set -uo pipefail; . "$1"; human_history_count "tok-abc"' _ "$TMPD/func.sh" >/dev/null +if grep -q 'Authorization: Bearer tok-abc' "$TMPD/args"; then + ok "有 token 时请求带 Authorization: Bearer" +else + bad "请求没带 Authorization 头" "$(cat "$TMPD/args" 2>/dev/null)" +fi +if grep -q '/admin/api/history' "$TMPD/args"; then + ok "探测打的是 /admin/api/history(不是根 /api/)" +else + bad "探测地址不对" "$(cat "$TMPD/args" 2>/dev/null)" +fi + +# ──────────────────────────────────────────────────────────── +printf '\n== 结果 ==\n通过 %d,失败 %d\n' "$PASS" "$FAIL" +[ "$FAIL" -eq 0 ] || exit 1 From cd108f809331273bb12afccea311c1585667861e Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 23:27:51 +0800 Subject: [PATCH 4/5] =?UTF-8?q?fix(admin):=20requireToken=20=E5=8E=BB?= =?UTF-8?q?=E6=8E=89=20fail-open=20=E6=AD=BB=E5=88=86=E6=94=AF=EF=BC=8C?= =?UTF-8?q?=E7=A9=BA=20expected=20=E4=B8=80=E5=BE=8B=E6=8B=92=E7=BB=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 审计第 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。 --- admin-server/main.go | 17 ++- admin-server/token_failclosed_test.go | 195 ++++++++++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 admin-server/token_failclosed_test.go diff --git a/admin-server/main.go b/admin-server/main.go index 81e0d8c..376f489 100644 --- a/admin-server/main.go +++ b/admin-server/main.go @@ -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 )") + return false } auth := r.Header.Get("Authorization") if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected { diff --git a/admin-server/token_failclosed_test.go b/admin-server/token_failclosed_test.go new file mode 100644 index 0000000..cfb12ac --- /dev/null +++ b/admin-server/token_failclosed_test.go @@ -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) + } +} From 745b8ea78679ac0ef6a1f954e0e4a3c4beb2ea18 Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Thu, 22 Oct 2026 23:29:43 +0800 Subject: [PATCH 5/5] =?UTF-8?q?docs(readme):=20=E4=BB=A4=E7=89=8C=E9=BB=98?= =?UTF-8?q?=E8=AE=A4=E5=80=BC=E8=AF=AD=E4=B9=89=E6=94=B9=E4=B8=BA=20fail-c?= =?UTF-8?q?losed=EF=BC=8C=E7=99=BB=E8=AE=B0=E4=B8=A4=E4=B8=AA=E6=9C=AC?= =?UTF-8?q?=E5=9C=B0=E8=B0=83=E8=AF=95=E5=BC=80=E5=85=B3?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 第 1 步把 history-server 的鉴权改成 fail-closed 后,README 的环境变量表 还写着「空(不鉴权)」—— 文档与代码相反,照它「不配也行」去部署的实例 升级后会全站 401。这正是 fail-closed 想要的结果,但必须说清楚, 否则第一个撞上的人会以为是我们改坏了。 改动(只动这两处,没有重写 README): 1. 环境变量表(README:156-157): - `NAVI_SYNC_TOKEN` / `NAVI_ADMIN_TOKEN` 的默认值列改为 「空 → 拒绝所有请求(fail-closed),生产必须配置」,并说明未配置时 哪些接口会拒绝(不是笼统写「必填」)。 - 新增 `NAVI_ADMIN_ALLOW_UNAUTHENTICATED` 与 `NAVI_HISTORY_ALLOW_UNAUTHENTICATED` 两行,并补一段注意事项: 默认关闭、只在没配 token 时才起作用、仅限本机调试、 **两者不要混用**(前者只放开 admin-server,后者只放开 history-server)。 - 明确写出升级提示:「从旧版本升级且此前未配 token 的实例,升级后必须 补上这两个变量,否则会撞上 401」。 2. 「实现参考」段(原 README:1080-1081)核对后已不准确,改为按服务分别写清: - history-server:/sync 与列表读校验 NAVI_SYNC_TOKEN;清空(DELETE /)、 /item 逐条删除、/rebuild 校验 NAVI_ADMIN_TOKEN(未配 admin token 时 接受 NAVI_SYNC_TOKEN 作为等价凭据,兼容旧部署); - admin-server:所有管理 API(含 /admin/api/history*)一律校验 NAVI_ADMIN_TOKEN; - 并点明「任一服务未配置对应 token 时拒绝所有请求(401),不再有 『不配就放行』的旁路」。 刻意未写进文档的(本轮不扩大范围,已在任务里确认不动): - `/stats`、`/nav-visit` 仍未鉴权 —— 因此文档里描述的是「哪些接口会拒绝」, 而不是笼统的「历史接口全拒绝」,避免再次写出与代码不符的文档。 - `NAVI_HISTORY_ALLOW_UNAUTHENTICATED` 没有写进 deploy/zd-host.sh 的 env 继承白名单(默认就该不设)。 核对方式:逐个变量 grep 非测试源码,确认 4 个变量都真实存在且引用位置与 文档描述一致(NAVI_HISTORY_ALLOW_UNAUTHENTICATED 只在 history-server 生效, NAVI_ADMIN_ALLOW_UNAUTHENTICATED 只在 admin-server 生效)。 --- README.md | 29 +++++++++++++++++++++++++---- 1 file changed, 25 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 36ccca8..162699f 100644 --- a/README.md +++ b/README.md @@ -153,12 +153,26 @@ icon: fas fa-star # Font Awesome | 变量 | 说明 | 默认 | |------|------|------| -| `NAVI_SYNC_TOKEN` | 浏览器扩展同步历史令牌 | 空(不鉴权) | -| `NAVI_ADMIN_TOKEN` | 管理后台/API 写操作令牌 | 空(不鉴权) | +| `NAVI_SYNC_TOKEN` | 浏览器扩展同步历史令牌;未配置时历史读写接口(`/sync`、列表、清空、逐条删除、`/rebuild`)**拒绝所有请求** | 空 → 拒绝所有请求(fail-closed),**生产必须配置** | +| `NAVI_ADMIN_TOKEN` | 管理后台/API 写操作令牌;未配置时管理接口**拒绝所有请求** | 空 → 拒绝所有请求(fail-closed),**生产必须配置** | +| `NAVI_ADMIN_ALLOW_UNAUTHENTICATED` | 仅本机调试:允许**未配置 token** 时访问管理 API。默认关闭,绝不要用在线上 | 关 | +| `NAVI_HISTORY_ALLOW_UNAUTHENTICATED` | 仅本机调试:允许**未配置 token** 时访问历史接口(`/sync`、列表、清空、逐条删除、`/rebuild`)。默认关闭,绝不要用在线上 | 关 | | `SEARXNG_BASE_URL` | 对外访问地址,影响搜索结果链接 | `http://localhost:3003` | | `NAVI_PORT` | compose 对外端口 | `3003` | | `NAVI_FAVICON_PROXY` | 抓历史图标用的代理;留空自动复用 `searxng-settings.yml` 的 `outgoing.proxies` | 空 | +> ⚠️ **两个 token 的默认值语义是「拒绝」而不是「不鉴权」**(fail-closed)。 +> 两个变量都留空时,管理接口与历史接口会返回 **401**(JSON 体里带 `error` 与修复指引), +> 表现为「全站 401 / 管理页打不开」。这是刻意设计:没配凭据就不该放行。 +> 从旧版本升级且此前未配 token 的实例,升级后必须补上这两个变量,否则会撞上 401。 +> +> 两个 `*_ALLOW_UNAUTHENTICATED` 开关**不要混用**: +> - `NAVI_ADMIN_ALLOW_UNAUTHENTICATED` 只放开 **admin-server** 的管理 API; +> - `NAVI_HISTORY_ALLOW_UNAUTHENTICATED` 只放开 **history-server**(历史接口)。 +> +> 两者都**只在对应服务没配 token 时才起作用**;一旦配了 token,就必须带对 `Authorization: Bearer`。 +> 它们只为「本机直接跑 handler / 调试」存在,部署时不要设置。 + ## 浏览器扩展 ### 安装(两种方式) @@ -1077,8 +1091,15 @@ curl -H "Authorization: Bearer " \ http://:3003/admin/api/item ``` -> 实现参考:`history-server` 的 `/sync` 校验 `NAVI_SYNC_TOKEN`, -> `admin-server` 的写操作校验 `NAVI_ADMIN_TOKEN`。 +> 实现参考(鉴权是**默认拒绝**的,两个服务各自校验): +> - `history-server`:`/sync` 与历史列表读接口校验 `NAVI_SYNC_TOKEN`; +> 清空(`DELETE /`)、`/item` 逐条删除、`/rebuild` 校验 `NAVI_ADMIN_TOKEN` +> (未配置 admin token 时接受 `NAVI_SYNC_TOKEN` 作为等价凭据,兼容旧部署)。 +> - `admin-server`:所有管理 API(含 `/admin/api/history*`、路由表、站点信息、门户口令) +> 一律校验 `NAVI_ADMIN_TOKEN`。 +> +> 任一服务**未配置对应 token 时拒绝所有请求(401)**,不再有「不配就放行」的旁路; +> 本机调试需要放行时,用上表的 `NAVI_*_ALLOW_UNAUTHENTICATED`(默认关闭,勿用于部署)。 ## 数据备份