上线 CSP 前先跑 Report-Only,不然你会静默打断统计
给站点加 CSP 时,我用 Report-Only 跑了 5 分钟,日志里就出现了一条真实违规:统计请求被 connect-src 拦掉。如果直接上正式策略,访问数据会全部丢失,而且浏览器不会报任何错。
给站点加 CSP 的常见做法是:查一份「Next.js 推荐 CSP」,复制到 Caddy 或 nginx 的 header 里,完事。
我原本也打算这么做,但先跑了一次 Report-Only。5 分钟后日志里出现这一行:
[csp] connect-src blocked=https://xxxxx.supabase.co/rest/v1/page_views doc=https://sacredlantern.online/
那是访问统计的上报请求。如果直接上正式策略,全站统计会立刻失效,而且不会有人发现。
先说结论
三件事:
- CSP 违反是静默的——资源被拦,页面照常渲染,控制台之外没有任何提示
- Report-Only 必须配一个接收端点,否则浏览器把报告直接丢弃,等于装了个不工作的策略
- Report-Only 的观察期按「违规清零」判断,不是按天数
下面是完整做法和真实数据。
为什么「先查推荐配置」这条路会翻车
网上的 Next.js CSP 示例基本长这样:
default-src 'self';
script-src 'self' 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data:;
connect-src 'self';
它的问题不在语法,在 connect-src 'self' 这一行。
connect-src 管的是 fetch / XHR / sendBeacon 能发去哪些域。默认只允许自己的域——看起来很安全,实际会拦掉所有第三方数据上报:
| 会被拦掉的 | 为什么常见 |
|---|---|
| 统计上报(Supabase / Plausible / Umami) | 前端 sendBeacon 发到自己域名之外 |
| 表单提交到第三方 | Typeform、Formspree、Mailchimp |
| 支付 SDK | Stripe.js 的部分握手请求 |
| 错误监控(Sentry) | 上报到 ingest 域名 |
这些请求被拦之后,页面上什么都看不出来。表单不提交、统计不记录、错误不上报,用户操作照常,你只是突然没有数据了。
排查起来很痛苦,因为症状是「数据变少了」而不是「报错了」。
第一步:先建报告接收端点
这一步最容易被跳过,也是最重要的一步。
没有 report-uri,浏览器收到违规时找不到地方投递,报告会被直接丢弃。 此时 Content-Security-Policy-Report-Only 只是一串没人读的响应头。
我在 Next.js 里加了一个接收端点:
// app/api/csp-report/route.ts
import { NextResponse } from "next/server";
export async function POST(request: Request) {
try {
const body = await request.json();
const reports = Array.isArray(body) ? body : [body];
for (const r of reports.slice(0, 10)) {
const b = r["csp-report"] || r.body || r;
const directive = b["violated-directive"] || b.effectiveDirective || "?";
const blocked = b["blocked-uri"] || b.blockedURL || "?";
const doc = b["document-uri"] || b.documentURL || "?";
console.warn(
`[csp] ${directive} blocked=${String(blocked).slice(0, 120)} doc=${String(doc).slice(0, 120)}`,
);
}
} catch {
// 报告格式异常不影响响应
}
return new NextResponse(null, { status: 204 });
}
几个设计考虑:
- 返回 204:报告不需要响应体,204 让浏览器不再重试
slice(0, 10):防止恶意请求一次塞几万条撑爆日志- 兼容两种格式:老的
csp-report(report-uri)和新的 Reporting API 格式结构不同 try/catch吞掉解析错误:CSP 报告格式在各浏览器之间并不统一,不能因为格式怪就返回 500
验证端点通了:
curl -s -o /dev/null -w "%{http_code}\n" -X POST \
-H "Content-Type: application/json" \
-d '{"csp-report":{"violated-directive":"script-src","blocked-uri":"inline"}}' \
https://your-site.com/api/csp-report
# 204
第二步:用 Report-Only 上线
Caddy 里这样写(nginx 换成 add_header):
header {
Content-Security-Policy-Report-Only "default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self' data:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://buy.stripe.com; report-uri /api/csp-report"
}
几个值的理由:
| 指令 | 值 | 为什么 |
|---|---|---|
script-src |
'unsafe-inline' 'unsafe-eval' |
Next.js 依赖内联脚本注入 RSC payload;去掉会白屏 |
style-src |
'unsafe-inline' |
Tailwind 内联样式和 style 属性 |
img-src |
data: blob: |
图表和 canvas 导出 |
frame-ancestors |
'none' |
防点击劫持,和 X-Frame-Options: DENY 双保险 |
form-action |
'self' https://buy.stripe.com |
表单提交到 Stripe 支付页 |
frame-ancestors 和 form-action 在 Report-Only 下也生效吗? 不。Report-Only 只报告不拦截,全部指令都是观察模式。所以这两个不会真的挡住攻击,只是提前告诉你哪里会冲突。
report-uri 是废弃标准但兼容性最好;新标准用 report-to + Report-To 响应头,Chrome 支持但 Safari 还不完整。我两个都没写 report-to,因为多一套端点维护成本,而 report-uri 目前仍被所有主流浏览器支持。
第三步:读日志,别猜
部署后几分钟,日志里出现了:
[csp] connect-src blocked=https://xxxxx.supabase.co/rest/v1/page_views doc=https://sacredlantern.online/
一条就够定性了。 统计组件在往 Supabase 上报页面浏览数据,被 connect-src 'self' 拦住。
把域加进白名单:
connect-src 'self' https://xxxxx.supabase.co
改完 reload,继续观察。
判断「什么时候可以转正式」的标准
不是「跑满两周」,而是:
- 日志里不再出现
blocked=指向你自己的功能域 - 允许出现的剩余违规只有两类:浏览器扩展注入(
chrome-extension://)、内联data:试错
第 2 类可以忽略——它们来自用户环境,不是你站点的问题。
如果日志里还有指向你主动使用的服务域的违规,说明策略还没收敛,不能转正式。
一个反直觉的细节:CSP 不会让页面变慢
很多人担心加 CSP 影响性能。实测前后:
| 指标 | 加 CSP 前 | 加 CSP 后 |
|---|---|---|
| LCP | 1.7s | 1.8s |
| TBT | 240ms | 60ms |
| 性能分 | 94 | 97 |
分数反而升了。 但这和 CSP 无关——同期我还删掉了一段内联的 lang 修正脚本(改用 route group 的静态语言标记),TBT 下降来自那里。
CSP 本身的开销在浏览器内部,量级在微秒级,测不出来。
顺手记下的两个坑
1. Content-Security-Policy-Report-Only 和 Content-Security-Policy 可以同时存在
上线初期常见做法是两者并存:正式策略拦一部分,Report-Only 观察更宽的范围。但两个头同时存在时,浏览器分别执行,报告会混在一起。要么就只有一个,要么在日志里带上策略来源字段区分。
2. 报告端点本身要排除在 CSP 之外
report-uri /api/csp-report 是浏览器自己发的请求,不受 connect-src 约束。但如果你的 CSP 里有 default-src 'self' 且端点在同域,正常情况没问题;跨域部署报告端点时需要额外注意。
最后
CSP 的价值不在「加了多少条指令」,在于你是否知道自己拦掉了什么。
一个不收集报告的 CSP,和没有 CSP 的区别只有:出问题时你不知道原因。
先跑 Report-Only,把日志看干净,再转正式。这中间大概需要一周,但省下的排查时间远不止一周。