csp

CSP 工具

Content-Security-Policy 的实战用法:每个指令管什么、nonce 与 hash 与来源表达式各自怎么匹配,以及如何用 Report-Only 把一份策略平稳地推上线。

1 工具

§01 领域指南

一份策略到底是什么

Content-Security-Policy 是一个由指令构成的响应头,每个指令持有一份来源表达式 清单。在浏览器取子资源、运行内联代码、提交表单或允许页面被内嵌之前,它会去查对应 的管辖指令;如果没有任何一项匹配,那个请求根本不会发生,并且会上报一条违规。执行 发生在浏览器里,而一份策略只会移除能力,绝不会赋予能力。

匹配是按源(scheme、host、port)进行的,不是按你脑子里想的那个 URL:在 https://app.example.com 上的 'self' 既不覆盖 https://cdn.example.com,也不覆盖 它的子域。一份策略的价值也只等于它最弱的那个逃生口:script-src 'self' 'unsafe-inline' 等于把一个可用的 script 标签直接交给任何能注入标记的人——而这正是 CSP 要阻止的攻击。请用按响应生成的 nonce 或内容 hash 替换 'unsafe-inline'

多份策略以交集方式组合,绝不是并集:当一个响应带了两个 Content-Security-Policy 头——你的加上 CDN 添的那个——一个资源必须同时满足两者,所以多出来的那个头只会让页面 更严。

指令参考

指令管什么是否回落到 default-src
script-srcscript 元素、eval、内联事件处理器
style-srcstyle 元素、样式表链接、@importstyle 属性
connect-srcfetchXMLHttpRequestWebSocketEventSourcesendBeacon
img-src / font-src / media-src / manifest-src图片与 srcset;网页字体;音频、视频、track;应用清单
object-srcobjectembed——永远设 'none'
frame-src / child-src / worker-src嵌套文档;worker是,worker 经由 child-src
base-uri / form-actionbase 标签可设的值;表单可提交到哪里
frame-ancestors谁可以内嵌本页;取代 X-Frame-Options
sandbox / require-trusted-types-for本文档的沙箱标志;DOM XSS 汇点
report-uri / report-to违规报告 POST 到哪里

右边那一列才是要命的地方:default-src 'self' 仍然把 base 标签劫持、表单外发和点击 劫持敞着。script-src-elemscript-src-attr 则把元素与内联处理器拆开管。

来源表达式

表达式匹配什么注意
'self' / 'none'文档自身精确的 scheme、host 和 port / 什么都不匹配不含子域;'none' 与任何其它值并列时形同无效
https:该 scheme 上的任意主机等于互联网上的每一个 CDN
https://cdn.example.com那个源,端口取默认值路径前缀在重定向后不被强制
*.example.com / *任意层级的任意子域 / 任意主机不含裸 example.com* 不含 data:blob:
'nonce-…'带有匹配 nonce 属性的元素128 位以上随机数,每次响应都要新的
'sha256-…'精确字节哈希等于此值的内联代码多一个空白字节,hash 就变了
'strict-dynamic'由已受信脚本创建的脚本主机与 scheme 来源会被忽略
'unsafe-inline'任何内联脚本或样式当存在 nonce 或 hash 时被忽略
'unsafe-eval' / 'unsafe-hashes'evalnew Function / 作用于 onclickstyle 的 hash'wasm-unsafe-eval' 是窄化版本

用 Report-Only 分阶段上线

把候选策略作为 Content-Security-Policy-Report-Only 发出:求值方式完全相同,什么都 不拦,并且可以与一条强制执行的策略并列送出,于是你可以在线上那条继续保护用户的同时 去收紧第二条。用 report-uri /csp-reports(已废弃,但支持面最广)或者 report-to (需要一个 Reporting-Endpoints 头)来收集违规;报告的投递不受 connect-src 约束。 跨源拦截在 blocked-uri 里会坍缩成一个源,指名被拒的主机而不是文件;加上 'report-sample' 可以拿到一小段代码,并按 effective-directive 分组来看。

什么场景用哪个工具

站点已经上线时,问题是实际送出去的策略是什么HTTP 头信息检查工具api.sitekits.dev 取回这个 URL,返回 状态、重定向次数和每一个响应头,从不返回正文——足以暴露某个代理改写了你的策略。它的 SSRF 防护会拒绝内网地址。

如果你还在起草,Content Security Policy 生成器 提供 14 个指令 字段,建立在一套加固过的基线之上:default-src 'self'; frame-ancestors 'none'; base-uri 'self'; object-src 'none',另有一个默认勾选的 upgrade-insecure-requests 复选框——所以一个什么都没填的表单,本身就已经输出这套 基线并追加 upgrade-insecure-requests。字符串随你输入实时重建,全程在浏览器里。这些 字段之外的指令(report-tosandboxrequire-trusted-types-for)需要手工追加。

给一个既有应用做白名单,本质是清点问题:从 DevTools 导出一份 HAR,用 HAR 文件查看器 读它,再把可疑的 URL 逐个丢进 URL 解析器,归约成来源表达式所需的 scheme-host-port 形式。附到 工单之前先用 HAR 文件清洗器 脱敏——HAR 里带着 cookie 和 Authorization 头。文本差异检查器 能显示 report-only 版和强制执行 版之间改了什么;JSON 格式化 让一份 csp-report 载荷变得可读;而 REST API 测试器 从浏览器直接调用你的目标,路径上没有 sitekits 的服务器——并且它跑在它自己页面的策略下,那份策略把 connect-src 放宽到 'self' https:,不是跑在你的策略下。一个调用在那里成功、在你的应用里失败,指向的就是 你自己的那个头。它不会告诉你原因:connect-src 拦截和 CORS 拒绝都表现为同一个 fetch TypeError,而该工具只打印一条同时覆盖这两种情况的消息。另见: HTTP 工具集安全工具集安全工程师工具箱

常见的破坏方式

加了 nonce 会静默地让 'unsafe-inline' 失效

那些自己注入 script 标签的标签管理器和聊天挂件会停止执行,因为它们永远看不到你按响应 生成的那个值;'strict-dynamic' 是解法。

CSP 的 hash 是摘要的 base64,不是十六进制

'sha256-…' 要的是那 32 个原始摘要字节的 base64,所以把 哈希生成器 给出的十六进制再过一遍 Base64 编码 / 解码,得到的是一个错误的字符串。请直接从浏览器控制台的 报错里复制。

写死的 nonce 就是绕了几步的 'unsafe-inline'

nonce 必须每次响应重新生成,这决定了它只能在服务端产生;模板里的一个常量——或者 HTML 被缓存在边缘而响应头却在重新生成——都是可猜的。UUID v4 生成器 适合 本地手工测试,绝不适合上线。

meta 标签形式的策略表达不出 CSP 的一半

frame-ancestorssandboxreport-uri 和 Report-Only 在 meta http-equiv 里会被 忽略,而且该策略只覆盖它之后的标记。

FAQ
script-src 里已经有 'unsafe-inline' 了,为什么我的内联脚本还是被拦?
因为同一个指令里同时还有 nonce 或 hash。只要该指令中出现任何 'nonce-…' 或 'sha256-…' 来源,浏览器就会故意忽略 'unsafe-inline',于是这个兜底值悄悄失效。要么把当前的 nonce 打到那段内联脚本上,要么加上它的 hash,要么加上 'strict-dynamic',让已受信脚本加载的脚本继承信任。
default-src 能覆盖所有指令吗?
不能,而这正是一份原本严格的策略上最常见的漏洞。base-uri、form-action、frame-ancestors、sandbox、report-uri 和 report-to 都不会回落到 default-src,所以仅有 default-src 'self' 仍然允许 base 标签劫持、表单提交到任意主机,以及被任意站点内嵌。这些必须逐条明写出来。
怎么部署 CSP 才不会把生产环境搞坏?
把候选策略作为 Content-Security-Policy-Report-Only 发送,它的求值方式与真正的头完全一致,但什么都不拦,而且可以与一条正在强制执行的策略并存。收集一到两周的报告,然后把头名换掉。要预期其中有很大一部分是无法处理的噪声,来自往你页面里注入内联脚本的浏览器扩展。
内联脚本该用 nonce 还是 hash?
HTML 是每次响应动态生成的就用 nonce,因为这个值必须不可预测、且每次都新鲜。HTML 是静态的、或者被缓存在 CDN 上,就用 hash,因为 hash 不需要服务端随机性——但它覆盖的是那段内联块的精确字节,改一个空格就会让它失效。两者都能与 'strict-dynamic' 搭配,后者会把信任从该指令本来允许的东西上传播出去:script-src 'sha256-…' 'strict-dynamic' https: 'unsafe-inline' 就是那种静态场景下有文档记载的、基于 hash 的严格策略。信任只延伸到被哈希的那段代码在运行时创建的脚本,不包括标记里已经存在的标签。
为什么我的违规报告在 blocked-uri 里只给了一个域名,而不是被拦的那个文件?
因为跨源违规被故意截断到源一级——报告完整 URL 就等于把 CSP 刚刚阻止页面读取的信息交给它。你只能知道哪个主机被拒了,而不是哪个资源;发生重定向后,你甚至可能只拿到最初那个源。给该指令加上 'report-sample',报告就会带上一小段违规代码;再按 effective-directive 分组,就能看出实际是哪条规则在触发。同源违规不会被截断,所以那些报告确实带完整路径。