内容安全策略(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;
}

避坑清单

  1. style-src 的 'unsafe-inline':很多框架(Vue 内联样式等)离不开它。只开 style 的 inline 风险远小于 script,可以接受,但要心里有数;
  2. data: 别放进 script-src,IMG 的 data: 一般没问题;
  3. 别用已废弃的 X-Webkit-CSP / X-Content-Security-Policy 头,现代浏览器只认标准的 Content-Security-Policy;
  4. CSP 不是替代转义:它拦的是"脚本从哪来",拦不了纯 HTML 注入(如 <img onerror> 之外的内容注入、钓鱼文案)。该转义还是要转义;
  5. 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 规则都实在。

← 返回首页 · 历史归档