Skip to content

内容安全策略(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 侧落点
controlWorker(HTMLRewritercrypto.getRandomValues(16) → 十六进制<head> 前置一段 window.CAP_SCRIPT_NONCE=…,并给页面上每个 <script>nonce 属性
authnginx sub_filter$request_idindex.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_staticsub_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