你好,我是 hockor,作为 Web 开发工程师,我们时刻面临着各种安全威胁,其中跨站脚本攻击(XSS)是最常见也是危害最大的漏洞之一。攻击者通过注入恶意脚本,可以窃取用户敏感信息、劫持会话甚至篡改网页内容。传统的防御手段(如输入验证和输出编码)虽然重要,但往往难以做到滴水不漏。这时,浏览器提供的一项重要安全特性——Content Security Policy (CSP)——就显得尤为关键。那么今天,让我们先从理论层面好好来梳理一下 CSP,看看它到底有哪些约束来保障我们的 web 内容的安全性,下一期我们再从代码演示上来分别展示。
CSP 本质上是建立一个内容安全白名单,通过 HTTP 头或 <meta> 标签告知浏览器哪些外部资源(脚本、样式、图片、字体、框架等)允许被加载和执行。如果资源的来源不在白名单内,浏览器就会拒绝加载,从而有效地阻止了大部分 XSS 攻击以及其他类型的内容注入攻击。
如下图,抖音官网的响应头中关于 CSP 相关的配置
为什么需要 CSP?
想象一下,你的网站有一个留言板功能。如果没有对用户输入进行严格的过滤,攻击者可能会提交包含恶意 <script> 标签的留言。当其他用户浏览这条留言时,浏览器会解析并执行这个恶意脚本,造成 XSS 攻击。
传统的防御: 对用户输入进行 HTML 转义,例如将 < 转换为 <。但这依赖于开发者对所有潜在输入点的严格控制,容易遗漏。
CSP 的防御: 通过设置 CSP,你可以限制哪些域名下的脚本可以执行,甚至完全禁止页面执行内联脚本(<script>...)或通过 eval() 执行字符串代码。这样一来,即使攻击者成功注入了 <script> 标签,如果它的来源或执行方式不符合 CSP 策略,浏览器也会拒绝执行它,从而大大降低了 XSS 的风险。
除了 XSS,CSP 还能防御:
- 数据注入攻击: 通过限制表单提交的目标(
form-action)。 - 点击劫持: 通过限制页面是否可以在
<frame>或<iframe>中嵌套(frame-ancestors)。 - 混淆内容攻击: 强制使用 HTTPS 连接(
upgrade-insecure-requests)。
CSP 的工作原理
CSP 的核心是定义一系列指令 (Directives) 及其对应的允许来源 (Sources)。这些策略可以通过以下两种方式发送给浏览器:
-
HTTP 响应头 (推荐): 在服务器的 HTTP 响应中添加
Content-Security-Policy或Content-Security-Policy-Report-Only头部。Content-Security-Policy: default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'这是最推荐的方式,因为它可以覆盖整个页面,并且策略是在页面内容解析之前生效的。
-
<meta>标签: 在页面的<head>中添加一个<meta>标签。<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">这种方式优先级较低,并且不能用于
report-uri、report-to和frame-ancestors等指令。
Content-Security-Policy-Report-Only 头部:
这个头部用于报告违反 CSP 策略的行为,但并不阻止这些行为。这对于在生产环境中测试新的 CSP 策略非常有帮助。你可以在不影响用户体验的情况下,观察哪些资源会被新的策略阻止,然后根据报告调整策略,直到满意为止,最后再切换到 Content-Security-Policy 头部。
CSP 发展历程
| 时间 | 标准版本 | 状态 | 关键特性 | 网址 |
|---|---|---|---|---|
| 2012 | CSP Level 1 | 已废弃 | 基本资源限制 | www.w3.org/TR/CSP1/ |
| 2014 | CSP Level 2 | 推荐使用 | nonce、hash、报告机制 | www.w3.org/TR/CSP2/ |
| 2021 | CSP Level 3 | 草案 | strict-dynamic、report-to | www.w3.org/TR/CSP3/ |
| 未来 | CSP Level 4 | 规划中 | 新特性支持(待定) |
浏览器兼容性
CSP 已经被主流现代浏览器广泛支持,包括 Chrome, Firefox, Safari, Edge 等。但是,不同浏览器对 CSP 规范的支持程度可能有所不同,尤其是对于一些较新或不太常用的指令(如 report-to, worker-src, strict-dynamic)以及 CSP Level 2/3 的特性。
- CSP Level 1: 提供了基础的指令(如
default-src,script-src,style-src,img-src,report-uri等)。兼容性很好。 - CSP Level 2: 增加了
base-uri,child-src,form-action,frame-ancestors,block-all-mixed-content,upgrade-insecure-requests以及 Nonce 和 Hash 源。兼容性也很不错,但 IE 并不完全支持。 - CSP Level 3: 增加了
worker-src,manifest-src,report-to,strict-dynamic等指令。这些指令在较新版本的浏览器中支持较好。
在部署 CSP 策略时,如果你需要支持较旧的浏览器版本时,建议参考 Can I use 网站来查询特定指令的兼容性情况。
总结兼容性要点:
- 绝大多数用户使用的现代浏览器都支持 CSP Level 1 和 Level 2 的核心特性。
- Nonce, Hash, 和
strict-dynamic在现代浏览器中支持良好。 report-to是相对较新的特性,兼容性不如report-uri。frame-ancestors只能通过 HTTP 头设置,并且在旧版 IE 中不受支持(可以使用X-Frame-Options头作为补充防御)。
核心 CSP 指令详解
CSP 提供了非常多的指令来控制不同类型资源的加载。以下是一些最常用和重要的指令:
default-src: 这是最重要的回退指令。如果某个资源类型没有特定的指令(例如没有script-src),浏览器就会使用default-src的策略。强烈建议设置此指令。script-src: 控制 JavaScript 的来源。style-src: 控制 CSS 样式表的来源。img-src: 控制图片的来源。connect-src: 控制 XMLHttpRequest (XHR), WebSockets, Fetch 等接口连接的来源。font-src: 控制字体的来源。object-src: 控制<object>,<embed>,<applet>等标签的来源。frame-src: 控制<frame>,<iframe>,<frameset>等标签加载内容的来源(在新版本的 CSP 中,推荐使用child-src或worker-src,但frame-src仍被广泛支持)。child-src: 控制 Web Workers 和其他嵌套浏览器上下文(如 frames 和 iframes)的来源。worker-src: 控制 Worker、SharedWorker 或 ServiceWorker 脚本的来源。base-uri: 限制<base>标签中指定的 URL。form-action: 限制<form>标签提交的目标 URL。frame-ancestors: 限制哪些网站可以将当前页面嵌入到<frame>,<iframe>,<object>,<embed>,<applet>中。这个指令只能通过 HTTP 头设置,不能在<meta>标签中使用。report-uri: 指定一个 URL,当 CSP 策略被违反时,浏览器会发送一份违规报告到这个 URL。report-to: 一个新的替代report-uri的指令,支持报告分组和更详细的报告信息(需要配置 Reporting API)。upgrade-insecure-requests: 指示用户代理将所有 HTTP URL 重写为 HTTPS。这对于有大量遗留 HTTP 资源的网站很有用。block-all-mixed-content: 如果设置了这个指令,浏览器会阻止所有通过 HTTP 加载的资产,即使是 HTTPS 页面中的资源也会被阻止。
允许来源 (Sources) 的值:
来源可以是一个或多个,用空格分隔。常见的值包括:
'self': 允许来自同源(Same Origin)的资源(协议、域名、端口都相同)。*: 允许来自任何来源(慎用,非常不安全)。domain.com: 允许来自指定域名(及其子域名,如果前面加了*.)的资源。*.domain.com: 允许来自指定域名及其所有子域名的资源。https://domain.com: 允许来自指定域名的 HTTPS 资源。'unsafe-inline': 允许内联脚本 (<script>...</script>) 或内联样式 (<style>...</style>,style="...")。 极不推荐使用,因为它会引入 XSS 风险。'unsafe-eval': 允许使用eval(),setTimeout("..."),setInterval("...")等从字符串执行代码的方法。 极不推荐使用,因为它也引入 XSS 风险。'nonce-<base64-value>': 允许带有特定 nonce 属性的内联脚本或样式。服务器在每次响应时生成一个唯一的 nonce 值,并将其添加到 CSP 头和相应的<script>或<style>标签上。这是一种相对安全的允许内联代码的方式,因为它需要攻击者知道当前的 nonce 值(难以猜测)。- 示例 CSP 头:
script-src 'nonce-abc123xyz' - 示例 HTML:
<script nonce="abc123xyz">...</script>
- 示例 CSP 头:
'sha256-<base64-hash>': 允许具有特定哈希值的内联脚本或样式。你可以计算内联脚本或样式的哈希值(支持 SHA256, SHA384, SHA512),并将其添加到 CSP 头中。- 示例 CSP 头:
script-src 'sha256-abcdefg...' - 示例 HTML:
<script>alert('Hello, CSP!');</script>(你需要计算alert('Hello, CSP!');这段代码的 SHA256 哈希值)
- 示例 CSP 头:
'strict-dynamic': 一个强大的指令(通常与 nonce 或 hash 一起使用)。如果一个脚本通过 nonce 或 hash 被允许执行,那么这个脚本加载的其他脚本(例如通过document.createElement('script')创建并添加到 DOM 中)也会被信任并允许执行,而无需显式地在 CSP 中列出这些脚本的来源。这极大地简化了对复杂应用(如 SPA)的 CSP 管理,同时保持了安全性。- 示例 CSP 头:
script-src 'nonce-abc123xyz' 'strict-dynamic' - 示例 HTML:
<script nonce="abc123xyz">const s = document.createElement('script'); s.src = 'https://example.com/dynamic.js'; document.body.appendChild(s);</script>(即使example.com不在script-src中,dynamic.js也会被允许加载)
- 示例 CSP 头:
使用 Express 实现一个 CSP Demo
下面我们将创建一个简单的 Express 应用来演示如何设置 CSP 头部以及不同策略的效果。
项目结构:
csp-demo/
├── package.json
├── app.js
└── public/
├── index.html
├── safe-script.js
1. 初始化项目并安装 Express 和 body-parser (用于接收报告):
mkdir csp-demo
cd csp-demo
npm init -y
npm install express body-parser
2. 创建 public/index.html:
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>CSP 示例</title>
<!-- 该样式表应该被允许加载,因为 CSP 中允许 'self' 源 -->
<style>
body {
font-family: sans-serif;
}
</style>
<!-- 内联脚本:如果 script-src 不包含 'unsafe-inline',此脚本将被阻止执行 -->
<script>
console.log('这是一个内联脚本。');
</script>
<script>console.log('这是一个由 hash 判断的脚本。');</script>
<!-- 来自同源的外部脚本:CSP 允许加载 /safe-script.js -->
<script src="/safe-script.js"></script>
<!-- 来自 cdnjs.cloudflare.com 的外部脚本:CSP 允许加载该域名下的资源 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/jquery/3.6.0/jquery.min.js"></script>
</head>
<body>
<h1>内容安全策略(CSP)演示页面</h1>
<p>打开浏览器开发者工具查看 CSP 违规报告。</p>
<!-- 内联事件处理函数:这会触发 CSP 违规(如果未允许 'unsafe-inline') -->
<button onclick="alert('这是一个内联事件处理函数!')">点击我(内联事件)</button>
<!-- 图片资源:来自 placehold.co,CSP 已允许该来源 -->
<img src="https://placehold.co/150x150" alt="占位图片">
</body>
</html>
3. 创建 public/safe-script.js:
console.log('这是一个来自同源的安全脚本。');
4. 创建 app.js:
const express = require('express');
const path = require('path');
const bodyParser = require('body-parser');
const app = express();
const port = 3000;
// 使用 body-parser 中间件来解析请求体,用于处理 CSP 报告
// 针对 JSON 类型和 application/csp-report 类型的请求体进行解析
app.use(bodyParser.json({
type: ['json', 'application/csp-report'] // 特别用于处理 CSP 的违规报告
}));
// 自定义中间件:设置 Content-Security-Policy(内容安全策略)响应头
app.use((req, res, next) => {
// 定义你的 CSP 策略
// 当前策略规定:
// - 默认资源加载来源为 'self'(即同源)
// - 脚本允许从 'self' 和 cdnjs.cloudflare.com 加载
// - 样式表允许从 'self' 加载,并允许内联样式('unsafe-inline'),仅用于演示,生产环境应避免使用
// - 图片资源允许从 'self' 和 https://via.placeholder.com 加载
// - 所有违反策略的行为将报告到 /report 接口
const cspPolicy = `
default-src 'self';
script-src 'self' https://cdnjs.cloudflare.com 'sha256-WnZRYRws9lJmeyKcnwV8cR+ycNmLoVQQPANm6GNlsUk=';
style-src 'self' 'unsafe-inline';
img-src 'self' https://placehold.co;
report-uri /report;
`.replace(/\s+/g, ' ').trim(); // 清理多余的空白字符
// 使用 Content-Security-Policy 响应头来 **强制执行** 策略
res.setHeader('Content-Security-Policy', cspPolicy);
next();
});
// 从 'public' 目录提供静态文件(HTML、CSS、JS 等)
app.use(express.static(path.join(__dirname, 'public')));
// 接收 CSP 违规报告的接口
app.post('/report', (req, res) => {
console.log('收到 CSP 违规报告:');
console.log(JSON.stringify(req.body, null, 2)); // 将报告内容格式化后打印到控制台
res.sendStatus(204); // 返回 204 No Content 表示成功接收但无内容返回
});
// 启动服务器
app.listen(port, () => {
console.log(`CSP 示例应用正在运行在 http://localhost:${port}`);
console.log('请在浏览器中打开 http://localhost:3000 并查看控制台输出。');
});
5. 运行应用:
node app.js
打开浏览器访问 http://localhost:3000。打开开发者工具的 Console (控制台) 和 Network (网络) 面板。
观察现象:
- 根据
script-src 'self' https://cdnjs.cloudflare.com;策略:-
内联脚本("这是一个内联脚本.")会被阻止,除非策略中包含
'unsafe-inline'或使用了 nonce/hash。 -
同源脚本 (
safe-script.js) 会被允许加载。 -
来自 cdnjs 的 jQuery 脚本会被允许加载。
-
console.log('这是一个由 hash 判断的脚本。'); 会被允许加载
-
- 根据
style-src 'self' 'unsafe-inline';策略:- 内联样式 (
body { ... }) 会被允许,因为包含了'unsafe-inline'。如果去掉'unsafe-inline',内联样式就会被阻止。
- 内联样式 (
- 根据
img-src 'self' https://via.placeholder.com;策略:- 来自
https://placehold.co的图片会被允许加载。如果去掉这个来源,图片就会被阻止。
- 来自
- 内联事件处理函数 (
onclick="alert('...')) 通常会被script-src阻止,即使有'unsafe-inline'。它们需要更宽松的策略或使用其他技术(如通过 JavaScript 添加事件监听器)。 - 在 Console 中,你会看到关于 CSP 违规的警告或错误信息。
- 如果你的策略包含了
report-uri /report;并且有违规发生,服务器端的控制台会打印出接收到的违规报告的 JSON 数据。
你可以修改 app.js 中的 cspPolicy 来实验不同的 CSP 策略,观察它们对页面行为的影响。例如,去掉 'unsafe-inline',看看内联脚本和样式是否被阻止。
构建工具 (Webpack/Vite) 的最佳实践
现代 Web 开发通常使用构建工具(如 Webpack 或 Vite)来打包、优化和处理资源。构建过程可能会产生一些 CSP 需要特别关注的问题:
- 内联代码: 构建工具(尤其是 Webpack 的一些插件)可能会生成一些内联的运行时脚本或小的 CSS 块。这些内联代码如果没有被 CSP 允许,会导致应用无法正常运行。
- 哈希文件名: 构建工具常常为了缓存优化,给输出文件添加哈希(例如
bundle.abcdef.js)。CSP 策略通常是基于来源路径,这与哈希文件名不冲突。
解决方案:
- 最小化内联代码: 尽量减少构建工具生成的内联脚本和样式。
- 使用 Nonce 或 Hash:
- Nonce: 这是处理动态生成的内联脚本(如运行时代码、动态导入的启动脚本)的最佳方式。
- 实现: 服务器在每个请求时生成一个唯一的 base64 编码的 Nonce 值。将这个 Nonce 值添加到 CSP 响应头的
script-src和/或style-src指令中,例如script-src 'nonce-YOUR_NONCE_VALUE' 'self' ...;。 - 同时,将这个 Nonce 值传递给你的前端模板引擎,将它作为
nonce属性添加到构建工具生成的内联<script>或<style>标签上。 - 一些构建工具或其插件提供了 Nonce 集成的能力。例如,在使用 HTMLWebpackPlugin 生成 HTML 时,可以通过插件选项将 Nonce 注入到生成的标签中。
- 实现: 服务器在每个请求时生成一个唯一的 base64 编码的 Nonce 值。将这个 Nonce 值添加到 CSP 响应头的
- Hash: 适用于内容不变的静态内联脚本或样式。
- 实现: 构建完成后,计算所有内联脚本和样式的哈希值(SHA256, SHA384 或 SHA512)。将这些哈希值添加到 CSP 响应头的
script-src和/或style-src指令中,例如script-src 'sha256-HASH_OF_SCRIPT1' 'sha256-HASH_OF_SCRIPT2' 'self' ...;。 - 这种方法对于每次构建都会变化的内联代码(如包含构建时间戳的代码)不太实用。
- 实现: 构建完成后,计算所有内联脚本和样式的哈希值(SHA256, SHA384 或 SHA512)。将这些哈希值添加到 CSP 响应头的
- Nonce: 这是处理动态生成的内联脚本(如运行时代码、动态导入的启动脚本)的最佳方式。
- 使用
'strict-dynamic'(与 Nonce/Hash 配合): 当使用nonce或hash允许了初始脚本后,可以使用'strict-dynamic'允许这些被信任的脚本动态加载其他脚本,而无需在 CSP 中明确列出所有可能的来源。这极大地简化了 SPA 等应用的 CSP 配置。- 示例策略:
script-src 'nonce-YOUR_NONCE_VALUE' 'strict-dynamic' 'self' https:; object-src 'none'; base-uri 'self'; - 注意:当
'strict-dynamic'生效时,会忽略'unsafe-inline'和基于 URL 的白名单(例如https://cdn.example.com),除非它们也带有 Nonce 或 Hash 或通过被信任的脚本加载。但'self'通常仍然有效作为回退。
- 示例策略:
- 配置允许的来源: 对于外部引用的资源(JS, CSS, Images 等),根据你的项目依赖,在 CSP 策略中明确列出所有允许的 CDN、API 服务等来源。
实施 CSP 的建议步骤
- 分析现有应用: 了解你的应用加载了哪些外部资源,哪些使用了内联脚本或样式,哪些使用了
eval()。 - 从
report-only开始: 在生产环境中使用Content-Security-Policy-Report-Only头部和report-uri指令。部署策略,并收集违规报告,分析哪些合法的资源被阻止了。 - 逐步收紧策略: 根据报告调整策略,逐渐移除
'unsafe-inline'和'unsafe-eval',明确列出允许的来源。优先解决script-src和default-src的问题。 - 考虑 Nonce 或 Hash: 如果确实需要内联脚本(如构建工具生成的运行时代码),集成 Nonce 或 Hash 机制。考虑使用
'strict-dynamic'简化管理。 - 切换到强制模式: 当你确信策略不会阻止应用的正常功能时,将
Content-Security-Policy-Report-Only切换为Content-Security-Policy。 - 持续监控: 即使在强制模式下,也要继续使用
report-uri或report-to收集报告,以便及时发现新的问题或潜在的攻击尝试。 - 结合其他安全措施: CSP 是重要的安全层,但不能替代输入验证、输出编码、HTTP Strict Transport Security (HSTS)、SameSite cookies 等其他安全实践。
总结
Content Security Policy (CSP) 是现代 Web 安全中不可或缺的一部分,它是防御 XSS 和其他内容注入攻击的强大武器。通过明确声明页面允许加载和执行的资源来源,CSP 为浏览器提供了一个安全基线,即使其他防御措施失效,也能在一定程度上限制攻击的危害范围。
虽然部署 CSP 需要一些工作来分析现有应用并调整策略,但这项投入是值得的。从 report-only 模式开始,逐步收紧策略,结合 Nonce 或 Hash 处理内联代码,并利用 'strict-dynamic' 简化复杂应用的策略管理,你就能有效地提升你的 Web 应用的安全性,为你的用户提供更安全的浏览体验。
好了,以上就是本次关于 CSP 理论上的梳理,下一期我们从代码层面来分别试一试各个安全策略的具体效果,我们下次再见👋~