使用严格的内容安全政策 (CSP) 缓解跨站脚本攻击 (XSS)

Lukas Weichselbaum
Lukas Weichselbaum

浏览器支持

  • Chrome:52.
  • Edge:79。
  • Firefox:52.
  • Safari:15.4。

来源

跨站脚本攻击 (XSS) 是一种能够将恶意脚本注入 Web 应用的攻击,在过去十多年来一直是最大的 Web 安全漏洞之一。

内容安全政策 (CSP) 是一项额外的安全措施,有助于防范 XSS 攻击。如需配置 CSP,请将 Content-Security-Policy HTTP 标头添加到网页,并设置用于控制用户代理可以为该网页加载哪些资源的值。

本页介绍了如何使用基于 Nonce 或哈希的 CSP 来防范 XSS,而不是使用常用的基于主机许可名单的 CSP,因为后者通常会使网页容易受到 XSS 攻击,因为它们在大多数配置中都可以被绕过

关键字:“Nonce”是一种只能使用一次的随机数字,可用于将 <script> 代码标记为可信。

关键字:哈希函数是一种数学函数,用于将输入值转换为称为哈希的压缩数值。您可以使用哈希(例如 SHA-256)将内嵌 <script> 标记为可信。

基于 Nonce 或哈希值的内容安全政策通常称为严格 CSP。当应用使用严格的 CSP 时,发现 HTML 注入缺陷的攻击者通常无法利用这些缺陷来强制浏览器在存在漏洞的文档中执行恶意脚本。这是因为严格 CSP 仅允许使用经过哈希处理的脚本或在服务器上生成正确 Nonce 值的脚本,因此攻击者在不知道给定响应的正确 Nonce 值的情况下无法执行脚本。

为何应使用严格的 CSP?

如果您的网站已有类似 script-src www.googleapis.com 的 CSP,那么它可能无法有效防范跨网站攻击。这种类型的 CSP 称为许可名单 CSP。它们需要大量的自定义设置,可能会被攻击者绕过

基于加密 Nonce 或哈希的严格 CSP 可避免这些陷阱。

严格 CSP 结构

基本严格的内容安全政策使用以下 HTTP 响应标头之一:

基于 Nonce 的严格 CSP

Content-Security-Policy:
  script-src 'nonce-{RANDOM}' 'strict-dynamic';
  object-src 'none';
  base-uri 'none';
基于 Nonce 的严格 CSP 的工作原理。

基于哈希的严格 CSP

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

以下属性使得本例的 CSP“严格”,因而安全:

  • 它使用 Nonce 'nonce-{RANDOM}' 或哈希 'sha256-{HASHED_INLINE_SCRIPT}' 来指明网站开发者信任哪些 <script> 标记在用户的浏览器中执行。
  • 它会设置 'strict-dynamic',以自动允许执行可信脚本创建的脚本,从而减少部署基于 Nonce 或哈希的 CSP 的工作量。这也会解锁大多数第三方 JavaScript 库和 widget 的使用。
  • 它不是基于网址许可名单,因此不会受到常见 CSP 绕过攻击的影响。
  • 它会屏蔽不受信任的内嵌脚本,例如内嵌事件处理脚本或 javascript: URI。
  • 它会限制 object-src 停用 Flash 等危险插件。
  • 它会限制 base-uri 以阻止注入 <base> 代码。这可以防止攻击者更改从相对网址加载的脚本的位置。

采用严格 CSP

若要采用严格的 CSP,您需要:

  1. 确定您的应用应设置基于 Nonce 或基于哈希的 CSP。
  2. 严格 CSP 结构部分复制 CSP,并将其设置为应用中的响应标头。
  3. 重构 HTML 模板和客户端代码,以移除与 CSP 不兼容的模式。
  4. 部署您的 CSP。

在此过程中,您可以使用 Lighthouse(v7.3.0 及更高版本,带有标志 --preset=experimental)的最佳实践审核功能,检查您的网站是否具有 CSP,以及 CSP 是否足够严格,能否有效防范 XSS 攻击。

Lighthouse 报告警告,称在强制模式下找不到任何 CSP。
如果您的网站没有 CSP,Lighthouse 会显示此警告。

第 1 步:确定您是否需要基于 Nonce 或哈希的 CSP

以下是两种严格 CSP 的运作方式:

基于 Nonce 的 CSP

使用基于 Nonce 的 CSP 时,您可以在运行时生成随机数,将其包含在 CSP 中,并将其与网页中的每个脚本标记相关联。攻击者无法在您的网页中包含或运行恶意脚本,因为他们需要猜出该脚本的正确随机数。这仅在数字不可猜测时才有效,并且是在运行时为每个响应新生成的。

对在服务器上呈现的 HTML 网页使用基于 Nonce 的 CSP。对于这些页面,您可以为每个响应创建一个新的随机数字。

基于哈希的 CSP

对于基于哈希的 CSP,系统会将每个内嵌脚本标记的哈希添加到 CSP。每个脚本都有不同的哈希。攻击者无法在您的网页中包含或运行恶意脚本,因为只有在 CSP 中包含该脚本的哈希才能运行该脚本。

针对静态传送的 HTML 网页或需要缓存的网页使用基于哈希的 CSP。例如,您可以针对使用 Angular、React 或其他框架构建的单页 Web 应用使用基于哈希的 CSP,这些应用以静态方式提供,无需进行服务器端呈现。

第 2 步:设置严格的 CSP 并准备脚本

设置 CSP 时,您有以下几种选项:

  • “仅报告”模式 (Content-Security-Policy-Report-Only) 或强制执行模式 (Content-Security-Policy)。在“仅限报告”模式下,CSP 不会阻止资源,因此您网站上的任何内容都不会中断,但您可以查看错误,并获取关于任何可能被屏蔽的内容的报告。在本地设置 CSP 时,这一点并不重要,因为这两种模式都会在浏览器控制台中向您显示错误。强制执行模式可以帮助您找到草稿 CSP 屏蔽的资源,因为屏蔽资源可能会导致网页看起来损坏。在后续流程中,报告模式最为实用(请参阅第 5 步)。
  • 标头或 HTML <meta> 标记。对于本地开发,<meta> 标记可以更方便地调整 CSP 并快速了解其对网站的影响。不过:
    • 之后,在生产环境中部署 CSP 时,我们建议将其设置为 HTTP 标头。
    • 如果您想将 CSP 设置为仅报告模式,则需要将其设置为标头,因为 CSP 元标记不支持仅报告模式。

选项 A:基于 Nonce 的 CSP

在应用中设置以下 Content-Security-Policy HTTP 响应标头:

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

为 CSP 生成 Nonce

Nonce 是每次网页加载时仅使用一次的随机数字。只有在攻击者无法猜出 Nonce 值的情况下,基于 Nonce 的 CSP 才能缓解 XSS 攻击。CSP 随机数必须:

  • 强加密随机值(长度最好超过 128 位)
  • 系统会为每条回复生成新的 ID
  • Base64 编码

下面是一些在服务器端框架中添加 CSP Nonce 的示例:

const app = express();

app.get('/', function(request, response) {
  // Generate a new random nonce value for every response.
  const nonce = crypto.randomBytes(16).toString("base64");

  // Set the strict nonce-based CSP response header
  const csp = `script-src 'nonce-${nonce}' 'strict-dynamic'; object-src 'none'; base-uri 'none';`;
  response.set("Content-Security-Policy", csp);

  // Every <script> tag in your application should set the `nonce` attribute to this value.
  response.render(template, { nonce: nonce });
});

<script> 元素添加 nonce 属性

对于基于 Nonce 的 CSP,每个 <script> 元素必须具有与 CSP 标头中指定的随机 Nonce 值匹配的 nonce 属性。所有脚本都可以使用相同的 Nonce。第 1 步是将这些属性添加到所有脚本,以便 CSP 允许使用这些属性。

选项 B:基于哈希的 CSP 响应标头

在应用中设置以下 Content-Security-Policy HTTP 响应标头:

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

对于多个内嵌脚本,语法如下:'sha256-{HASHED_INLINE_SCRIPT_1}' 'sha256-{HASHED_INLINE_SCRIPT_2}'

动态加载来源脚本

您可以使用内嵌脚本动态加载第三方脚本。

有关如何内嵌脚本的示例。
CSP 允许
<script>
  var scripts = [ 'https://example.org/foo.js', 'https://example.org/bar.js'];

  scripts.forEach(function(scriptUrl) {
    var s = document.createElement('script');
    s.src = scriptUrl;
    s.async = false; // to preserve execution order
    document.head.appendChild(s);
  });
</script>
如需运行此脚本,您必须计算内嵌脚本的哈希,并将其添加到 CSP 响应标头中,替换 {HASHED_INLINE_SCRIPT} 占位符。如需减少哈希的数量,您可以将所有内嵌脚本合并到一个脚本中。如需查看此操作的实际效果,请参阅此示例及其代码
因不符合 CSP 而被屏蔽
<script src="https://example.org/foo.js"></script>
<script src="https://example.org/bar.js"></script>
CSP 会屏蔽这些脚本,因为它们并非动态添加的,并且没有与允许的来源匹配的 integrity 属性。

脚本加载注意事项

内嵌脚本示例添加了 s.async = false,以确保 foobar 之前执行,即使 bar 先加载也是如此。在此代码段中,s.async = false 不会在脚本加载时阻止解析器,因为脚本是动态添加的。解析器仅在脚本执行期间停止,就像 async 脚本一样。不过,使用此代码段时,请注意以下几点:

  • 一个或两个脚本可能会在文档下载完成之前执行。如果您希望文档在脚本执行时准备就绪,请在附加脚本之前等待 DOMContentLoaded 事件。如果由于脚本未尽早开始下载而导致性能问题,请在网页上更早使用预加载标记
  • defer = true 不会执行任何操作。如果您需要此行为,请在需要时手动运行脚本。

第 3 步:重构 HTML 模板和客户端代码

内嵌事件处理脚本(例如 onclick="…"onerror="…")和 JavaScript URI (<a href="javascript:…">) 可用于运行脚本。这意味着发现 XSS 错误的攻击者可以注入这种 HTML 并执行恶意 JavaScript。基于 Nonce 或哈希的 CSP 禁止使用此类标记。 如果您的网站使用了上述任何模式,您需要将其重构为更安全的替代方案。

如果您在上一步中启用了 CSP,那么每当 CSP 屏蔽不兼容的模式时,您都会在控制台中看到 CSP 违规情况。

Chrome 开发者控制台中的 CSP 违规报告。
针对已屏蔽代码的控制台错误。

在大多数情况下,解决方法很简单:

重构内嵌事件处理脚本

CSP 允许
<span id="things">A thing.</span>
<script nonce="${nonce}">
  document.getElementById('things').addEventListener('click', doThings);
</script>
CSP 允许使用 JavaScript 注册的事件处理脚本。
因不符合 CSP 而被屏蔽
<span onclick="doThings();">A thing.</span>
CSP 会屏蔽内嵌事件处理脚本。

重构 javascript: URI

CSP 允许
<a id="foo">foo</a>
<script nonce="${nonce}">
  document.getElementById('foo').addEventListener('click', linkClicked);
</script>
CSP 允许使用使用 JavaScript 注册的事件处理脚本。
已被 CSP 阻止
<a href="javascript:linkClicked()">foo</a>
CSP 会屏蔽 javascript: URI。

从 JavaScript 中移除 eval()

如果您的应用使用 eval() 将 JSON 字符串序列化转换为 JS 对象,则应将此类实例重构为 JSON.parse(),这样做速度也会更快。

如果您无法移除对 eval() 的所有用法,仍然可以设置基于 Nonce 的严格 CSP,但必须使用 'unsafe-eval' CSP 关键字,这会使您的政策的安全性略有降低。

您可以在以下严格 CSP Codelab 中找到这些和更多此类重构示例:

第 4 步(可选):添加后备版本以支持旧版浏览器

浏览器支持

  • Chrome:52.
  • Edge:79。
  • Firefox:52。
  • Safari:15.4。

来源

如果您需要支持旧版浏览器,请执行以下操作:

  • 使用 strict-dynamic 需要添加 https: 作为早期版本的 Safari 的回退。执行此操作后:
    • 所有支持 strict-dynamic 的浏览器都会忽略 https: 回退,因此这不会降低政策的强度。
    • 在旧版浏览器中,只有来自 HTTPS 来源的外部来源脚本才能加载。这比严格 CSP 的安全性要低,但仍可防止一些常见的 XSS 原因,例如 javascript: URI 注入。
  • 为确保与非常旧的浏览器版本(4 年以上)兼容,您可以添加 unsafe-inline 作为回退选项。如果存在 CSP Nonce 或哈希,所有近期的浏览器都会忽略 unsafe-inline
Content-Security-Policy:
  script-src 'nonce-{random}' 'strict-dynamic' https: 'unsafe-inline';
  object-src 'none';
  base-uri 'none';

第 5 步:部署您的 CSP

在确认您的 CSP 不会阻止本地开发环境中的任何合法脚本后,您可以将 CSP 部署到预演环境,然后再部署到生产环境:

  1. (可选)使用 Content-Security-Policy-Report-Only 标头在“仅限报告”模式下部署 CSP。“仅报告”模式非常便于在开始强制执行 CSP 限制之前在生产环境中测试具有重大影响的更改(例如新的 CSP)。在仅生成报告模式下,CSP 不会影响应用的行为,但当浏览器遇到与 CSP 不兼容的模式时,仍会生成控制台错误和违规报告,以便您了解最终用户会遇到哪些问题。如需了解详情,请参阅 Reporting API
  2. 如果您确信 CSP 不会破坏最终用户的网站,请使用 Content-Security-Policy 响应标头部署 CSP。我们建议您使用 HTTP 标头服务器端设置 CSP,因为它比 <meta> 标记更安全。完成此步骤后,CSP 开始保护您的应用免受 XSS 攻击。

限制

严格的 CSP 通常会提供一层强大的额外安全保障,有助于防范 XSS 攻击。在大多数情况下,CSP 通过拒绝 javascript: URI 等危险模式,可显著缩小攻击面。不过,在某些情况下,CSP 无法保护您的应用,具体取决于您使用的 CSP 类型(Nonces、哈希,以及是否使用 'strict-dynamic'):

  • 如果您为脚本设置了 Nonce,但有代码直接注入到该 <script> 元素的正文或 src 参数中。
  • 向动态创建的脚本的位置 (document.createElement('script')) 注入了注入事件,包括注入任何基于其参数值创建 script DOM 节点的库函数。这包括一些常见 API,例如 jQuery 的 .html(),以及 jQuery 3.0 以下的 .get().post()
  • 如果旧版 AngularJS 应用中有模板注入。可注入 AngularJS 模板的攻击者可以使用该模板执行任意 JavaScript
  • 如果政策包含 'unsafe-eval',则会注入 eval()setTimeout() 和一些其他很少使用的 API。

在代码审核和安全审核期间,开发者和安全工程师应特别注意此类模式。如需详细了解这些案例,请参阅内容安全政策:增强和缓解之间的成功混乱

深入阅读