Skip to content

排查 CSP 与人机验证故障

这类故障的共同特点是症状离原因很远:最常见的一种在浏览器控制台里一条错误都不报。 按本表对症下药,不要凭直觉猜。

策略全文见 reference/csp.md,组件侧见 internals/captcha.md

症状 → 原因速查

症状最可能的原因跳到
验证码转圈 20 秒后超时,Console 无任何 Refused to …script-src'unsafe-eval'A
页面源码里能看到 __CSP_NONCE__ 原文nginx sub_filter 没生效B
响应头 nonce ≠ 页面 nonce响应被缓存C
三次请求 nonce 相同同上C
Console 报 Refused to load … cdn.jsdelivr.net用了 npm 版 Cap 产物D
Console 报被拦的、来路不明的内联脚本Rocket Loader 未关E
登录页正常但控制台内某处功能连不上后端connect-src 未含该主机F

A. 验证码静默超时

这是最难查的一种,因为主文档一条违规都不报。

Cap 的 instrumentation 挑战跑在 sandbox="allow-scripts" 的 srcdoc iframe 里, 那是个 opaque origin 的子文档——违规事件发生在那里,主文档看不到。表现只有一个: 控件转 20 秒然后报超时。

确认

bash
curl -sI https://auth.natlan.io/authorize | grep -io "script-src[^;]*"

script-src 里有没有 'unsafe-eval'只给 'wasm-unsafe-eval' 不够—— PoW 解题器确实只需要后者,但 instrumentation 脚本用 eval / Function

要直接看到那条违规,在 DevTools 里把 iframe 选作执行上下文,会看到 securitypolicyviolation script-src eval 紧接着 Instrumentation timeout

:把 'unsafe-eval' 加回 script-src。控制台改 apps/control/worker/index.js,授权页改 nginx 的 add_header Content-Security-Policy


B. 占位符没被替换

只影响授权界面(控制台的 nonce 由 Worker 注入,没有占位符环节)。

bash
curl -s https://auth.natlan.io/authorize | grep -c __CSP_NONCE__
# 常态应为 0;出现 2 说明 sub_filter 没生效

逐条排查:

  1. nginx 有没有 http_sub_module

    bash
    docker compose -p docker-compose exec nginx nginx -V 2>&1 | tr ' ' '\n' | grep sub_module
  2. gzip_static 是不是开着 —— sub_filter 改不动预压缩文件。

  3. sub_filter_once 是不是 off —— index.html 里有两处占位符, once on(默认)只会替换第一处。

  4. 产物是不是旧的 —— 反过来,如果占位符本来就不在产物里,那是前端侧的问题: index.html 的占位符被删过。这正是迁往 Worker 那次留下的教训。

    bash
    grep -c __CSP_NONCE__ apps/auth/dist/index.html   # 必须是 2

C. nonce 不一致或不变

bash
curl -s -D /tmp/h.txt https://auth.natlan.io/authorize -o /tmp/b.html
grep -o "nonce-[a-f0-9]*" /tmp/h.txt | head -1
grep -o 'nonce="[a-f0-9]*"' /tmp/b.html | sort -u

for i in 1 2 3; do curl -sI https://auth.natlan.io/authorize | grep -io "nonce-[a-f0-9]*"; done

两个值必须相同,三次请求必须互不相同。

原因几乎总是缓存:nonce 让每份文档唯一,缓存任何一份都会钉死一个过期 nonce。

  • 授权页:location = /authorize 必须有 add_header Cache-Control "no-store" always
  • 控制台:Worker 对 HTML 响应设 no-store,并且在转发给资产服务前删掉If-None-Match / If-Modified-Since——一个 304 会让浏览器继续用旧 HTML, 而 CSP 头是新的。

另一种可能:nonce 不是十六进制。base64 的 = padding 会截断不带引号的 HTML 属性, 表现为页面里的 nonce 比头里的短。


D. 出现 jsdelivr 请求

说明用回了 npm 发布版的 Cap 产物——它运行时会去 CDN 拉 WASM 解题器和 pako 兜底。

bash
grep -rl jsdelivr apps/auth/dist/    # 必须无输出
grep -r jsdelivr ui/vendor/cap       # 必须无结果

:确认导入的是 ui/vendor/cap,不是 @cap.js/widget。若刚重新 vendor 过, 按 upgrade-cap-widget.md 把本地改动重新打回去。


E. 来路不明的内联脚本

Rocket Loader 会改写页面上的 <script> 标签,并注入一个属于它自己的、没有 nonce 的 内联脚本。nonce 方案对字节改写免疫,但对标签被整个改写无能为力。

:在 natlan.io zone 上关闭 Rocket Loader。顺带确认 Auto Minify (HTML) 也是 off——它是历史上打掉哈希方案的元凶,虽然对 nonce 无害。


F. connect-src 拦掉了控制服务器

只影响控制台。登录页的「API Token」方式允许运维输入任意控制服务器地址, 但 CSP 的 connect-src 是一份固定允许清单

bash
curl -sI https://console.natlan.io/ | grep -io "connect-src[^;]*"

:把新主机加进 apps/control/worker/index.jsconnect-src 并重新部署。 这是有意的设计——地址可以输,但可达的主机由部署方控制。


通用手法

在真实浏览器里复现,不要只看 curl。 ④ 类验收(走完整授权流 / 登录流)自动化替代 不了:只有真的解一次题才知道验证码是否可用。

注意区分本站与浏览器扩展的报错。 content_main.js 之类的报错来自扩展,与站点无关。

回滚永远是先手。 授权页只需删掉 nginx 里的三行;控制台回退 Worker 到上一版部署。 带着故障排查比带着故障服务用户便宜。