外观
内容安全策略(CSP)
两个站发 CSP:控制台(Cloudflare Worker 注入 nonce)与授权界面(nginx sub_filter 注入 nonce)。门户与首页当前不发 CSP。
事实源:apps/control/worker/index.js(控制台)与部署机上的 docker/nginx/conf.d/auth-natlan.conf(授权页,不在版本库里)。
1. 为什么必须是 nonce,不能是哈希
哈希方案试过并在 2026-08-09 撤回。它输给的是 Cloudflare 边缘在构建之后对 HTML 做的 两件事:
- Auto Minify (HTML) 会改写内联脚本的字节,哈希随之失配;
- Rocket Loader 会注入它自己的内联脚本,并改写页面上的
<script>标签。
nonce 对字节改写免疫,因此解决了第一条。但 Rocket Loader 仍然必须对该 zone 关闭 ——它会把 <script> 整个改写掉,并注入一个没有 nonce 的脚本,nonce 方案同样是死路。
重开或调整 CSP 前,先确认 zone 的 Auto Minify(HTML) 与 Rocket Loader 都是 off。
第二个绕不开 nonce 的理由来自 Cap:它把服务端下发的 instrumentation 挑战放在 sandbox="allow-scripts" 的 srcdoc iframe 里内联执行。srcdoc 继承父文档策略, 而 'self' 匹配不上内联脚本,只有 nonce 放得行。见 internals/captcha.md。
2. nonce 的两条投递路径
| 站 | 注入者 | nonce 来源 | HTML 侧落点 |
|---|---|---|---|
| control | Worker(HTMLRewriter) | crypto.getRandomValues(16) → 十六进制 | 给 <head> 前置一段 window.CAP_SCRIPT_NONCE=…,并给页面上每个 <script> 盖 nonce 属性 |
| auth | nginx sub_filter | $request_id | index.html 里两处 __CSP_NONCE__ 占位符(script 标签属性 + 那行赋值),故需 sub_filter_once off |
nonce 必须是十六进制。 它也会被写进 HTML 属性,而 Cap 的 shadow-DOM <style nonce=…> 属性不带引号,base64 的 = padding 会把属性截断。
两处都必须保证「响应头里的值」与「页面里的值」完全相同,且逐请求不同。因此:
- 控制台的 Worker 对 HTML 响应设
Cache-Control: no-store,并在转发前删掉If-None-Match/If-Modified-Since——一个 304 会让浏览器手里那份旧 HTML 的 nonce 与即将下发的 CSP 头对不上; - 授权页的
location = /authorize同样带Cache-Control: no-store; - 授权页所在的 nginx 不能开
gzip_static:sub_filter改不动预压缩文件,占位符 会原样发给浏览器。
3. 控制台策略
default-src 'self';
script-src 'self' 'nonce-<hex>' 'unsafe-eval' https://static.cloudflareinsights.com;
worker-src 'self' blob:;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self' https://api.natlan.io https://legacy.api.natlan.io
https://control-server.ngworks.org https://challenges.natlan.io
https://cloudflareinsights.com;
frame-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'逐条理由:
| 指令片段 | 理由 |
|---|---|
'unsafe-eval' | Cap 的 instrumentation 挑战用 eval / Function。缺失 = 登录不可用,且主文档一条违规都不报(见下方「故障形态」)。它同时覆盖 PoW 解题器 WebAssembly.compile() 需要的 'wasm-unsafe-eval' |
static.cloudflareinsights.com | 边缘注入的分析 beacon。Cloudflare 见到 nonce 时会把它复制到 beacon 上,因此这一条是保险而非承重 |
worker-src blob: | Cap 的解题 worker 池从 Blob URL 启动 |
style-src 'unsafe-inline' | 项目自己的内联 style 属性(app.html 的包裹层、少数组件)。已与 Cap 无关——它的界面现在是普通组件、走外链 stylesheet |
frame-src 'self' | Cap 的 instrumentation iframe 是 about:srcdoc,它继承本策略而非被 frame-src 匹配 |
frame-ancestors | 只能经响应头投递,<meta> 形式的 CSP 会忽略它 |
connect-src 是唯一随机群变动的指令。 它是控制服务器的允许清单:登录页的 「API Token」方式允许运维输入任意控制服务器地址,但在本策略下只有清单里的主机可达 ——指向新控制面必须同步改 apps/control/worker/index.js。
'unsafe-eval' 是这条策略最弱的一环
这是明知的取舍。注入仍然拦得住:script-src 里没有 'unsafe-inline',攻击者拿不到 逐请求 nonce 就落不下脚本。放弃的是对 eval 型 gadget 的第二道防线。
收回这一格的路径已知:把挑战脚本挪进一个自带宽松策略的同源空白页,用 postMessage 把脚本送进去。需要重写 runInstrumentationChallenge(),属于独立的一次改造。
4. 授权界面策略
与控制台同构,差别只在两处:connect-src 只需要同源与 Cap(认证服务同源), 且 nonce 由 nginx 而非 Worker 注入。
default-src 'self';
script-src 'self' 'nonce-$request_id' 'unsafe-eval' https://static.cloudflareinsights.com;
worker-src 'self' blob:;
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
font-src 'self';
connect-src 'self' https://challenges.natlan.io https://cloudflareinsights.com;
frame-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none'对应的 nginx 片段与发布顺序见 guides/deploy-auth.md。
5. 静态资产不过 Worker
控制台的 wrangler.jsonc 里:
jsonc
"run_worker_first": ["/*", "!/_app/*"]文档必须经过 Worker 才能拿到 nonce;带内容哈希的构建产物不必,让它们由资产服务 直供、完全跳过 Worker 调用。
6. 故障形态速查
| 现象 | 原因 |
|---|---|
验证码转圈 20 秒后超时,Console 无任何 Refused to … | script-src 缺 'unsafe-eval'。违规事件发生在 sandbox iframe 那个 opaque origin 的子文档里,主文档看不到 |
页面源码里能看到 __CSP_NONCE__ 原文 | nginx 的 sub_filter 没生效:模块缺失、gzip_static 开着,或改了 nginx 但产物是旧的 |
| 头里的 nonce 与页面里的不一致 | 响应被缓存了,no-store 未生效 |
| 三次请求的 nonce 相同 | 同上 |
| 脚本被拦,且页面上出现来路不明的内联脚本 | Rocket Loader 未关闭 |
排查流程见 guides/troubleshoot-csp.md。