吃透 CSP:现代 Web 安全的核心机制

626 阅读15分钟

你好,我是 hockor,作为 Web 开发工程师,我们时刻面临着各种安全威胁,其中跨站脚本攻击(XSS)是最常见也是危害最大的漏洞之一。攻击者通过注入恶意脚本,可以窃取用户敏感信息、劫持会话甚至篡改网页内容。传统的防御手段(如输入验证和输出编码)虽然重要,但往往难以做到滴水不漏。这时,浏览器提供的一项重要安全特性——Content Security Policy (CSP)——就显得尤为关键。那么今天,让我们先从理论层面好好来梳理一下 CSP,看看它到底有哪些约束来保障我们的 web 内容的安全性,下一期我们再从代码演示上来分别展示。

CSP 本质上是建立一个内容安全白名单,通过 HTTP 头或 <meta> 标签告知浏览器哪些外部资源(脚本、样式、图片、字体、框架等)允许被加载和执行。如果资源的来源不在白名单内,浏览器就会拒绝加载,从而有效地阻止了大部分 XSS 攻击以及其他类型的内容注入攻击。

如下图,抖音官网的响应头中关于 CSP 相关的配置

为什么需要 CSP?

想象一下,你的网站有一个留言板功能。如果没有对用户输入进行严格的过滤,攻击者可能会提交包含恶意 <script> 标签的留言。当其他用户浏览这条留言时,浏览器会解析并执行这个恶意脚本,造成 XSS 攻击。

传统的防御: 对用户输入进行 HTML 转义,例如将 < 转换为 &lt;。但这依赖于开发者对所有潜在输入点的严格控制,容易遗漏。

CSP 的防御: 通过设置 CSP,你可以限制哪些域名下的脚本可以执行,甚至完全禁止页面执行内联脚本(<script>...)或通过 eval() 执行字符串代码。这样一来,即使攻击者成功注入了 <script> 标签,如果它的来源或执行方式不符合 CSP 策略,浏览器也会拒绝执行它,从而大大降低了 XSS 的风险。

除了 XSS,CSP 还能防御:

  • 数据注入攻击: 通过限制表单提交的目标(form-action)。
  • 点击劫持: 通过限制页面是否可以在 <frame><iframe> 中嵌套(frame-ancestors)。
  • 混淆内容攻击: 强制使用 HTTPS 连接(upgrade-insecure-requests)。

CSP 的工作原理

CSP 的核心是定义一系列指令 (Directives) 及其对应的允许来源 (Sources)。这些策略可以通过以下两种方式发送给浏览器:

  1. HTTP 响应头 (推荐): 在服务器的 HTTP 响应中添加 Content-Security-PolicyContent-Security-Policy-Report-Only 头部。

    Content-Security-Policy: default-src 'self'; script-src 'self' https://cdnjs.cloudflare.com; style-src 'self' 'unsafe-inline'
    

    这是最推荐的方式,因为它可以覆盖整个页面,并且策略是在页面内容解析之前生效的。

  2. <meta> 标签: 在页面的 <head> 中添加一个 <meta> 标签。

    <meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self'">
    

    这种方式优先级较低,并且不能用于 report-urireport-toframe-ancestors 等指令。

Content-Security-Policy-Report-Only 头部:

这个头部用于报告违反 CSP 策略的行为,但并不阻止这些行为。这对于在生产环境中测试新的 CSP 策略非常有帮助。你可以在不影响用户体验的情况下,观察哪些资源会被新的策略阻止,然后根据报告调整策略,直到满意为止,最后再切换到 Content-Security-Policy 头部。

CSP 发展历程

时间标准版本状态关键特性网址
2012CSP Level 1已废弃基本资源限制www.w3.org/TR/CSP1/
2014CSP Level 2推荐使用nonce、hash、报告机制www.w3.org/TR/CSP2/
2021CSP Level 3草案strict-dynamic、report-towww.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-srcworker-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>
  • 'sha256-<base64-hash>': 允许具有特定哈希值的内联脚本或样式。你可以计算内联脚本或样式的哈希值(支持 SHA256, SHA384, SHA512),并将其添加到 CSP 头中。
    • 示例 CSP 头: script-src 'sha256-abcdefg...'
    • 示例 HTML: <script>alert('Hello, CSP!');</script> (你需要计算 alert('Hello, CSP!'); 这段代码的 SHA256 哈希值)
  • '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 也会被允许加载)

使用 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 策略通常是基于来源路径,这与哈希文件名不冲突。

解决方案:

  1. 最小化内联代码: 尽量减少构建工具生成的内联脚本和样式。
  2. 使用 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 注入到生成的标签中。
    • Hash: 适用于内容不变的静态内联脚本或样式。
      • 实现: 构建完成后,计算所有内联脚本和样式的哈希值(SHA256, SHA384 或 SHA512)。将这些哈希值添加到 CSP 响应头的 script-src 和/或 style-src 指令中,例如 script-src 'sha256-HASH_OF_SCRIPT1' 'sha256-HASH_OF_SCRIPT2' 'self' ...;
      • 这种方法对于每次构建都会变化的内联代码(如包含构建时间戳的代码)不太实用。
  3. 使用 'strict-dynamic' (与 Nonce/Hash 配合): 当使用 noncehash 允许了初始脚本后,可以使用 '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' 通常仍然有效作为回退。
  4. 配置允许的来源: 对于外部引用的资源(JS, CSS, Images 等),根据你的项目依赖,在 CSP 策略中明确列出所有允许的 CDN、API 服务等来源。

实施 CSP 的建议步骤

  1. 分析现有应用: 了解你的应用加载了哪些外部资源,哪些使用了内联脚本或样式,哪些使用了 eval()
  2. report-only 开始: 在生产环境中使用 Content-Security-Policy-Report-Only 头部和 report-uri 指令。部署策略,并收集违规报告,分析哪些合法的资源被阻止了。
  3. 逐步收紧策略: 根据报告调整策略,逐渐移除 'unsafe-inline''unsafe-eval',明确列出允许的来源。优先解决 script-srcdefault-src 的问题。
  4. 考虑 Nonce 或 Hash: 如果确实需要内联脚本(如构建工具生成的运行时代码),集成 Nonce 或 Hash 机制。考虑使用 'strict-dynamic' 简化管理。
  5. 切换到强制模式: 当你确信策略不会阻止应用的正常功能时,将 Content-Security-Policy-Report-Only 切换为 Content-Security-Policy
  6. 持续监控: 即使在强制模式下,也要继续使用 report-urireport-to 收集报告,以便及时发现新的问题或潜在的攻击尝试。
  7. 结合其他安全措施: CSP 是重要的安全层,但不能替代输入验证、输出编码、HTTP Strict Transport Security (HSTS)、SameSite cookies 等其他安全实践。

总结

Content Security Policy (CSP) 是现代 Web 安全中不可或缺的一部分,它是防御 XSS 和其他内容注入攻击的强大武器。通过明确声明页面允许加载和执行的资源来源,CSP 为浏览器提供了一个安全基线,即使其他防御措施失效,也能在一定程度上限制攻击的危害范围。

虽然部署 CSP 需要一些工作来分析现有应用并调整策略,但这项投入是值得的。从 report-only 模式开始,逐步收紧策略,结合 Nonce 或 Hash 处理内联代码,并利用 'strict-dynamic' 简化复杂应用的策略管理,你就能有效地提升你的 Web 应用的安全性,为你的用户提供更安全的浏览体验。

好了,以上就是本次关于 CSP 理论上的梳理,下一期我们从代码层面来分别试一试各个安全策略的具体效果,我们下次再见👋~