CSP 示例与上线流程

Content-Security-Policy 示例:如何为现代网站构建严格的 CSP

Content-Security-Policy 告诉浏览器哪些脚本可以在页面上运行。本文提供三个可用的示例策略,讲解让策略变严格的要素(nonce、hash 和 strict-dynamic)、default-src 覆盖不到的指令、违规报告,以及从 Report-Only 模式起步的分阶段上线方法。

概念插图:象牙色陶瓷门楼配有玻璃闸门,带着薄荷绿印章的玻璃球顺利通过并发光,一颗没有印章的灰色球体被拦在门外,旁边放着一枚铜印。
概念插图

Content-Security-Policy(CSP)是一个响应头,告诉浏览器页面可以使用哪些脚本、样式、图片、框架和连接。如果攻击者设法把脚本塞进你的 HTML,一份好的策略能让浏览器拒绝执行它。从论坛帖子里照搬的策略通常会走向两个极端:要么弄坏统计代码、网页字体和支付组件,要么被塞满 'unsafe-inline' 和通配符,最后几乎什么都放行。

本文为三类网站提供可用的示例,解释什么让策略变得严格(nonce、hash 和 'strict-dynamic'),介绍 default-src 覆盖不到的指令,演示如何收集违规报告,并给出从 Report-Only 模式开始的上线流程,避免在毫无预警的情况下弄坏页面。所有示例都使用 example.com,请替换为你自己的值。

本指南的适用范围

免费检测工具只读取一次公开响应的响应头:即你输入的源站首页(最多跟随五次重定向)。它只读取已生效的 Content-Security-Policy 头,不读取 Report-Only 策略,也不判断策略是否适合你的网站,不属于渗透测试。

1. Content-Security-Policy 的作用与局限

策略是由分号分隔的一组指令。每条指令指定一种资源类型及其允许的来源:script-src 管 JavaScript,style-src 管 CSS,另有 img-src、font-src,管 fetch、XHR 和 WebSocket 连接的 connect-src,以及管页面嵌入框架的 frame-src。当某条具体指令缺失时,default-src 是这些 fetch 类指令的后备(W3C CSP Level 3)。

有三条重要指令不会回退到 default-src:frame-ancestors(哪些站点可以嵌入你的页面)、base-uri(<base> 元素可以设置什么)和 form-action(表单可以提交到哪里)。因此,仅有 default-src 'self' 的策略仍然允许任何站点嵌入页面,也允许被注入的 <base> 标签改写相对 URL。

来源值分为三类:

  • 关键字,用单引号括起:'self'(页面自身的源)、'none'(什么都不允许)、'unsafe-inline'、'unsafe-eval' 和 'strict-dynamic'。
  • 主机和协议:https://cdn.example.com、https://*.example.com、https: 或 data:。
  • Nonce 和 hash:'nonce-…' 和 'sha256-…',它们只放行单个脚本或样式元素,而不是整个主机。

请通过 HTTP 响应头发送策略。<meta http-equiv="Content-Security-Policy"> 标签对大多数指令有效,但规范规定其中不支持 frame-ancestors、report-uri 和 sandbox,而 Report-Only 策略根本无法通过 meta 标签下发(MDN CSP 指南)。请保持合理预期:CSP 限制的是被注入代码能做什么,它不能取代对输出的转义和对输入的校验,后者才是从源头阻止注入的手段。

2. 三个可作为起点的 Content-Security-Policy 示例

示例 A:不含第三方脚本的网站。当所有脚本和样式表都是你自己源站上的文件时,白名单策略就够用:

Content-Security-Policy: default-src 'self'; img-src 'self' data:; object-src 'none'; base-uri 'self'; form-action 'self'; frame-ancestors 'self'; upgrade-insecure-requests

它会拦截所有内联 <script>、所有 style 属性以及从其他主机加载的内容,因此适合没有嵌入内容的手工搭建或静态生成的网站。

示例 B:使用 nonce 的服务器端渲染网站。这是 web.dev 为服务器逐请求生成的页面推荐的严格策略,并加上了防嵌套保护:

Content-Security-Policy: script-src 'nonce-R4nd0mBase64Value' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'

nonce 在每次响应中都会变化(见第 3 节)。注意它省略了什么:没有 default-src,样式、图片、字体和连接都不受限制。这是有意的取舍:它把重点放在脚本执行上(跨站脚本的大部分危害都出在这里),同时避免了供应商更换域名就会失效的主机列表。

示例 C:使用 hash 的静态或缓存页面。当同一份 HTML 提供给所有人时,改用 hash 放行内联脚本:

Content-Security-Policy: script-src 'sha256-BASE64_HASH_OF_INLINE_LOADER' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'
策略类型适用场景持续工作注意事项
A:主机白名单没有第三方代码的静态网站新增供应商时更新主机列表允许主机上的任何脚本都能被加载,包括共享 CDN 上的旧版本库
B:nonce + strict-dynamic由应用逐请求渲染的页面在模板中为每个 script 标签添加 nonce页面缓存会把同一个 nonce 重放给所有访客
C:hash + strict-dynamic静态、预渲染或边缘缓存的 HTML内联代码变化时重新计算 hash压缩工具和模板改变空白字符,从而改变 hash

无论选哪一种,都请加上第 6 节的报告指令,并先以 Content-Security-Policy-Report-Only 发送。

3. 用 nonce 和 hash 取代 'unsafe-inline'

'unsafe-inline' 会放行所有内联脚本,包括攻击者注入的脚本,这就抵消了 CSP 针对跨站脚本的大部分防护。Nonce 和 hash 则只放行你本来就打算发布的内联代码。当同一指令中包含 nonce 或 hash 时,浏览器会忽略 'unsafe-inline'(MDN),因此严格策略中可以保留它,作为非常老旧浏览器的后备。

Nonce

nonce 是服务器为每次响应生成的随机值,写入响应头,并复制到每个要运行的脚本的 nonce 属性中。它必须不可预测,且每次响应都要更新;web.dev 建议使用加密安全的生成器产生至少 128 位。在 Node.js 应用中:

import crypto from 'node:crypto';

app.use((req, res, next) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;
  res.setHeader('Content-Security-Policy',
    `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self'`);
  next();
});

模板随后以相同的值写出 <script nonce="…" src="/app.js"></script>。请在模板中写入你自己脚本标签的位置添加 nonce,不要对最终生成的 HTML 中的每个 <script> 统一改写:一刀切的改写会连攻击者注入的脚本也一并放行。有两个常见陷阱:缓存 HTML 的 CDN 或页面缓存会把同一个 nonce 重放给所有访客;在构建时写死到静态文件里的值根本不是 nonce。

Hash

hash 放行的是某一段确切的内联脚本:即 script 标签之间文本的 base64 编码 SHA-256、SHA-384 或 SHA-512 摘要。每个字符都算数,包括空格和换行,因此压缩工具或模板一改,hash 就会变。这使得 hash 更适合静态和缓存页面,因为每次响应的内容都相同。Chrome 在拦截内联脚本时会在控制台打印期望的 hash,你也可以自己计算:

printf '%s' 'console.log("hello")' | openssl dgst -sha256 -binary | openssl base64

两者都不放行的内容

基于 nonce 或 hash 的策略会拦截 onclick="…" 这类内联事件处理器和 javascript: URL。请把这些代码移到脚本文件中,并用 addEventListener 绑定。'unsafe-hashes' 可以按 hash 放行某个特定处理器,但只应视为权宜之计。把字符串当代码执行的写法,例如 eval()、new Function() 或带字符串参数的 setTimeout,需要 'unsafe-eval';能替换就替换,如果只是 WebAssembly 需要编译,可以使用范围更窄的 'wasm-unsafe-eval'(MDN script-src)。

4. strict-dynamic:让受信任的脚本加载它所需的内容

大多数网站运行的脚本会再加载更多脚本:标签管理器添加统计代码,同意横幅添加其供应商脚本,聊天组件拉取自己的包。把它们可能用到的所有主机都列出来很脆弱。'strict-dynamic' 走了另一条路:被 nonce 或 hash 放行的脚本可以再添加其他脚本,这些脚本同样被信任。

在支持它的浏览器中,'strict-dynamic' 还会让浏览器忽略 script-src 中的主机白名单、'self' 和 'unsafe-inline'(MDN)。由此产生三个后果:

  • HTML 中的每个 <script> 标签都需要 nonce 或匹配的 hash,包括来自你自己源站的脚本,因为 'self' 不再放行它们。
  • 受信任脚本通过 document.createElement('script') 创建的脚本会被允许;通过 document.write 写入页面的脚本(解析器插入的脚本)则不会,因此依赖它的旧版嵌入代码会失效。
  • 信任会向下传递。标签管理器加载的任何内容都会运行,所以所有能在标签管理器中发布内容的人,实际上都能在你的网站上运行代码。请像对待部署权限一样对待这项权限。

对于较旧的浏览器,可以添加新浏览器会忽略的后备值:

script-src 'nonce-R4nd0mBase64Value' 'strict-dynamic' https: 'unsafe-inline'; object-src 'none'; base-uri 'none'

支持 CSP Level 3 的浏览器只应用 nonce 和 'strict-dynamic'。认识 nonce 但不认识 'strict-dynamic' 的浏览器使用 nonce 和 https:,非常老的浏览器则回退到 'unsafe-inline' https:。Google 提供了支持 nonce 的 Tag Manager 代码片段,会把 nonce 传递给它添加的脚本,并指出 Tag Manager 中的自定义 JavaScript 变量仍然需要 'unsafe-eval'(Tag Manager CSP 指南)。

5. frame-ancestors、object-src 'none' 和 base-uri:需要显式添加的指令

  • frame-ancestors 决定哪些站点可以用框架嵌入你的页面,是防御点击劫持的手段。如果没有人需要嵌入该页面,使用 'none';只有自己的页面嵌入时使用 'self';也可以列出确切的源,例如 https://app.example.com。它只在 HTTP 响应头中有效。X-Frame-Options(DENY 或 SAMEORIGIN)仍可作为旧浏览器的后备;请让两者允许相同的嵌入范围(MDN frame-ancestors)。
  • object-src 'none' 拦截 <object> 和 <embed>。现代浏览器已不再支持插件,但这些元素仍可加载内容,而没有 default-src 的严格策略如果不明确写出,就不会限制它们。
  • base-uri 'none'(如果页面使用了 <base> 元素,则用 'self')可以阻止被注入的 <base href> 把所有相对脚本 URL 指向其他主机。
  • form-action 'self' 限制表单可以提交到的位置。如果表单会提交给支付或登录服务商,请加上对方的源,并在变更后测试这些流程。
  • upgrade-insecure-requests 让浏览器通过 HTTPS 获取 http:// 子资源。它有助于解决混合内容问题,但不能取代 HSTS。

请把这些指令显式写入每一份策略,包括上面的示例 B 和 C。它们几乎不需要维护,却能补上脚本规则留下的缺口。

6. 使用 report-to 和 report-uri 上报违规

违规报告能告诉你,在真实访客的浏览器里,策略拦截了什么,或者在 Report-Only 模式下会拦截什么。CSP Level 3 定义了 report-to,它指向 Reporting-Endpoints 响应头中声明的端点,同时弃用了直接接收 URL 的旧指令 report-uri。MDN 将 report-to 标为自 2026 年起在当前主流浏览器中新近可用,而较旧版本的浏览器只认 report-uri。支持 report-to 的浏览器会忽略 report-uri,所以目前请两者都发送(MDN report-to):

Reporting-Endpoints: csp="https://example.com/csp-reports"
Content-Security-Policy-Report-Only: script-src 'nonce-R4nd0mBase64Value' 'strict-dynamic' 'report-sample'; object-src 'none'; base-uri 'none'; report-uri https://example.com/csp-reports; report-to csp

两种机制提交的格式不同。report-uri 发送带有 csp-report 键的 JSON 对象,内容类型为 application/csp-report。report-to 发送 application/reports+json:这是一个报告数组,其中 type 为 csp-violation,正文包含 documentURL、blockedURL、effectiveDirective 和 disposition(enforce 或 report)等字段。启用 'report-sample' 后,针对被拦截内联代码的报告会包含其前 40 个字符。请让同一个端点同时接受两种格式。

  • 预期会有噪声。浏览器扩展会注入脚本,并触发来源文件为 chrome-extension:// 或 moz-extension:// 的违规。请按指令和被拦截主机对报告分组,寻找规律,而不是盯着单条记录。
  • 把报告当作个人数据处理。文档 URL 和 referrer 可能带有包含邮箱地址或令牌的查询字符串。请去除或截短它们,只在较短时间内保留报告,并且无故不要转交他人。
  • 保护端点。任何人都可以提交伪造报告,因此请设置大小和频率限制,并且绝不要在管理后台中不经转义地显示报告内容。

7. 常见的故障点:统计代码、字体、内联脚本和嵌入内容

头几天出现的大多数违规来自少数几个来源。报告中的 effectiveDirective 会告诉你该查看哪条规则:

现象报告中的指令典型修复
统计代码不再记录页面浏览script-src-elem、connect-src、img-src用 nonce 加载该标签,或放行其脚本主机,并放行供应商文档中列出的数据收集主机(Google 按产品列出了这些主机)
网页字体回退为系统字体font-src、style-src-elem在 style-src 中放行样式表主机,在 font-src 中放行字体文件主机(Google Fonts 对应 fonts.googleapis.com 和 fonts.gstatic.com),或者自行托管这些文件
主题或 CMS 代码片段失效script-src-elem,且 blockedURL 为“inline”在模板中添加 nonce,把代码移到文件中,或放行其 hash
带 onclick 的按钮没有反应script-src-attr在脚本文件中用 addEventListener 重写处理器
标签管理器的自定义变量没有返回值script-src(eval)改用内置变量,或把 'unsafe-eval' 作为有记录的例外接受下来
嵌入的视频、地图或支付框架一片空白frame-src添加服务商的确切源
内联样式被忽略,布局发生偏移style-src-elem、style-src-attr把样式移到样式表中,为 style 元素添加 nonce,或仅在 style-src 中保留 'unsafe-inline',并作为有记录的例外
聊天或实时更新无法连接connect-src添加供应商的 https:// 和 wss:// 主机
你的应用或合作方无法再嵌入该页面frame-ancestors列出嵌入该页面的确切源

内联样式的风险小于内联脚本,但并非无害:被注入的 CSS 可以改变访客看到的内容,借助属性选择器还可能泄露部分页面数据。许多框架和 CSS-in-JS 库会在运行时插入 style 元素,所以在从 style-src 中移除 'unsafe-inline' 之前,请先确认你使用的库是否支持 nonce。自行托管字体和库可以一次性消除上表中的好几行问题,并让策略保持简短。

8. 分阶段上线 CSP

  1. 盘点。列出关键页面上的脚本、样式、字体、框架和连接目标:首页、登录、账户、结账以及带嵌入组件的页面。DevTools 的 Network 面板和标签管理器配置是最快的信息来源。
  2. Report-Only。以 Content-Security-Policy-Report-Only 发送草案并启用报告。不会拦截任何内容;违规会出现在控制台和你的端点上。让它运行足够长的时间,使访问较少的页面和营销活动页面也能出现,通常需要一周或更久。
  3. 修复来源,而不是放宽策略。添加 nonce,把内联处理器移到文件中,自行托管字体。只有在有负责人和理由并做了记录的前提下,才添加主机或 'unsafe-eval' 作为例外。
  4. 强制执行。把响应头名称改为 Content-Security-Policy。同时保留一份更严格的 Report-Only 策略并行运行,用来测试下一步收紧:当两个响应头同时到达时,浏览器会强制执行其中一个,对另一个只上报。
  5. 持续观察。新的营销标签、插件更新或框架升级都可能带来新的内联代码。请保持报告端点运行,并在每次服务器、CDN 或框架变更后再次检查响应头。

有两个基础设施细节常常带来意外。如果 CDN 或框架添加了自己的 CSP 头,浏览器会执行收到的每一份策略,资源必须通过所有策略,所以额外的响应头只会让限制更严,绝不会更松(MDN)。另外,如果 HTML 在边缘被缓存,基于 nonce 的策略需要在页面组装的位置生成 nonce,或者改用 hash。要查看页面实际发送了什么:

curl -sS -D - -o /dev/null https://example.com/ | grep -i content-security-policy

安全响应头检查指南介绍了应与 CSP 搭配的其他响应头,网站上线安全清单则把它们纳入完整的上线前审查。

9. 用免费的安全响应头检测工具检查已部署的响应头

策略生效后,免费的安全响应头检测工具可以展示浏览器从你的首页收到了什么。它运行的是免费的上线准备快照,对一个公开域名执行八项被动检查,无需注册,并把响应头检查排在其余七项之前展示。

  • 它请求你输入的源站首页,最多跟随五次重定向,并读取最终响应。登录或结账等其他页面通常带有不同的策略,请用 DevTools 或 curl 检查。
  • 它只读取已生效的 Content-Security-Policy 头。在 Report-Only 阶段,它仍会把 CSP 报告为缺失,风险等级为中,因为仅报告的策略不拦截任何内容。在你正式强制执行之前,这个结果是正常的。
  • 已生效策略中的 frame-ancestors 指令与 X-Frame-Options 一样,算作防嵌套保护。
  • 在单独的加固评级(A 到 F)中,当 default-src 或 script-src、style-src 指令(包括 -elem 和 -attr 变体)含有 'unsafe-inline',或当 default-src 或脚本指令含有 'unsafe-eval' 时,CSP 得到的分数会更低。评级按策略的字面内容判断,所以 nonce 旁边的 'unsafe-inline' 后备也会降低得分,尽管当前浏览器会忽略它。
  • 它不评估 nonce、hash、'strict-dynamic'、object-src、base-uri 或报告配置。这些请使用浏览器控制台和你的违规报告来检查。

结果会显示总分以及八项检查各自的结果,让 CSP 信号与 HSTS、TLS 和其他公开检查并列呈现。

常见问题

从哪个 Content-Security-Policy 示例开始比较好?

对于服务器端渲染的网站:script-src 'nonce-{random}' 'strict-dynamic'; object-src 'none'; base-uri 'none'; frame-ancestors 'self',并且每次响应都使用新的 nonce。对于静态或缓存页面,用内联脚本的 hash 代替 nonce。两种方案都先以 Content-Security-Policy-Report-Only 启动。

CSP 中应该用 nonce 还是 hash?

当应用逐页构建时用 nonce,因为每次都可以插入新值。当 HTML 是静态或被缓存时用 hash,因为缓存的 nonce 会被重复发给每位访客。两者都可以与 'strict-dynamic' 配合使用。

style-src 中的 'unsafe-inline' 是问题吗?

它的风险小于 script-src 中的 'unsafe-inline',但被注入的 CSS 仍可能改变访客看到的内容并泄露部分页面数据。仅在把样式迁入样式表或为 style 元素添加 nonce 的过渡期内,将其作为有记录的例外保留。

Content-Security-Policy-Report-Only 能保护我的网站吗?

不能。它不拦截任何内容,只报告强制执行的策略会拦截什么。请用它做测试,然后切换到 Content-Security-Policy 头。两个头可以同时发送,这对测试下一版更严格的策略很有用。

可以通过 meta 标签设置 CSP 吗?

大多数指令可以。但 frame-ancestors、report-uri 和 sandbox 在 meta 标签中会被忽略,Report-Only 策略也无法通过它下发。只要服务器或托管平台允许,就请使用 HTTP 响应头。

report-uri 和 report-to 有什么区别?

report-uri 直接接收 URL;CSP Level 3 已弃用它,但较旧的浏览器仍依赖它。report-to 指向 Reporting-Endpoints 响应头中的端点,并使用 Reporting API 格式。支持 report-to 的浏览器会忽略 report-uri,所以两者同时发送没有问题。

来源与延伸阅读

  1. W3C:Content Security Policy Level 3www.w3.org
  2. MDN:内容安全策略(CSP)指南developer.mozilla.org
  3. MDN:Content-Security-Policy 响应头developer.mozilla.org
  4. MDN:CSP script-src 指令developer.mozilla.org
  5. MDN:CSP frame-ancestors 指令developer.mozilla.org
  6. MDN:CSP report-to 指令developer.mozilla.org
  7. MDN:Reporting-Endpoints 响应头developer.mozilla.org
  8. W3C:Reporting APIwww.w3.org
  9. web.dev:用严格的内容安全策略(CSP)缓解跨站脚本(XSS)web.dev
  10. Google Tag Platform:在内容安全策略下使用 Tag Managerdevelopers.google.com
  11. OWASP 内容安全策略速查表cheatsheetseries.owasp.org
Sitelemetry 团队

由 Sitelemetry 编辑团队编写。文末的一手资料可帮助你深入了解相关主题。

SITELEMETRY

把学到的知识用起来。

通过 Sitelemetry 探索网站的结构、设置与技术信号。