内容安全策略(CSP)从原理到落地
XSS 是 Web 安全里打不死的小强。输入过滤、输出转义能解决绝大部分问题,但只要有一个漏网之鱼,攻击者的 <script src="https://evil.com/steal.js"> 就能执行。CSP(Content-Security-Policy) 的价值在于:就算页面被注入了脚本,浏览器也拒绝执行来自非白名单来源的代码。这是纵深防御的最后一道墙。
指令速读
CSP 是一个 HTTP 响应头,用"指令 + 来源列表"的方式告诉浏览器各类资源的加载边界:
Content-Security-Policy: default-src 'self'; img-src 'self' data:; script-src 'self'
常用指令:
| 指令 | 控制什么 |
|---|---|
default-src |
兜底默认值,未单独声明的指令都回落到它 |
script-src |
JS 来源(核心,防 XSS 主战场) |
style-src |
CSS 来源 |
img-src |
图片来源 |
font-src |
字体来源 |
connect-src |
fetch/XHR/WebSocket 目标 |
frame-ancestors |
谁能用 iframe 嵌你(替代 X-Frame-Options) |
object-src |
<object>/<embed>,直接 'none' |
来源关键字:'self'(同源)、'none'(全禁)、'unsafe-inline'(允许内联,会拆掉大半防护)、'unsafe-eval'(允许 eval,更不要开)、data:(data URI)。
第一步:先 Report-Only
CSP 最大的坑是上线即白屏——策略太严,把自家资源也拦了。正确姿势是先用 Content-Security-Policy-Report-Only 头跑一段时间,只报告不拦截:
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /csp-report
浏览器会把违规项 POST 到 report-uri(也可以用 report-to + Reporting API)。观察日志里没有误伤后,再切成正式的 Content-Security-Policy。
内联脚本的正确处理:nonce
老站几乎都有内联 <script>,直接 'self' 会全部误杀。用 nonce 解决——服务端每次响应生成随机串,只有带匹配 nonce 的内联脚本放行:
# nginx 原生不支持每请求随机 nonce,静态站可用固定值 + 脚本注入
# 动态站(如 Flask)则每次请求生成
<script nonce="r4nd0mStr1ng">
// ok: nonce matches response header
</script>
<script>
// blocked: no nonce, attacker-injected
</script>
Content-Security-Policy: script-src 'self' 'nonce-r4nd0mStr1ng'
注意 nonce 必须每次请求重新生成且不可预测,写死在静态文件里等于自欺欺人。这也是为什么静态站的终极方案是消灭内联脚本。
静态站落地示例
以本站为例,页面是构建期生成的,最优解:所有 JS 都外链 + 所有 CSS 都外链,然后收紧到接近极限:
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'; img-src 'self' data:; font-src 'self'; object-src 'none'; base-uri 'self'; form-action 'none'; frame-ancestors 'self'" always;
策略解读:
default-src 'self':默认一切只允许同源;script-src 'self':外链脚本仅限本站,没有'unsafe-inline'——本站新模板零 JS,天然满足;object-src 'none'、base-uri 'self':封死<object>注入和<base>篡改这两个冷门但致命的向量;form-action 'none':纯内容站没有表单,谁也别想借我的域名钓鱼提交;frame-ancestors 'self':等价于 X-Frame-Options,且支持多值。
旧页面(Hexo 时代的模板)有内联脚本和内联样式,对它们要网开一面,用单独的 location 区分策略:
# new pages: strict
location /posts/ {
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self'" always;
}
# legacy pages: allow inline (they predate CSP)
location /archives/ {
add_header Content-Security-Policy "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'" always;
}
避坑清单
style-src的'unsafe-inline':很多框架(Vue 内联样式等)离不开它。只开 style 的 inline 风险远小于 script,可以接受,但要心里有数;data:别放进script-src,IMG 的data:一般没问题;- 别用已废弃的
X-Webkit-CSP/X-Content-Security-Policy头,现代浏览器只认标准的Content-Security-Policy; - CSP 不是替代转义:它拦的是"脚本从哪来",拦不了纯 HTML 注入(如
<img onerror>之外的内容注入、钓鱼文案)。该转义还是要转义; - CSP Level 3 的严格动态(
strict-dynamic):适合大量第三方脚本的老业务,纯静态站用不上,别为了时髦加上。
验证
curl -sI https://www.example.com/posts/ | grep -i content-security
配合浏览器的 DevTools → Security 面板,以及 CSP Evaluator 扫一遍策略本身有没有漏洞(比如 'unsafe-inline' 配 'self' 的组合有没有拆防护)。
一句话总结:CSP 的强度取决于最松的那条指令。静态站的优势是构建期可控——把内联全部消灭后,你可以把策略收到攻击者几乎无脚本可用的程度,这比任何 WAF 规则都实在。