Merge PR #1: 审计高危项修复(history 鉴权 fail-closed / 网关 TLS 校验 / 部署脚本)
commondev 判据正向+变异全通过,老大确认合入。
This commit is contained in:
29
README.md
29
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 <NAVI_ADMIN_TOKEN>" \
|
||||
http://<ip>: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`(默认关闭,勿用于部署)。
|
||||
|
||||
## 数据备份
|
||||
|
||||
|
||||
@ -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")
|
||||
|
||||
208
admin-server/gateway_tls_verify_test.go
Normal file
208
admin-server/gateway_tls_verify_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 <token>)")
|
||||
return false
|
||||
}
|
||||
auth := r.Header.Get("Authorization")
|
||||
if strings.HasPrefix(auth, "Bearer ") && strings.TrimPrefix(auth, "Bearer ") == expected {
|
||||
|
||||
195
admin-server/token_failclosed_test.go
Normal file
195
admin-server/token_failclosed_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
@ -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
|
||||
|
||||
277
deploy/zd-host_test.sh
Executable file
277
deploy/zd-host_test.sh
Executable file
@ -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' '<html>502 Bad Gateway</html>' > "$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
|
||||
295
history-server/auth_test.go
Normal file
295
history-server/auth_test.go
Normal 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 则不校验」的放行短路,必须收掉")
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
|
||||
Reference in New Issue
Block a user