审计发现的方式与 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。
296 lines
10 KiB
Go
296 lines
10 KiB
Go
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 则不校验」的放行短路,必须收掉")
|
||
}
|
||
}
|