fix: 审计高危项修复(history 鉴权 fail-closed / 网关 TLS 校验 / 部署脚本) #1
Reference in New Issue
Block a user
No description provided.
Delete Branch "fix/audit-high-priority"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
修了什么
history-server 鉴权 fail-closed(审计高危①)
requireToken:NAVI_SYNC_TOKEN未配置时不再放行,一律 401;requireAdminToken/isTruthy/jsonErr/logOnce,3 处调用点改为 admin 语义;history-server/auth_test.go(8 用例)。网关 https 上游不再无条件关闭证书校验(审计高危②)
admin-server/gateway.go:只有上游显式声明insecureTls才proxy_ssl_verify off;gateway_tls_verify_test.go(6 用例)。admin-server requireToken 去掉 fail-open 死分支(高危①的另一半)
expected == ""时返回 401 + JSON 修复指引,绝不return true;token_failclosed_test.go(含跨仓库静态护栏)。部署脚本 zd-host.sh(审计连带项)
human_history_count():无 token / 非 200 一律输出「未知」,不再假报 0;/tmp明文令牌 env 文件:trap ... EXIT+fail()+ 成功路径三处必删;deploy/zd-host_test.sh(26 项断言)。README:令牌默认值语义改为 fail-closed,登记两个本地调试开关。
复核
commondev 三组判据(正向 + 反向变异)全部 pass,变异 7/7 全部咬住。
未做
审计里的中低风险项(
/nav-visit写放大、登录无失败限制、CORS 星号、SVG 上传、session 密钥退化、DNS 重绑定时间差、0644 权限)不在本 PR 范围,留待后续。审计发现的方式与 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 生效)。View command line instructions
Checkout
From your project repository, check out a new branch and test the changes.