内容安全策略 (CSP) 实施
内容安全策略 (CSP) 实施
内容安全策略 (Content Security Policy, CSP) 是一种由浏览器强制执行的安全机制。它通过一份"白名单"告诉浏览器:这个页面只允许从哪些来源加载脚本、样式、图片、字体等资源,以及是否允许执行内联脚本。凡是不在白名单里的资源,浏览器会直接拒绝加载或执行。
可以把 CSP 想象成一家高级会所的门禁:即使有人偷偷把一张伪造的邀请函(恶意脚本)塞进了信封(页面 HTML),门口的保安(浏览器)也会拿着宾客名单(CSP 策略)逐一核对,名单上没有的人一律不放行。这样即便攻击者成功注入了脚本,脚本也可能因为"不在名单上"而无法执行或无法把窃取的数据发回攻击者服务器。
为什么 CSP 重要
XSS(跨站脚本)长期霸占 OWASP Top 10。传统防御依赖"输入过滤 + 输出编码",但只要有一处遗漏,攻击者就能注入脚本。CSP 提供的是"纵深防御"的最后一道防线——即使前面的编码环节被绕过,CSP 仍能阻断脚本执行。
CSP 基本概念
定义:
核心目标:
工作原理:
CSP 指令详解
资源来源指令(Fetch Directives):
文档与导航指令:
报告指令:
常用来源关键字:
代码示例
示例 1:Next.js 中间件下发基于 nonce 的严格 CSP
// middleware.ts
import { NextResponse } from 'next/server';
import type { NextRequest } from 'next/server';
export function middleware(request: NextRequest) {
// 每次请求生成一个一次性随机数
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const csp = [
`default-src 'self'`,
// strict-dynamic 让被 nonce 授权的脚本可以再动态加载其他脚本
`script-src 'nonce-${nonce}' 'strict-dynamic' https: 'unsafe-inline'`,
`style-src 'self' 'nonce-${nonce}'`,
`img-src 'self' data: https:`,
`font-src 'self'`,
`connect-src 'self' https://api.example.com`,
`frame-ancestors 'none'`,
`base-uri 'self'`,
`form-action 'self'`,
`upgrade-insecure-requests`,
].join('; ');
const requestHeaders = new Headers(request.headers);
requestHeaders.set('x-nonce', nonce);
const response = NextResponse.next({ request: { headers: requestHeaders } });
response.headers.set('Content-Security-Policy', csp);
return response;
}示例 2:在服务端组件里读取 nonce 并注入内联脚本
import { headers } from 'next/headers';
export default function Page() {
const nonce = headers().get('x-nonce') ?? '';
return (
<html>
<body>
<h1>受 CSP 保护的页面</h1>
{/* 只有携带正确 nonce 的内联脚本才会被执行 */}
<script nonce={nonce} dangerouslySetInnerHTML={{ __html: 'window.__APP__ = true;' }} />
</body>
</html>
);
}示例 3:Express + helmet 配置 CSP
const express = require('express');
const helmet = require('helmet');
const crypto = require('crypto');
const app = express();
// 为每个请求生成 nonce
app.use((req, res, next) => {
res.locals.nonce = crypto.randomBytes(16).toString('base64');
next();
});
app.use(
helmet.contentSecurityPolicy({
directives: {
defaultSrc: ["'self'"],
scriptSrc: [
"'self'",
(req, res) => `'nonce-${res.locals.nonce}'`,
"'strict-dynamic'",
],
styleSrc: ["'self'", "'unsafe-inline'"],
imgSrc: ["'self'", 'data:', 'https:'],
connectSrc: ["'self'", 'https://api.example.com'],
objectSrc: ["'none'"],
baseUri: ["'self'"],
frameAncestors: ["'none'"],
reportUri: ['/csp-report'],
},
})
);示例 4:使用哈希(hash)授权固定的内联脚本
// 对于内容固定、无法加 nonce 的内联脚本,可用其 SHA-256 哈希授权
const crypto = require('crypto');
const inlineScript = 'console.log("hello csp");';
const hash = crypto.createHash('sha256').update(inlineScript).digest('base64');
console.log(`script-src 'sha256-${hash}'`);
// 输出示例: script-src 'sha256-xxxxxxxxxxxxxxxx...'示例 5:接收并解析 CSP 违规报告
// Express 中接收违规上报
app.post('/csp-report', express.json({ type: ['application/csp-report', 'application/json'] }), (req, res) => {
const report = req.body['csp-report'] || req.body;
console.warn('[CSP 违规]', {
documentUri: report['document-uri'],
blockedUri: report['blocked-uri'],
violatedDirective: report['violated-directive'],
sourceFile: report['source-file'],
lineNumber: report['line-number'],
});
res.status(204).end();
});CSP 实施策略
严格策略(推荐):
宽松策略(过渡期):
平滑迁移三步走:
真实案例
案例一:GitHub 的 CSP 实践
GitHub 是最早大规模落地严格 CSP 的平台之一。他们采用基于 nonce 的策略,几乎完全禁用了 `unsafe-inline`,把大量本可导致 XSS 的注入点变成了无害的文本。GitHub 工程团队公开分享过,CSP 让他们即便偶有 XSS 漏洞被发现,实际可利用性也极低。
案例二:某电商因误用 unsafe-inline 导致 CSP 失效
某电商上线了 CSP,看似合规,却在 `script-src` 中同时写了 `'unsafe-inline'`。结果攻击者通过存储型 XSS 注入内联脚本,浏览器因为 `unsafe-inline` 照常执行,CSP 完全没起作用。教训:白名单里只要出现 `unsafe-inline`,脚本类防护基本归零(nonce/hash 会使浏览器忽略 unsafe-inline,是更安全的写法)。
案例三:report-only 提前发现第三方脚本外发数据
某内容站点在灰度阶段用 report-only 模式跑了两周,从违规报告里发现一个引入的第三方统计 SDK 偷偷向未申报域名发送数据。团队据此及时替换了 SDK,避免了正式上线后的数据合规事故。
数据与对比
| 策略写法 | 抗 XSS 能力 | 维护成本 | 说明 |
| --- | --- | --- | --- |
| 无 CSP | 无 | 无 | 完全依赖编码防护 |
| default-src self + unsafe-inline | 很弱 | 低 | 脚本防护基本失效 |
| 域名白名单(无 nonce) | 中 | 高 | 白名单易被 JSONP/开放跳转绕过 |
| nonce + strict-dynamic | 强 | 中 | Google 推荐的现代方案 |
| hash 授权固定脚本 | 强 | 中高 | 适合内容固定的内联脚本 |
| 下发方式 | 支持 report-uri | 灵活性 | 适用场景 |
| --- | --- | --- | --- |
| HTTP 响应头 | 支持 | 高,可动态生成 nonce | 生产环境首选 |
| meta 标签 | 不支持 | 低,无法防 frame-ancestors | 纯静态站点兜底 |
常见坑
最佳实践
资源管理:
报告与监控:
框架集成:
测试与验证:
工具与资源
CSP 三代演进:Level 1 / 2 / 3
CSP 并非一蹴而就,而是经过三次大版本迭代逐步成熟。理解每一代解决了什么问题、引入了什么新能力,能帮助你判断"某个指令能不能用""浏览器会不会支持"。
Level 1(2012 年 W3C 候选推荐):
Level 2(2016 年 W3C 推荐):
Level 3(持续演进的工作草案,主流浏览器已大量实现):
| 版本 | 发布/状态 | 标志性能力 | 主要局限 |
| --- | --- | --- | --- |
| Level 1 | 2012 候选推荐 | 基础 fetch 指令、域名白名单 | 内联脚本只能全开全关 |
| Level 2 | 2016 正式推荐 | nonce、hash、frame-ancestors | 仍依赖域名白名单,易被绕过 |
| Level 3 | 工作草案,广泛实现 | strict-dynamic、Trusted Types、report-to | 部分指令兼容性参差 |
一个实用建议:面向现代浏览器时以 Level 3 的 nonce + strict-dynamic 为主,同时保留 Level 2 的 hash 与 Level 1 的域名白名单作为老浏览器的兜底,浏览器会各取所需、互不冲突。
指令逐条深入
前面列过指令清单,这里挑几个"容易被忽略但很关键"的指令展开讲。
object-src 与 base-uri:两个必配的"堵漏"指令
`object-src` 控制 `
// 几乎所有站点都应该加上这两条
"object-src 'none'"; // 禁止一切插件资源
"base-uri 'none'"; // 或 'self',防止注入 <base href> 劫持相对 URL`base-uri` 常被遗忘:如果攻击者能注入一个 `
script-src-elem 与 script-src-attr:标签与属性分治
Level 3 把脚本控制拆成了两半: