Content Security Policy
概述
即使开发者完全遵循了XSS防御的最佳实践,Web应用仍然可能存在安全漏洞。人非圣贤,总会有遗漏——一个被遗忘的dangerouslySetInnerHTML、一个第三方脚本的安全缺陷、一个老旧依赖引入的漏洞。Content Security Policy(CSP,内容安全策略)正是为这一场景设计的最后防线。
CSP的核心思想是声明式控制:通过HTTP响应头告诉浏览器,这个页面允许加载哪些资源、执行哪些脚本。任何不在白名单中的资源加载或脚本执行都会被浏览器阻止,即使攻击者成功注入了恶意代码,CSP也能在浏览器层面阻断执行。
2014年,Google在 Gmail 上部署了严格的CSP,成功阻止了所有已知的XSS攻击向量。但这付出了代价——Gmail团队花了数年时间重构前端代码,以适应CSP的严格限制。这个案例告诉我们: CSP是强大的工具,但需要从项目初期就开始规划。
本节将系统讲解CSP的工作原理、指令系统、Nonce和Hash两种严格的脚本控制方案,以及如何从Report-Only模式渐进过渡到严格策略。我们将通过大量真实案例帮助你理解CSP的工程实践。
目标
- 深入理解CSP的工作原理和各种指令的含义
- 掌握从宽松策略到严格策略的渐进式部署方法
- 学会使用Nonce和Hash实现严格的脚本控制
- 理解strict-dynamic的作用和适用场景
- 能够处理CSP与第三方脚本、框架的兼容性
知识体系
1. CSP 基础
CSP通过HTTP响应头或HTML meta标签传递给浏览器。浏览器在解析HTML之前就会检查CSP,因此CSP可以阻止HTML解析阶段的攻击。
# 通过 HTTP Header 设置
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; style-src 'self' 'unsafe-inline'; img-src *; font-src 'self' https://fonts.gstatic.com
<!-- 通过 Meta 标签设置 -->
<!-- 功能较 HTTP Header 受限,不支持 frame-ancestors、report-uri -->
<meta
http-equiv="Content-Security-Policy"
content="default-src 'self'; script-src 'self'"
/>
使用meta标签设置CSP的局限性需要注意:meta标签无法设置frame-ancestors(该指令必须在frame来源页面设置)和report-uri/report-to(用于违规报告收集)。因此,生产环境推荐使用HTTP头方式。
# 仅报告模式 — 不阻止违规,只发送报告
Content-Security-Policy-Report-Only: default-src 'self'; report-uri /api/csp-report
Report-Only模式是CSP部署的最佳起点。在这个模式下,浏览器会报告违规但不会阻止。你可以在不影响用户的情况下,收集足够的数据了解页面的资源加载情况,然后逐步收紧策略。
2. CSP 指令详解
CSP的指令系统设计清晰,每个指令控制特定类型资源的加载来源。
# 各指令控制不同类型资源的加载来源
default-src # 所有资源的默认策略
script-src # JavaScript 脚本
style-src # CSS 样式表
img-src # 图片
font-src # 字体文件
connect-src # Ajax/WebSocket/EventSource 连接目标
media-src # 音频和视频
object-src # <object>/<embed>/<applet>
frame-src # <iframe> 加载来源
child-src # Web Worker 和嵌套浏览上下文
worker-src # Web Worker/Service Worker
frame-ancestors # 允许嵌入当前页面的父页面
base-uri # <base> 标签的 href
form-action # <form> 的提交目标
指令值决定了允许的来源:
'self' # 同源
'none' # 禁止所有
'unsafe-inline' # 允许内联(不推荐)
'unsafe-eval' # 允许 eval()(不推荐)
'strict-dynamic' # 信任已信任脚本加载的子脚本
'nonce-{random}' # 允许匹配 nonce 的脚本/样式
'sha256-{hash}' # 允许匹配 hash 的脚本/样式
https: # 仅允许 HTTPS
data: # 允许 data: URI
blob: # 允许 blob: URI
*.example.com # 通配符域名
3. 渐进式 CSP 部署
CSP的部署应该循序渐进。从宽松策略开始,收集违规报告,逐步收紧,最终达到严格策略。
阶段一:报告模式 — 收集违规
第一步是部署Report-Only模式,收集所有违规行为,不实际阻止任何内容。这个阶段可能持续数周甚至数月,取决于你收集到的数据是否足够全面。
// Express 中间件
app.use((req, res, next) => {
res.setHeader(
'Content-Security-Policy-Report-Only',
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self'",
"img-src 'self' data: https:",
"font-src 'self'",
"connect-src 'self'",
"report-uri /api/csp-report",
].join('; ')
);
next();
});
// CSP 违规报告接收
app.post('/api/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
const report = req.body['csp-report'];
console.log('CSP Violation:', {
blockedURI: report['blocked-uri'],
violatedDirective: report['violated-directive'],
documentURI: report['document-uri'],
sourceFile: report['source-file'],
lineNumber: report['line-number'],
});
res.status(204).end();
});
收集报告后,需要分析违规来源。常见的违规包括:CDN资源未在白名单、第三方脚本(如Google Analytics)需要特殊配置、内联脚本和样式需要迁移到外部文件。
阶段二:宽松策略 — 基础防护
基于Report-Only数据,配置宽松但已阻止明显恶意内容的策略。这个阶段允许内联脚本和样式(unsafe-inline),因为迁移内联代码需要较大工作量。
Content-Security-Policy:
default-src 'self';
script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.example.com;
style-src 'self' 'unsafe-inline';
img-src 'self' data: https:;
font-src 'self' https://fonts.gstatic.com;
connect-src 'self' https://api.example.com;
object-src 'none';
frame-ancestors 'self';
base-uri 'self';
form-action 'self'
阶段三:严格策略 — 使用 Nonce
最终目标是移除unsafe-inline和unsafe-eval,使用Nonce或Hash机制精确控制允许的脚本。
Content-Security-Policy:
default-src 'self';
script-src 'nonce-{random}' 'strict-dynamic';
style-src 'self' 'nonce-{random}';
img-src 'self' data: https:;
font-src 'self';
connect-src 'self' https://api.example.com;
object-src 'none';
base-uri 'self'
4. Nonce 方案
Nonce(一次性数字)是CSP控制内联脚本的核心机制。服务端为每个请求生成一个随机Nonce,将其同时添加到CSP头和页面对应的script标签中。浏览器只允许带有匹配Nonce的内联脚本执行。
// 服务端为每个请求生成随机 Nonce
import crypto from 'crypto';
function cspMiddleware(req, res, next) {
// 每个请求生成唯一 Nonce
const nonce = crypto.randomBytes(16).toString('base64');
res.locals.cspNonce = nonce;
res.setHeader(
'Content-Security-Policy',
[
"default-src 'self'",
`script-src 'nonce-${nonce}' 'strict-dynamic'`,
`style-src 'self' 'nonce-${nonce}'`,
"img-src 'self' data: https:",
"connect-src 'self' https://api.example.com",
"object-src 'none'",
"base-uri 'self'",
].join('; ')
);
next();
}
<!-- HTML 中使用 Nonce -->
<script nonce="{{nonce}}">
// 只有匹配 Nonce 的内联脚本才能执行
console.log('This script is allowed');
</script>
<script nonce="{{nonce}}" src="/js/app.js"></script>
<!-- 没有 Nonce 的脚本会被阻止 -->
<script>alert('blocked!');</script>
<style nonce="{{nonce}}">
.safe { color: green; }
</style>
Nonce的随机性至关重要。应该使用密码学安全的随机数生成器(如crypto.randomBytes),而不是Math.random。每次请求必须生成新的Nonce值,Nonce应该足够长以防暴力猜测。
Next.js 中配置 Nonce
Next.js的中间件系统提供了便捷的Nonce配置方式。
// middleware.ts
import { NextResponse } from 'next/server';
export function middleware(request) {
const nonce = Buffer.from(crypto.randomUUID()).toString('base64');
const cspHeader = `
default-src 'self';
script-src 'self' 'nonce-${nonce}' 'strict-dynamic';
style-src 'self' 'nonce-${nonce}';
img-src 'self' blob: data:;
font-src 'self';
object-src 'none';
base-uri 'self';
form-action 'self';
frame-ancestors 'none';
`.replace(/\s{2,}/g, ' ').trim();
const response = NextResponse.next();
response.headers.set(
'Content-Security-Policy',
cspHeader
);
// 传递 Nonce 到页面组件
response.headers.set('x-nonce', nonce);
return response;
}
5. Hash 方案
Hash方案允许内联脚本执行——只要脚本内容的Hash匹配CSP头中声明的Hash。这适用于不经常变化的静态内联脚本。
// 对内联脚本内容计算 Hash
const crypto = require('crypto');
const scriptContent = 'console.log("hello");';
const hash = crypto.createHash('sha256').update(scriptContent).digest('base64');
// CSP Header 中使用 Hash
// script-src 'sha256-xxxxx'
Content-Security-Policy:
script-src 'sha256-RFWPLDbv2BY+rCkDzsE+0fr8ylGr2R2faWMhq4lfEQc='
<!-- 只有内容 Hash 匹配的脚本才能执行 -->
<script>console.log("hello");</script>
Hash方案的局限性在于:任何对脚本内容的修改都需要重新计算Hash并更新CSP头。这使得Hash方案更适合完全静态的内联代码,对于需要频繁变更的应用不太实用。
6. strict-dynamic
strict-dynamic是CSP Level 3引入的特性,它允许通过Nonce或Hash信任的脚本动态加载其他脚本,而不需要将加载的脚本也加入白名单。
# strict-dynamic 允许已信任的脚本加载其他脚本
# 对 SPA 动态加载非常有用
Content-Security-Policy:
script-src 'nonce-abc123' 'strict-dynamic' https: 'unsafe-inline'
# 行为:
# 1. 匹配 nonce 的脚本可以执行
# 2. 这些脚本通过 DOM API 动态加载的子脚本自动信任
# 3. 'unsafe-inline' 和 https: 作为兼容旧浏览器的 fallback
# 4. 不支持 strict-dynamic 的浏览器会 fallback 到 https: 'unsafe-inline'
strict-dynamic解决了SPA的脚本加载问题。现代Web应用通常使用动态import、模块加载器、React/Vue等框架,它们都会动态创建script标签。如果没有strict-dynamic,就需要将所有可能的动态加载目标加入白名单,这几乎是不可能的。
7. 常见 CSP 问题
CSP部署中最常见的问题是第三方脚本兼容性。Google Analytics、广告SDK、社交分享按钮等第三方服务通常要求比CSP更宽松的配置。
// 问题一:第三方脚本
// Google Analytics、广告等需要 eval 或 inline
// 解决:使用 nonce 或为第三方创建代理
// 问题二:CSS-in-JS 需要 unsafe-inline
// styled-components、emotion 等默认使用 <style> 注入
// 解决:配置 nonce 支持
// styled-components nonce 配置
import { StyleSheetManager } from 'styled-components';
function App({ nonce }) {
return (
<StyleSheetManager nonce={nonce}>
<MainApp />
</StyleSheetManager>
);
}
// 问题三:Web Worker
// worker-src 需要单独配置
// Content-Security-Policy: worker-src 'self' blob:
对于必须使用第三方脚本的场景,有几种解决思路:
使用自己的域名代理:将第三方脚本下载到自己服务器,通过自己的域名加载。这种方式绕过了第三方脚本的安全风险,但失去了CDN的全球加速优势,也需要自行维护脚本更新。
使用sandbox iframe:将第三方内容隔离在sandbox iframe中,限制其能力。sandbox iframe内的内容不会影响父页面的CSP。
评估和监控:如果必须加载第三方脚本,应该定期评估其安全性,关注其是否爆出安全漏洞。
8. CSP 策略模板
一个严格的CSP模板应该作为目标,但在部署前需要仔细评估兼容性。
// 严格策略模板(推荐)
const strictCSP = {
'default-src': ["'self'"],
'script-src': ["'nonce-{random}'", "'strict-dynamic'"],
'style-src': ["'self'", "'nonce-{random}'"],
'img-src': ["'self'", 'data:', 'https:'],
'font-src': ["'self'"],
'connect-src': ["'self'", 'https://api.example.com'],
'object-src': ["'none'"],
'base-uri': ["'self'"],
'form-action': ["'self'"],
'frame-ancestors': ["'none'"],
'upgrade-insecure-requests': [],
};
function buildCSPHeader(policy, nonce) {
return Object.entries(policy)
.map(([directive, values]) => {
const processedValues = values.map((v) =>
v.replace('{random}', nonce)
);
return `${directive} ${processedValues.join(' ')}`;
})
.join('; ');
}
实战练习
练习 1:CSP 渐进部署
从Report-Only模式开始,逐步为一个真实项目部署CSP策略。记录每个阶段的违规报告,分析违规来源,制定下一阶段的收紧策略。
练习 2:Nonce 集成
在Next.js或Express应用中实现基于Nonce的严格CSP。处理styled-components、React组件库、第三方SDK的兼容性。验证严格CSP部署后XSS攻击确实被阻止。
练习 3:CSP 报告分析
搭建CSP违规报告收集系统,使用ELK或类似技术栈存储和可视化报告数据。分析常见的违规来源和模式,识别需要优先处理的技术债务。
延展阅读
- MDN — Content Security Policy:CSP的MDN文档,包含所有指令的详细说明和示例。
- CSP Evaluator:Google开发的CSP评估工具,可以分析现有CSP配置的安全强度。
- Strict CSP - web.dev:Google推荐的严格CSP部署指南,包含Nonce方案和strict-dynamic的使用。
- CSP Is Dead, Long Live CSP! - Google Research:Google研究院关于CSP有效性的实证研究论文。
关键术语
| 术语 | 解释 |
|---|---|
| CSP | Content Security Policy,内容安全策略,声明式的资源加载控制机制 |
| Directive | CSP指令,控制特定类型资源加载来源的声明 |
| Nonce | 一次性随机令牌,用于标记可信的内联脚本 |
| Hash | 脚本内容的加密摘要,用于验证内联脚本 |
| strict-dynamic | CSP Level 3指令,允许可信脚本动态加载子脚本 |
| Report-Only | 仅报告模式,不阻止违规但发送报告 |
| Violation Report | CSP违规报告,包含被阻止资源的详细信息 |
| Fallback | CSP指令链中的兜底值,用于不支持某指令的浏览器 |
| Content-Security-Policy-Report-Only | 不阻止违规、只发送报告的CSP模式 |