Files
HomeDash/history-server/auth_test.go
JianFeeeee 857ec074aa 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。
2026-10-22 23:08:32 +08:00

296 lines
10 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

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