Content Security Policy

深入讲解CSP的工作原理、指令系统、Nonce/Hash方案以及渐进式部署策略。

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-inlineunsafe-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或类似技术栈存储和可视化报告数据。分析常见的违规来源和模式,识别需要优先处理的技术债务。

延展阅读

关键术语

术语 解释
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模式