CSP 不是复制粘贴:报告模式、nonce 与 hash 的四档收紧路线

给站点加上 CSP(Content-Security-Policy),是这两年我在安全上做过的最有价值的一件事,也是最容易做成「配了等于没配」的一件事。最常见的失败姿势是:在网上抄一条自以为很严格的策略,直接加到响应头里,然后第二天收到用户投诉说页面样式全丢、评论发不出去、统计没数据。于是赶紧把整条删掉,从此再也不敢碰。

正确做法是把 CSP 当成一次为期两周的灰度变更,而不是一次配置修改。这篇文章讲的就是这条路径:先用报告模式把所有真实来源摸清楚,再分档收紧,最后用 report-to 长期监控。整个过程对线上用户零影响,但能在几周内把 XSS 的可行面砍掉一大半。

一、CSP 到底拦什么,不拦什么

先明确边界,否则很容易期待错。CSP 是一份「浏览器端的资源加载白名单」,它管的是:这个页面允许从哪些地址加载脚本、样式、图片、字体、iframe、表单提交到哪里、能不能用 eval、能不能内联执行。浏览器在渲染前拿这份白名单逐项比对,不在名单里的一律拒绝。

它不管的是:服务器端漏洞、数据库注入、被篡改的合法资源、用户自己装了的浏览器插件、以及同源下的越权访问。所以 CSP 不能替代输入过滤和权限校验——它只是把 XSS 的最后一环(注入的脚本被执行)掐断。哪怕攻击者成功把 <script> 塞进了页面,只要不是白名单里的来源、也没有正确的 nonce,浏览器照样不执行。

这一点很重要。我在实际排查里见过很多「SQL 注入已经拿到数据了但站点看起来完好」的案例,也见过「注入点早就修了但历史页面里还留着 payload」的情况。CSP 对这两类都是兜底,成本低、收益长。

二、先上报告模式,别急着拦

第一步永远是 Content-Security-Policy-Report-Only。它的语义是:策略照常评估、违规照常上报,但浏览器不做拦截。这样你可以在一周内拿到一份真实的资源来源清单,而线上没有任何变化。

add_header Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self'; report-uri /csp-report;" always;

注意最后的 always,它让 Nginx 在 4xx、5xx 响应上也带上这个头——很多「页面出问题时不报违规」的假象就是这么来的。

接收端要自己写。一个最简的 PHP 接收器只需要把请求体原样落到日志里,再按日期切分:

<?php
$body = file_get_contents('php://input');
if (strlen($body) > 8192) { http_response_code(413); exit; }
$dir = '/var/log/csp';
if (!is_dir($dir)) { @mkdir($dir, 0750, true); }
$line = date('c') . "\t" . ($_SERVER['HTTP_USER_AGENT'] ?? '-') . "\t" . $body . "\n";
file_put_contents($dir . '/report-' . date('Ymd') . '.log', $line, FILE_APPEND | LOCK_EX);
http_response_code(204);

这里有两个细节。一是长度上限,防止被恶意刷爆磁盘;二是 LOCK_EX,多进程并发写同一文件时没有它会出现交错截断。日志按天切分,配合 logrotate 保留 14 天就够。

报告里最关键的字段是 violated-directive 和 blocked-uri。前者告诉你哪一类资源被拦,后者告诉你是哪个地址。实际统计时先按 blocked-uri 聚合,你会发现出现频率最高的一般只有五六条。

三、第一周能收到什么:四类典型违规

我自己的站跑了一周报告模式,收到大约四百条报告,去重后只有十一类来源。按出现频次排序,前四类是:

第一类是内联脚本。主题模板里直接写的 <script> 块、按钮上的 onclick 属性、以及各种统计代码的初始化片段。这一类往往占比最高,也是最难改的,因为要逐个改成外部文件或者加 nonce。

第二类是第三方统计与广告。统计脚本会再自己加载子资源,形成一条链:主脚本从 a 域名来,它又去 b 域名拉配置、往 c 域名发数据。报告里会分别出现,白名单里必须逐个补齐,缺一个就会出现「统计能加载但不计数」的诡异状态。

第三类是内联样式与 style 属性。style-src 'self' 会连元素上的 style 属性(也就是内联样式写法)一起拦掉。这一条我建议用 style-src 'self' 'unsafe-inline' 长期放宽——内联样式的 XSS 价值远低于脚本,为了它去改造整站模板不划算。这是少数值得主动放弃严格性的地方。

第四类是 data: 与 blob: 协议的图片。很多主题把 base64 图片直接内联在 CSS 里,或者用 canvas 生成缩略图。默认策略里 img-src 不含 data: 就会全部挂掉。

四、分档收紧:从宽松到严格的四步

看清来源之后开始改。我的做法是分四档,每档观察三到五天,确认报告量降到个位数再进下一档。不要跳档,跳档的代价是某个夜深人静的时段站点功能悄悄坏掉而你不知道。

第一档只锁住 default-src,其他类型全部显式放宽到报告里出现过的来源:

default-src 'self';
img-src 'self' data: https:;
style-src 'self' 'unsafe-inline';
font-src 'self' data:;
connect-src 'self' https://www.google-analytics.com;
frame-ancestors 'self';

第二档开始收紧 script-src,把内联脚本改造成带 nonce 的形式。Typecho 和 WordPress 都能在模板头部输出一个随机串,然后在每个 <script> 上标注。关键是这个随机串必须每次请求都不同,且不能出现在任何缓存得住的 HTML 里——如果你用了整页缓存,nonce 方案和它天然冲突,这时应该改用 hash 而不是 nonce。

第三档把 object-src 'none'、base-uri 'self'、form-action 'self' 全部收死。这三条几乎不会误伤任何正常站点,收益却是最直接的:阻止插件加载、阻止 <base> 劫持相对路径、阻止表单把数据 POST 到别的域。

第四档才是全线切换到强制模式,同时保留 report-uri。注意强制模式下可以用 upgrade-insecure-requests 顺手把混内容收掉。

五、nonce 与 hash 的取舍

内联脚本只有两条合法出路:给它一个 nonce,或者给它算一个 hash。选择标准很实际:脚本内容固定(不随请求变化)就用 hash,脚本内容动态(比如带用户 ID 的初始化参数)就用 nonce。

hash 的写法是 'sha256-基数64摘要',浏览器只认完整匹配,改一个空格都不一样。好处是它天然兼容缓存,坏处是每改一次脚本就要重算一次,容易忘。我的建议是把它做成构建步骤的一部分,用脚本自动算完写进配置模板,人工不碰。

nonce 反过来,灵活但吃缓存。这里有个常被忽略的坑:nonce 一旦被 CDN 缓存下来,所有用户都共用同一个值,攻击者只要自己访问一次拿到 nonce,就能构造出带正确 nonce 的注入。所以用 nonce 的页面必须是 Cache-Control: private, no-store,或者干脆不走 CDN 缓存。

六、和 CDN、反向代理一起用时注意什么

如果你的 HTML 经过 CDN,那么 add_header 加在源站上可能被 CDN 覆盖或者重复。表现是响应里出现两条 CSP 头,浏览器会把它们按「交集」处理——两条都满足才放行,于是策略变得比预期更严,某些资源莫名其妙被拦。

排查方法很简单,看响应头条数:

curl -sI "https://www.example.com/?_=$(date +%s)" | grep -ci 'content-security-policy'
curl -sI "https://www.example.com/?_=$(date +%s)" | grep -i 'content-security-policy'

第一条返回 1 才算正常。返回 2 就要去 CDN 控制台把源站那条去掉,只在一层加。另外注意 add_header 在 Nginx 里是「继承即覆盖」:只要 location 块里写了任何一个 add_header,上层所有 add_header 全部失效。这是 CSP 头在部分页面消失的最常见原因,改用 add_header ... always 并检查每个 location。

七、上线后的长期监控

切到强制模式不等于完工。真实的运营中会有三种持续性的违规来源:新装的主题或插件引入了新域名、广告位换了素材、以及用户 UGC 里的外链图片。这些都会持续产生报告。

我的做法是每周看一次报告聚合,只关注两件事:报告总量是否突增、是否出现新的 blocked-uri。前者代表站点有变更,后者代表需要补白名单或者修代码。为了不被噪音淹没,接收端在入库前可以过滤掉已知的三类:浏览器扩展注入(chrome-extension:)、用户脚本、以及内联样式的违规。

最后一句提醒:CSP 是防御纵深的最后一层,不是第一层。它的价值在于把「注入成功」和「代码执行」分开,让你有时间在数据泄露之前发现并修掉注入点。把它当成兜底,而不是当成解决方案,心态就对了。

Last modification:November 13th, 2026 at 12:34 pm

Leave a Comment