React 安全实践:CSP nonce 与锁不住的样式
译注:本文编译自 dev.to 作者 Abrar Qasim 的个人实践记录,原文标题为《React Security Best Practices: CSP Nonces and the Style I Couldn't Lock Down》,原文链接见文末。文章以 pgAdmin 4 v9.18 的发布说明为引子,讲述作者如何把 React 应用的 CSP 从
'unsafe-inline'迁移到 per-request nonce,以及为什么style-src-attr最终仍要保留'unsafe-inline'。
从一份发布说明说起
作者坦言,过去三年里他发布的每个 React 应用都带着一份从 Stack Overflow 抄来的 CSP 响应头,第二条指令就是 'unsafe-inline'。他很清楚这条指令几乎关掉了 CSP 的大部分意义,但总想着"上线后再修"——上线之后,就再也没修。
真正促使他动手的,是上周读到 pgAdmin 4 v9.18 的发布说明。pgAdmin 是一个 React + MUI 应用,更新日志里描述的正是他一直在回避的迁移:内联脚本改为在 per-request nonce 下运行,不再使用一刀切的 'unsafe-inline',同时移除了 'unsafe-eval'。但紧接着有一行让他会心一笑——style-src 仍然保留 'unsafe-inline',理由是"MUI 和 React 会注入运行时样式和内联 style 属性,这些无法携带 nonce"。
连比作者更有理由在意安全的 pgAdmin 团队,也只锁住了脚本、放弃了样式。这让他重新理解了这件事:CSP 不是全有或全无,而是两部分问题,其中一部分远比另一部分容易。
nonce 到底买到了什么
CSP 是一个响应头,告诉浏览器允许从哪些来源执行脚本和样式。React 应用的麻烦在于:构建产物通常在 index.html 里至少有一个内联 <script>,而 CSS-in-JS 库会在运行时注入 <style> 标签。偷懒的做法是用 'unsafe-inline' 放行,但这等于放行所有内联脚本,包括攻击者通过未转义字段注入的那一个。
nonce 是替代方案。服务器每次请求生成一个随机值,以 'nonce-abc123' 形式写进响应头,并给每个打算执行的 <script> 标签打上同样的值。任何没有这个精确 nonce 的内联内容都会被拦截。注入的脚本无法猜到它,因为每次响应都不同。MDN 的 CSP 参考因此反复强调:nonce 必须不可预测,且每次响应都要重新生成。
作者过去发布的响应头大致如下:
Content-Security-Policy: default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;而他最终落到的版本是:
Content-Security-Policy: default-src 'self';
script-src 'self' 'nonce-{NONCE}';
style-src-elem 'self' 'nonce-{NONCE}';
style-src-attr 'unsafe-inline';
img-src 'self' data:;
object-src 'none';
base-uri 'self';其中最有意思的一行是 style-src-attr,后文会解释它为何仍在。
脚本:找到 Vite 选项后就简单了
作者过去屡屡放弃,是因为他以为需要自己写插件把 nonce 贯穿整个构建流程。其实不用。Vite 提供了配置项 html.cspNonce,设置后 Vite 会给它产出的每个 <script>、<style> 和样式表 <link> 加上 nonce 属性,并额外输出一个 <meta property="csp-nonce"> 标签,供它后续注入的内容内部使用。
// vite.config.ts
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
export default defineConfig({
plugins: [react()],
html: {
cspNonce: "__CSP_NONCE__",
},
});这个值只是占位符,不是真正的 nonce。构建是静态的,无法知道每次请求的值,必须由服务器在每次响应时替换。Vite 文档专门为此加了警告框:如果把占位符当成真 nonce 发布,它就变成了常量,攻击者可以直接从页面里读出来,等于精心打造了一个 'unsafe-inline'。
作者在反向代理层做替换。Caddy 的写法很简短:
example.com {
root * /srv/app/dist
file_server
@html path / /index.html
handle @html {
header Content-Security-Policy "default-src 'self'; script-src 'self' 'nonce-{http.request.uuid}'; style-src-elem 'self' 'nonce-{http.request.uuid}'; style-src-attr 'unsafe-inline'; img-src 'self' data:; object-src 'none'; base-uri 'self'"
templates
}
}这只是示意,不能直接照搬。Caddy 的 templates 指令可以对 HTML 正文做字符串替换,{http.request.uuid} 提供 per-request 随机值。实际中作者在静态文件前放了一个很小的 Go handler,因为他希望用 crypto/rand 生成 nonce,并把替换集中在一处以便单元测试。无论哪种方式,形态都一样:每次请求生成一个随机值,同时写进响应头和 HTML。
移除 'unsafe-eval' 是零成本的,生产包中没有任何东西需要它。pgAdmin 的说明提到,当 DEBUG 设置时他们会为开发包自动加回该指令,作者照搬了这个思路:开发服务器用更宽松的响应头,生产环境不用。他过去的错误是用同一个响应头,让开发需求泄漏到了生产。
样式:理解 style-src-attr 的代价
这是作者卡住的地方,也是 pgAdmin 更新日志比大多数教程更诚实的地方。
MUI 使用 Emotion 在运行时注入 <style> 元素,这些元素可以携带 nonce。Emotion 的 cache 接受 nonce,MUI 的 CSP 指南给出了配置方法:从 Vite 输出的 meta 标签读取 nonce,交给 cache。
// main.tsx
import createCache from "@emotion/cache";
import { CacheProvider } from "@emotion/react";
const nonce =
document.querySelector<HTMLMetaElement>('meta[property="csp-nonce"]')
?.nonce ?? "";
const cache = createCache({ key: "mui", nonce });
createRoot(document.getElementById("root")!).render(
<CacheProvider value={cache}>
<App />
</CacheProvider>
);作者照做后重新加载,控制台仍然刷满 CSP 违规。每一条都是 style 属性,而不是 <style> 元素。这正是他此前没搞清的区分:CSP level 3 把 style-src 拆成了 style-src-elem(针对 <style> 标签和样式表链接)和 style-src-attr(针对元素上的 style="..." 属性)。nonce 只能附加在元素上,属性上根本没有地方放。因此任何渲染 style={{ width: 240 }} 的组件——大量 MUI 内部实现和作者自己的代码——都会产生一个 nonce 永远无法授权的内联 style 属性。
MUI 指南直言:style-src-elem 接受 nonce,style-src-attr 需要 'unsafe-inline',因为某些组件会为尺寸、定位等动态值设置内联样式。pgAdmin 的更新日志用更少的字说了同样的事。作者花了两个晚上,试图让 nonce 去做规范不允许的事。
对于属性,诚实的选择只有两个:把 'unsafe-inline' 限定在 style-src-attr,或者用 'unsafe-hashes' 加上应用可能产生的每一个内联样式值的哈希——当值在运行时计算时,这并不现实。作者选了前者。这比最初的洞小得多:能注入标记的攻击者可以设置内联样式,从而实现一些难看的 UI 重绘攻击,但无法执行脚本,而脚本才是真正让他睡不着的东西。
那些不是自己写的违规
响应头变严之后,浏览器开始报告一些他读自己代码永远发现不了的东西。其中两个很突出。
一年前粘进 index.html 的第三方分析代码片段是一个没有 nonce 的内联脚本,被拦截了。作者把它移到自有来源的外部文件,并在标签上加了 nonce;顺手发现它还在从某个从未听过的域名加载第二个脚本,于是直接删除而非加 nonce。
另一个是小型图表组件用 new Function() 求值来自 props 的格式化字符串。这是披着外衣的 eval,已经在包里躺了好几个月。移除 'unsafe-eval' 立刻让它现形。作者把它重写成了普通的函数映射,本来就该如此。
这两者都不是真正的漏洞利用,但都属于严格策略本该捕获的类别,而作者发现它们的唯一原因,是策略不再只是装饰。他做过 Laravel 应用的安全审计,CSP 总是第一个检查项,却在 React 这边悄悄让自己的清单不及格。
如何不搞崩生产地推进
作者没有一次性给所有人开启严格响应头。Content-Security-Policy-Report-Only 正是为此存在:它评估策略、把违规上报到指定端点,但不拦截任何东西。他用 report-to 指向一个追加日志文件的小端点,跑了一周,然后读日志。
日志头两天很吵,大多来自浏览器扩展注入自己的脚本,这类违规你无能为力。过滤掉之后,真正的清单就是上面两项,外加一些旧代码里的内联事件处理器。随后他把响应头从 report-only 切换为强制执行,保留上报端点,继续推进。
他至今不确定 img-src 是否配对了。他允许 data:,因为 Vite 默认会把小资源内联为 data URI,Vite 文档建议要么允许它,要么把 build.assetsInlineLimit 设为 0。他选择了允许,但可能会改变主意。
本周可以做什么
给你在跑的一个 React 应用加上 Content-Security-Policy-Report-Only,其中 script-src 'self' 'nonce-...' 且不含 'unsafe-inline',把 report-to 指向任何能写文件的端点,然后放几天。先别修任何东西,只读报告。作者的报告里有两样他不知不觉发布了一年的东西,发现它们只花了读日志的时间。