圣灯在线
2026-11-08· 7 分钟安全CSPNext.js运维

上线 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/

那是访问统计的上报请求。如果直接上正式策略,全站统计会立刻失效,而且不会有人发现。

先说结论

三件事:

  1. CSP 违反是静默的——资源被拦,页面照常渲染,控制台之外没有任何提示
  2. Report-Only 必须配一个接收端点,否则浏览器把报告直接丢弃,等于装了个不工作的策略
  3. 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,继续观察。

判断「什么时候可以转正式」的标准

不是「跑满两周」,而是:

  1. 日志里不再出现 blocked= 指向你自己的功能域
  2. 允许出现的剩余违规只有两类:浏览器扩展注入(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,把日志看干净,再转正式。这中间大概需要一周,但省下的排查时间远不止一周。

你的站有这些问题吗?

用免费体检工具输入域名,30 秒看结果。也可以直接写信说清情况,我告诉你值不值得修。