fix: 审计高危项修复(history 鉴权 fail-closed / 网关 TLS 校验 / 部署脚本) #1

Open
jianf wants to merge 0 commits from fix/audit-high-priority into master
Owner

修了什么

  1. history-server 鉴权 fail-closed(审计高危①)

    • requireToken:NAVI_SYNC_TOKEN 未配置时不再放行,一律 401;
    • 新增 requireAdminToken / isTruthy / jsonErr / logOnce,3 处调用点改为 admin 语义;
    • 新增 history-server/auth_test.go(8 用例)。
  2. 网关 https 上游不再无条件关闭证书校验(审计高危②)

    • admin-server/gateway.go:只有上游显式声明 insecureTls 才 proxy_ssl_verify off;
    • 主 location 与 favicon 段两条路径分别钉住;
    • 新增 gateway_tls_verify_test.go(6 用例)。
  3. admin-server requireToken 去掉 fail-open 死分支(高危①的另一半)

    • expected == "" 时返回 401 + JSON 修复指引,绝不 return true;
    • 新增 token_failclosed_test.go(含跨仓库静态护栏)。
  4. 部署脚本 zd-host.sh(审计连带项)

    • 历史条数探测抽成 human_history_count():无 token / 非 200 一律输出「未知」,不再假报 0;
    • /tmp 明文令牌 env 文件:trap ... EXIT + fail() + 成功路径三处必删;
    • 令牌轮换只打印长度;
    • 新增 deploy/zd-host_test.sh(26 项断言)。
  5. README:令牌默认值语义改为 fail-closed,登记两个本地调试开关。

复核

commondev 三组判据(正向 + 反向变异)全部 pass,变异 7/7 全部咬住。

判据 正向 变异
history-server 鉴权 fail-closed pass 2/2 咬住
网关仅显式 insecureTls 关校验 pass 2/2 咬住
部署脚本不假报/必删 env pass 3/3 咬住

未做

审计里的中低风险项(/nav-visit 写放大、登录无失败限制、CORS 星号、SVG 上传、session 密钥退化、DNS 重绑定时间差、0644 权限)不在本 PR 范围,留待后续。

## 修了什么 1. **history-server 鉴权 fail-closed**(审计高危①) - `requireToken`:`NAVI_SYNC_TOKEN` 未配置时不再放行,一律 401; - 新增 `requireAdminToken` / `isTruthy` / `jsonErr` / `logOnce`,3 处调用点改为 admin 语义; - 新增 `history-server/auth_test.go`(8 用例)。 2. **网关 https 上游不再无条件关闭证书校验**(审计高危②) - `admin-server/gateway.go`:只有上游显式声明 `insecureTls` 才 `proxy_ssl_verify off`; - 主 location 与 favicon 段两条路径分别钉住; - 新增 `gateway_tls_verify_test.go`(6 用例)。 3. **admin-server requireToken 去掉 fail-open 死分支**(高危①的另一半) - `expected == ""` 时返回 401 + JSON 修复指引,绝不 `return true`; - 新增 `token_failclosed_test.go`(含跨仓库静态护栏)。 4. **部署脚本 zd-host.sh**(审计连带项) - 历史条数探测抽成 `human_history_count()`:无 token / 非 200 一律输出「未知」,不再假报 0; - `/tmp` 明文令牌 env 文件:`trap ... EXIT` + `fail()` + 成功路径三处必删; - 令牌轮换只打印长度; - 新增 `deploy/zd-host_test.sh`(26 项断言)。 5. **README**:令牌默认值语义改为 fail-closed,登记两个本地调试开关。 ## 复核 commondev 三组判据(正向 + 反向变异)全部 **pass**,变异 7/7 全部咬住。 | 判据 | 正向 | 变异 | |---|---|---| | history-server 鉴权 fail-closed | pass | 2/2 咬住 | | 网关仅显式 insecureTls 关校验 | pass | 2/2 咬住 | | 部署脚本不假报/必删 env | pass | 3/3 咬住 | ## 未做 审计里的中低风险项(`/nav-visit` 写放大、登录无失败限制、CORS 星号、SVG 上传、session 密钥退化、DNS 重绑定时间差、0644 权限)**不在本 PR 范围**,留待后续。
jianf added 5 commits 2026-10-22 23:42:32 +08:00
审计发现的方式与 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。
审计第 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 次;恢复后全绿。
两处都是「看起来成功、实际给了错误信息/留下凭据」的问题。

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-<stamp>.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。
审计第 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。
第 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 生效)。
Checking for merge conflicts ...
View command line instructions

Checkout

From your project repository, check out a new branch and test the changes.
git fetch -u origin fix/audit-high-priority:fix/audit-high-priority
git checkout fix/audit-high-priority
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jianf/HomeDash#1
No description provided.