安全
Content-Security-Policy 详解:指令、unsafe-inline、nonce 与稳妥上线
CSP 是功能最强的安全响应头,也是最容易配错的一个。本文介绍真正重要的指令、nonce 和哈希如何取代 'unsafe-inline',以及一套不会弄坏网站的上线方案。
本页内容
Content-Security-Policy (CSP) 是一个响应头,用来告诉浏览器页面可以从哪些地方加载脚本、样式、图片、框架和其他资源。它的主要职责是控制损失:如果攻击者利用跨站脚本 (XSS) 漏洞往你的页面里注入了一个 <script>,一份好的策略能阻止浏览器运行它。
问题在于,“有 CSP”和“有一份好的 CSP”完全是两回事。一个满是通配符的响应头能通过“是否存在”的检查,却几乎什么都保护不了。本指南将说明如何分辨二者。
策略是怎样构成的
一份策略由若干条用分号隔开的指令组成。每条指令指定一种资源类型,以及允许它使用的来源:Content-Security-Policy: default-src 'self'; img-src 'self' https://images.example-cdn.com; object-src 'none'。
来源可以是 'self'(你自己的源)、https://js.stripe.com 这样的具体的源、https: 或 data: 这样的协议、'none' 这样的关键字,也可以是下文会讲到的 nonce 和哈希。关键字要写在单引号里,源则不用。
最重要的几条指令
default-src:大多数未单独列出的资源类型所使用的兜底规则。script-src:脚本可以来自哪里。真正防御 XSS 的就是这条指令,所以最值得花心思。style-src、img-src、font-src、connect-src(fetch、XHR、WebSockets)、frame-src和media-src:其他资源类型。object-src 'none':禁用<object>和<embed>等插件,这是一条老旧但至今仍常被提及的脚本执行途径。base-uri 'self'或'none':阻止被注入的<base>标签改变相对脚本网址的指向。frame-ancestors:哪些网站可以用框架嵌入你的页面,是 X-Frame-Options 的现代替代方案。form-action:表单可以提交到哪里。upgrade-insecure-requests:要求浏览器通过 HTTPS 加载http://子资源。report-to/report-uri:浏览器把违规报告发送到哪里。
哪些写法会削弱策略
'unsafe-inline'
在 script-src 中,'unsafe-inline' 会放行所有内联的 <script> 块和 onclick 这类内联事件处理程序。XSS 攻击注入的恰恰就是这些东西,所以脚本策略里带有 'unsafe-inline' 的 CSP 几乎拦不住它。在支持 nonce 或哈希的浏览器中,只要策略里有 nonce 或哈希,'unsafe-inline' 就会被忽略,因此有时会把它和 nonce 一起保留,作为照顾极旧浏览器的后备。
'unsafe-eval'
'unsafe-eval' 会放行 eval()、new Function(),以及传给 setTimeout 的字符串参数。它把数据变成了代码,这很危险。一些较老的库和模板引擎需要它;现代的构建产物通常不需要。
过于宽泛的来源
script-src 中的 *、https:、http: 或 data: 实际上允许从任何地方加载脚本,攻击者可以把自己的脚本放在任意一台 HTTPS 服务器上。热门 CDN 的问题则更隐蔽:放行整个公共 CDN 主机,攻击者就能加载托管在那里的任何库,包括存在已知绕过方法的旧版本。
只用 Report-Only
Content-Security-Policy-Report-Only 只报告违规,什么都不拦截。从它起步是对的,止步于它就错了。
nonce 与哈希:摆脱 'unsafe-inline' 的出路
nonce 是服务器为每个响应生成的一个随机值。你把它写进响应头(script-src 'nonce-R4nd0mV4lue'),同时写在你信任的每个 script 标签上:<script nonce="R4nd0mV4lue">。浏览器只运行带有匹配 nonce 的脚本。被注入的脚本不知道这个值,所以会被拦截。nonce 必须不可预测,并且每个响应都不一样;固定不变的 nonce 并不比 'unsafe-inline' 强。
哈希则根据内联脚本内容的 SHA-256、SHA-384 或 SHA-512 哈希值,放行某一段特定的内联脚本:script-src 'sha256-…'。哈希适合那些不便为每个响应生成 nonce 的静态页面;脚本有任何改动,哈希值都会随之变化。
加上 'strict-dynamic' 后,你用 nonce 或哈希信任的脚本就可以继续加载其他脚本,同时支持该特性的浏览器会忽略主机白名单。这让所谓的严格 CSP 即使在使用代码管理器和各种小组件的网站上也切实可行:script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'。
看看你的 CSP 实际允许什么
Rudra 的免费安全检测工具会逐条指令解析你的 Content-Security-Policy,并标出 'unsafe-inline'、'unsafe-eval'、过于宽泛的脚本来源,以及缺失的 object-src 或 base-uri。
一套不会弄坏网站的上线方案
- 清点页面加载了什么。在关键页面(首页、登录页、结账页、带嵌入内容的页面)上打开开发者工具,记下每一个脚本、样式、字体、框架和 API 的源。
- 发送一份 Report-Only 策略。可以先在
Content-Security-Policy-Report-Only中使用类似default-src 'self'; script-src 'self' 'nonce-{random}'; object-src 'none'; base-uri 'self'这样的策略。违规情况会显示在浏览器控制台中;如果你设置了上报端点,也会发送到那里。 - 逐一修复或放行每项违规。把内联脚本移到文件中或给它们加上 nonce,用
addEventListener替换内联的onclick处理程序,并添加你实际用到的具体第三方源。 - 强制执行。当关键流程中不再出现违规报告时,把响应头名称改为
Content-Security-Policy。你可以同时继续发送一份更严格的 Report-Only 策略,用来测试下一步。 - 持续维护。每新增一个小组件、统计分析工具或支付服务商,都需要调整策略。添加之后记得复查。
尽量在服务器、CDN 或框架层面设置这个响应头,而不是写在 <meta> 标签里:meta 标签形式的策略无法使用 frame-ancestors、上报功能和 Report-Only 模式。
Rudra 会检查你策略中的哪些内容
免费网站安全检测工具会读取你的页面实际发送的 CSP,并把解析后的指令作为依据展示出来。它会标出以下情况:没有策略;策略只以 Report-Only 形式发送;策略完全没有限制脚本(既没有 script-src 也没有 default-src);脚本策略中含有 'unsafe-inline'(存在 nonce 或哈希时不标记);含有 'unsafe-eval';含有 * 或 https: 等过于宽泛的脚本来源(除非你同时使用了 'strict-dynamic' 和 nonce 或哈希);此外还会以提示信息的形式指出缺少 object-src 'none'、缺少 base-uri 以及存在内联样式。只要 frame-ancestors 的值不是 * 或单独一个协议,它就会被认定为点击劫持防护。
检测工具能告诉你策略允许什么,却无法告诉你页面在这份策略下是否还能正常工作。这一点只有在仅报告模式下测试真实的使用流程才能知道。
常见问题
Content-Security-Policy 能防止 XSS 吗?
它修复不了底层的漏洞,但一份严格的策略能阻止大多数被注入的脚本运行,从而限制损失。你仍然需要转义输出、校验输入。
style-src 中的 'unsafe-inline' 是问题吗?
比起出现在 script-src 中,它的严重程度要低得多。被注入的样式在某些攻击中仍可能被滥用,所以去掉它是不错的加固措施,但应当优先处理脚本策略。
可以在每个页面上使用同一个 nonce 吗?
不可以。nonce 必须是随机的,并且每个响应都独一无二。固定或重复使用的 nonce 可以被攻击者复制,起不到任何保护作用。
强制执行之前,Report-Only 应该运行多久?
要足够覆盖你真实的流量模式,通常是一到两周,其中要包括登录、结账以及所有带第三方嵌入内容的页面。当报告中只剩下浏览器扩展这类噪音时,就可以强制执行了。