fix(history): 鉴权改为 fail-closed,未配置 token 时拒绝一切请求

审计发现的方式与 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。
This commit is contained in:
JianFeeeee
2026-10-22 23:08:32 +08:00
parent 71d0c06b67
commit 857ec074aa
2 changed files with 379 additions and 9 deletions

295
history-server/auth_test.go Normal file
View File

@ -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 则不校验」的放行短路,必须收掉")
}
}

View File

@ -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 <token>), "+
"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)