
HTML 里的 <script nonce="…"> 还写着上一份值,响应头 Content-Security-Policy 已经换成新值,页面脚本便不再符合策略。这个错配可以由缓存层拼出来:HTML 被缓存,nonce 却在每次响应时重新生成。
一次未命中,一次命中
nonce 需要随机且不可预测,通常在服务端为每份 HTML 响应生成,再同时写进 CSP 响应头和允许执行的脚本标签。两处是一对值。只更新其中一处,浏览器就会把带旧 nonce 的内联脚本当成不匹配项。
我在本机搭了一个最小 HTTP 服务来复现缓存边界,不连接浏览器代理或 CDN。它将首次生成的 HTML 正文留在内存缓存;每个请求仍生成一份新的策略头。测试用固定的 nonce-A 和 nonce-B 代替随机值,目的是让差异可读,不是推荐固定 nonce。
| 请求 | HTML 缓存 | CSP 头中的 nonce | HTML 脚本的 nonce | 是否一致 |
|---|---|---|---|---|
| 第一次 | MISS | nonce-A | nonce-A | 是 |
| 第二次 | HIT | nonce-B | nonce-A | 否 |
第一行说明生成逻辑本身能把同一个值写进两处。第二行才是问题:缓存命中的正文没重新渲染,头部却走了新请求的 nonce。只看 200 或只检查 CSP 头都可能漏掉它,必须把同一响应的头和正文并排核对。
复现程序在 tools/verify-fullstack-csp-nonce-cache.mjs。用本机 Node.js 运行后,原始日志如下:
v26.8.1
server: 127.0.0.1:56833
request 1: cache=MISS, CSP nonce=nonce-A, HTML nonce=nonce-A, match=true
request 2: cache=HIT, CSP nonce=nonce-B, HTML nonce=nonce-A, match=false
assertion: PASS (cached HTML nonce and fresh CSP nonce diverge)
端口由系统分配,下一次运行会不同。脚本断言第一次一致,第二次命中且不一致;它验证的是响应构造与缓存模型,不是浏览器执行拦截,也没有接入真实共享缓存。浏览器会依据 CSP 检查脚本 nonce 是否匹配,实验没有把这一步伪装成测过。
缓存的边界要与 nonce 对齐
最直接的处理是不要共享缓存含有每请求 nonce 的 HTML:例如让动态 HTML 使用 Cache-Control: private, no-store,或者让页面渲染与策略头作为同一个不可拆分的缓存对象。具体缓存指令仍要按页面是否含个性化内容决定,不能把 no-store 当作所有静态页面的通用答案。
如果系统确实要缓存 HTML,就不能在缓存命中时独立换策略 nonce。把 nonce 在缓存对象的生成阶段同时写入正文和对应响应头,能避免两处失配;但整份响应被共享缓存复用时,nonce 也会跟着重复交付,不能再称为每次响应新生成。若部署要求 nonce 每次响应都新鲜,就应避免共享缓存这类 HTML,或在边缘层整体重新渲染并同步改写头与正文。缓存 TTL 和失效规则要与安全要求一并设计,不能只看页面是否还新。
另一条路是减少对内联脚本 nonce 的依赖,把脚本移到带内容哈希的静态文件,并用 CSP 哈希或受限的来源策略授权。迁移涉及构建产物和脚本内容,适合能稳定控制部署流程的站点;含动态内联代码时不能只删掉 nonce 就算完成。
排查时记录同一请求拿到的 CSP 头、HTML 中每个受策略保护脚本的 nonce,以及缓存命中状态。若头和正文来自不同层,检查它们是否共享同一缓存键、同一更新周期。这次本机实验只构造了一个内存缓存和两次请求,未覆盖 CDN 的重验证、边缘缓存规则或浏览器控制台表现;这些结果需要在目标部署链路单独验证。判断原则不变:nonce 不是头部上的孤立随机串,响应策略和 HTML 必须作为一组交付。