CSP:从报告模式到严格模式
白名单式 CSP 早已被证明容易绕过——基于 nonce 与 strict-dynamic 的严格 CSP 才是现代答案。附完整的四阶段落地路径、Next.js 实现和常见踩坑。
CSP:从报告模式到严格模式
CSP(内容安全策略)是一个响应头,告诉浏览器**「这个页面只允许从哪些地方加载哪些东西」**。它是 XSS 纵深防御里最有价值的一层:即使攻击者成功注入了脚本,CSP 也能让它执行不了、或者数据传不出去。
白名单式 CSP 的问题
大多数教程教你这么写:
Content-Security-Policy: script-src 'self' https://cdn.example.com https://www.google-analytics.com看起来很严谨,实际上大概率可以被绕过。研究者对上百万个真实站点的 CSP 做过分析,结论是绝大多数白名单 CSP 存在可利用的绕过路径。
原因有三:
一、白名单域名上常常有 JSONP 接口。
<!-- cdn 在白名单里,但它有个 JSONP 接口 -->
<script src="https://cdn.example.com/api/jsonp?callback=alert(1)"></script>CSP 检查通过(域名在白名单里),但回调参数让攻击者执行了任意代码。
二、白名单域名上托管着有漏洞的库。 很多 CDN 提供各版本的 AngularJS 等框架,攻击者加载一个旧版本,用它的模板注入能力绕过 CSP。
三、'unsafe-inline' 一旦出现,CSP 基本作废。
而很多站为了兼容内联脚本,不得不加上它。
加了 'unsafe-inline' 的 script-src,防 XSS 的作用趋近于零。 因为 XSS 注入的正是内联脚本。你可能觉得「至少还挡住了外部脚本」——但攻击者根本不需要外部脚本,内联就够干所有事。
如果你的 CSP 里有 script-src ... 'unsafe-inline',请把它当作「暂时没有 CSP」来对待。
严格 CSP:nonce + strict-dynamic
现代推荐做法:
Content-Security-Policy:
script-src 'nonce-{每次请求随机} ' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
require-trusted-types-for 'script';逐项解释:
| 指令 | 作用 |
|---|---|
'nonce-xxx' | 只有带正确 nonce 属性的 script 标签才能执行。nonce 每次请求随机生成,攻击者猜不到 |
'strict-dynamic' | 被信任的脚本动态创建的脚本也自动受信——这让白名单变得不必要,解决了第三方库动态加载子资源的问题 |
https: 与 'unsafe-inline' | 只是给不支持 strict-dynamic 的老浏览器兜底。支持的浏览器会忽略它们 |
object-src 'none' | 禁掉 Flash/Java 等插件,它们是老牌绕过途径 |
base-uri 'none' | 关键但常被漏掉:防止攻击者注入 <base href="//evil.com"> 把所有相对路径的脚本源改掉 |
用法:
<script nonce="r4nd0m-per-request">...</script>
<script nonce="r4nd0m-per-request" src="/app.js"></script>nonce 必须满足三个条件,缺一不可:
- 每次响应重新生成(不是每次部署,是每个请求);
- 足够随机(至少 128 bit,用密码学安全的随机源);
- 页面不能被缓存,否则 nonce 会跟着缓存一起被复用,等于公开。
第三条最容易踩:静态站点用 nonce 是有问题的——HTML 被 CDN 缓存后,所有用户拿到同一个 nonce,攻击者只要访问一次就知道它了。
静态站点的替代方案是 hash:script-src 'sha256-...',把每个内联脚本的哈希列进去。构建工具通常能自动生成。
四阶段落地
CSP 直接上强制模式几乎必然打断线上功能。必须分阶段。
阶段一:只报告,不拦截
Content-Security-Policy-Report-Only:
script-src 'self';
report-uri /api/csp-report用 -Report-Only 头,浏览器只上报违规、不阻止任何东西。跑一到两周,收集报告。
接收端点:
// app/api/csp-report/route.ts
export async function POST(req: Request) {
const report = await req.json();
console.warn('[CSP violation]', {
documentUri: report['csp-report']?.['document-uri'],
blockedUri: report['csp-report']?.['blocked-uri'],
directive: report['csp-report']?.['violated-directive'],
});
return new Response(null, { status: 204 });
}报告端点会收到大量噪音,做好心理准备。主要来源是浏览器扩展注入的脚本、运营商劫持插入的内容、以及各种 App 内置浏览器的注入。
过滤技巧:blocked-uri 为 chrome-extension://、moz-extension://、about: 之类的一律忽略。剩下的才值得看。
阶段二:按报告调整
看报告里被拦的都是什么:
- 是你自己的合法资源 → 加 nonce 或调整指令
- 是第三方服务 → 决定是加进策略还是干脆去掉这个依赖
- 是扩展/劫持 → 忽略
这个阶段的产出是一份「真实需要的资源清单」。顺带一提,这个清单本身就很有价值——很多团队第一次做 CSP 时,才发现自己的页面在加载十几个不知道哪来的第三方脚本。
阶段三:强制,但先从宽松开始
把 -Report-Only 去掉,同时保留 report-uri——强制模式下也能继续收报告,这样才知道你误伤了谁。
Content-Security-Policy:
script-src 'nonce-xxx' 'strict-dynamic' https: 'unsafe-inline';
object-src 'none';
base-uri 'none';
report-uri /api/csp-report阶段四:逐步收紧
稳定运行后,再逐条加严:style-src、img-src、connect-src、frame-ancestors、require-trusted-types-for。
一份完整的现代策略
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{RANDOM}' 'strict-dynamic' https: 'unsafe-inline';
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' data:;
connect-src 'self' https://api.example.com;
frame-ancestors 'none';
form-action 'self';
base-uri 'none';
object-src 'none';
upgrade-insecure-requests;
report-uri /api/csp-report几个说明:
style-src里的'unsafe-inline'是现实妥协。CSS 注入的危害远小于 JS(主要是数据外传和界面伪装),而绝大多数框架都会产生内联样式。先接受它,把精力放在script-src上。connect-src很重要:它限制fetch/XHR/WebSocket 能往哪发。即使 XSS 成功执行了,这一条也能阻止数据被传到攻击者的服务器——这是 CSP 兜底价值最直接的体现。frame-ancestors 'none'取代了老的X-Frame-Options,防点击劫持。form-action 'self'防止注入的表单把用户输入提交到外部。
Next.js 里的实现
因为 nonce 要每次请求生成,需要在 middleware 里做:
// middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request: Request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`default-src 'self'`,
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
`style-src 'self' 'unsafe-inline'`,
`img-src 'self' data: https:`,
`connect-src 'self'`,
`frame-ancestors 'none'`,
`base-uri 'none'`,
`object-src 'none'`,
].join('; ');
const headers = new Headers(request.headers);
headers.set('x-nonce', nonce); // 传给页面使用
const res = NextResponse.next({ request: { headers } });
res.headers.set('Content-Security-Policy', csp);
return res;
}全静态站(SSG)用不了这个方案——没有请求期的中间件,也不该有。静态站的正确选择是:
- 用 hash 方式(构建时算出所有内联脚本的 sha256),或者
- 干脆不用内联脚本,全部走外部文件 +
script-src 'self'。
第二条更简单也更彻底。如果你的站点能做到「零内联脚本」,script-src 'self' 就是一条又短又强的策略。
验证
# 看看线上到底下发了什么策略
curl -sSI https://example.com/ | grep -i content-security-policy
# 检查有没有致命的 unsafe-inline(在 script-src 里)
curl -sSI https://example.com/ | grep -i "content-security-policy" | grep -o "script-src[^;]*"浏览器控制台是最好的调试工具——违规会以明确的错误信息打印出来,告诉你哪条指令拦了什么。
小结
- 白名单式 CSP 大多可绕过(JSONP、旧版库、
'unsafe-inline')。 - 现代答案是 nonce +
strict-dynamic,配合object-src 'none'与base-uri 'none'。 - nonce 必须每请求随机且页面不可缓存;静态站应改用 hash 或彻底去掉内联脚本。
- 必须走四阶段:report-only → 调整 → 强制(保留报告)→ 逐步收紧。
connect-src是 XSS 兜底的关键:脚本跑起来了,数据也传不出去。- 报告端点噪音大,先过滤掉浏览器扩展。
下一章讲另一类跨站问题。👉 CSRF 与 SameSite