CSP 工具
Content-Security-Policy 的实战用法:每个指令管什么、nonce 与 hash 与来源表达式各自怎么匹配,以及如何用 Report-Only 把一份策略平稳地推上线。
1 工具
一份策略到底是什么
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-src | script 元素、eval、内联事件处理器 | 是 |
style-src | style 元素、样式表链接、@import、style 属性 | 是 |
connect-src | fetch、XMLHttpRequest、WebSocket、EventSource、sendBeacon | 是 |
img-src / font-src / media-src / manifest-src | 图片与 srcset;网页字体;音频、视频、track;应用清单 | 是 |
object-src | object 与 embed——永远设 'none' | 是 |
frame-src / child-src / worker-src | 嵌套文档;worker | 是,worker 经由 child-src |
base-uri / form-action | base 标签可设的值;表单可提交到哪里 | 否 |
frame-ancestors | 谁可以内嵌本页;取代 X-Frame-Options | 否 |
sandbox / require-trusted-types-for | 本文档的沙箱标志;DOM XSS 汇点 | 否 |
report-uri / report-to | 违规报告 POST 到哪里 | 否 |
右边那一列才是要命的地方:default-src 'self' 仍然把 base 标签劫持、表单外发和点击
劫持敞着。script-src-elem 和 script-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' | eval 与 new Function / 作用于 onclick、style 的 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-to、sandbox、require-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-ancestors、sandbox、report-uri 和 Report-Only 在 meta http-equiv 里会被
忽略,而且该策略只覆盖它之后的标记。