跨站请求伪造 (CSRF)

在跨站请求伪造 (CSRF) 攻击中,攻击者会欺骗用户或浏览器,使其从恶意网站向目标网站发起一个 HTTP 请求。该请求包含用户的凭据,并导致服务器执行一些有害操作,而服务器却认为这是用户本意。

概述

网站通常会代表用户执行特殊操作——例如,购买产品或进行预约——通过接收来自用户浏览器的 HTTP 请求,其中通常包含详细说明要执行的操作的参数。为了确保请求确实来自相关用户,服务器期望请求包含用户的凭据:例如,包含用户会话 ID 的 cookie。

在下面的示例中,用户之前已登录到其银行,并且浏览器已存储了用户的会话 cookie。该页面包含一个<form> 元素,允许用户将资金转账给他人。当用户提交表单时,浏览器会向服务器发送一个POST 请求,其中包含表单数据。如果用户已登录,则请求将包含用户的 cookie。服务器会验证 cookie 并执行特殊操作——在这种情况下,是转账。

Diagram showing a user submitting a browser form, the browser then making a POST request to the server, and the server validating the request.

在本指南中,我们将这种执行特殊操作的请求称为“状态更改请求”。

在 CSRF 攻击中,攻击者会创建一个包含表单的网站。表单的action 属性设置为银行的网站,并且表单包含模仿银行字段的隐藏输入字段。

html
<form action="https://my-bank.example.org/transfer" method="POST">
  <input type="hidden" name="recipient" value="attacker" />
  <input type="hidden" name="amount" value="1000" />
</form>

页面还包含在页面加载时提交表单的 JavaScript。

js
const form = document.querySelector("form");
form.submit();

当用户访问该页面时,浏览器会将表单提交到银行的网站。由于用户已登录到其银行,因此请求可能会包含用户的真实 cookie,从而银行服务器成功验证请求并转账。

Diagram showing a CSRF attack in which a decoy page submits a POST request to the website for the user's bank.

攻击者还可以通过其他方式发起跨站请求伪造。例如,如果网站使用GET 请求来执行操作,那么攻击者就可以完全避免使用表单,并通过向用户发送一个包含如下标记的页面的链接来执行攻击:

html
<img
  src="https://my-bank.example.org/transfer?recipient=attacker&amount=1000" />

当用户加载页面时,浏览器会尝试获取图像资源,而这实际上是事务请求。

通常,如果您的网站执行以下操作,则可能发生 CSRF 攻击:

  • 使用 HTTP 请求更改服务器上的某些状态。
  • 仅使用 cookie 来验证请求是否来自已认证用户。
  • 仅使用攻击者可以预测的请求中的参数。

防范 CSRF 的方法

在本节中,我们将概述三种替代的 CSRF 防御方法以及一种可用于为其中任何一种提供纵深防御的第四种实践。

  • 第一个主要防御措施是使用嵌入在页面中的CSRF 令牌。这是最常见的方法,如果您是从表单元素发出状态更改请求,就像我们上面的示例一样。

  • 第二个是使用Fetch metadata HTTP 标头来检查状态更改请求是否是跨站发起的。

  • 第三个是确保状态更改请求不是简单请求,这样跨源请求默认就会被阻止。此方法适用于您通过 JavaScript API(如fetch())发出状态更改请求的情况。

最后,我们将讨论SameSite cookie 属性,它可以用于为前述任何一种方法提供纵深防御。

CSRF 令牌

在此防御机制中,当服务器提供页面时,它会在页面中嵌入一个不可预测的值,称为 CSRF 令牌。然后,当合法页面将状态更改请求发送到服务器时,它会在 HTTP 请求中包含 CSRF 令牌。服务器随后可以检查令牌值,仅在匹配时才执行请求。由于攻击者无法猜测令牌值,因此他们无法成功发起伪造。即使攻击者在令牌被使用后发现它,如果令牌每次都发生变化,请求也无法重放。

对于表单提交,CSRF 令牌通常包含在一个隐藏的表单字段中,以便在表单提交时自动发送回服务器进行检查。

对于像 fetch() 这样的 JavaScript API,令牌可能会放在 cookie 中或嵌入在页面中,然后 JavaScript 会提取该值并将其作为额外的标头发送。

现代 Web 框架通常内置对 CSRF 令牌的支持:例如,Django 允许您使用csrf_token 标签来保护表单。这会生成一个包含令牌的额外隐藏表单字段,然后在服务器上由框架进行检查。

要利用此保护,您必须了解您网站中所有使用状态更改 HTTP 请求的地方,并确保您正在使用所选框架提供的保护。

Fetch metadata

Fetch metadata 是一组由浏览器添加的 HTTP 请求标头,它们提供有关 HTTP 请求上下文的额外信息。服务器可以使用这些标头来决定是否允许请求。

与 CSRF 最相关的是Sec-Fetch-Site 标头,它告诉服务器该请求是同源、同站、跨站还是用户直接发起的。服务器可以使用此信息允许跨源请求,或阻止它们作为潜在的 CSRF 攻击。

例如,这段Express代码只允许同站和同源请求。

js
app.post("/transfer", (req, res) => {
  const secFetchSite = req.headers["sec-fetch-site"];
  if (secFetchSite === "same-origin" || secFetchSite === "same-site") {
    console.log("allowed");
    // Update state
  } else {
    console.log("denied");
    // Don't update state
  }
});

有关 Fetch metadata 标头的完整列表,请参阅Fetch metadata 请求标头,有关使用此功能的指南,请参阅使用 Fetch Metadata 保护您的资源免受 Web 攻击

避免简单请求

Web 浏览器区分两种 HTTP 请求:简单请求和其他请求。

简单请求(这是由 <form> 元素提交引起的请求类型)可以在不被阻止的情况下跨源发出。由于 Web 早期以来表单就能发起跨源请求,因此出于兼容性考虑,它们仍应能够发起跨源请求。这就是为什么我们需要实现其他策略来防御表单免受 CSRF 攻击,例如使用 CSRF 令牌。

然而,Web 平台的其他部分,特别是像 fetch() 这样的 JavaScript API,可以发出不同类型的请求(例如,设置自定义标头的请求),而这些请求默认情况下不允许跨源,因此 CSRF 攻击不会成功。

因此,使用 fetch()XMLHttpRequest 的网站可以通过确保其发出的状态更改请求永远不是简单请求来防御 CSRF。

例如,将请求的 Content-Type 设置为 "application/json" 将阻止它被视为简单请求。

js
fetch("https://my-bank.example.org/transfer", {
  method: "POST",
  headers: {
    "Content-Type": "application/json",
  },
  body: JSON.stringify({ recipient: "joe", amount: "100" }),
});

同样,在请求中设置自定义标头也将阻止它被视为简单请求。

js
fetch("https://my-bank.example.org/transfer", {
  method: "POST",
  headers: {
    "X-MY-BANK-ANTI-CSRF": 1,
  },
  body: JSON.stringify({ recipient: "joe", amount: "100" }),
});

标头名称可以是任何名称,只要它不与标准标头冲突即可。

服务器随后可以检查该标头是否存在:如果存在,则服务器知道该请求未被视为简单请求。

非简单请求和 CORS

我们已经说过,非简单请求默认情况下不会跨源发送。关键在于 跨源资源共享 (CORS) 协议允许网站放宽此限制。

具体来说,如果您的网站对状态更改请求的响应包含以下内容,那么它将容易受到来自特定来源的 CSRF 攻击:

  • Access-Control-Allow-Origin 响应标头,并且该标头列出了发送方的来源。
  • Access-Control-Allow-Credentials 响应标头。

纵深防御:SameSite cookie

SameSite cookie 属性提供了一些针对 CSRF 攻击的保护。它不是一个完整的防御措施,最好将其视为其他防御措施的补充,提供一定程度的纵深防御。

此属性控制浏览器何时允许在跨站请求中包含 cookie。它有三个可能的值:NoneLaxStrict

Strict 值提供最强的保护:如果设置了此属性,浏览器将不会在任何跨站请求中包含 cookie。然而,这会带来可用性问题:如果用户登录到您的网站,然后从另一个网站链接到您的网站,那么您的 cookie 将不会被包含,并且用户到达您的网站时将不被识别。

Lax 值放宽了此限制:如果满足以下两个条件,则 cookie 会包含在跨站请求中:

  • 请求是顶级浏览上下文的导航。
  • 请求使用了安全的方法:特别是,GET 是安全的,但POST 不是。

然而,Lax 提供的保护比 Strict 弱得多。

  • 攻击者可以触发顶级导航。例如,在本文开头,我们展示了一个 CSRF 攻击,其中攻击者将表单提交给目标:这被视为顶级导航。如果表单使用 GET 提交,则请求仍会包含带有 SameSite=Lax 的 cookie。
  • 即使服务器确实检查了请求是否未使用 GET 发送,某些 Web 框架也支持“方法覆盖”:这允许攻击者使用 GET 发送请求,但让服务器认为它使用了 POST

总的来说,您应该尝试对某些 cookie 使用 Strict,对其他 cookie 使用 Lax

  • 对用于确定已登录用户是否应显示页面的 cookie 使用 Lax
  • 对用于状态更改请求且您不想允许跨站访问的 cookie 使用 Strict

SameSite 属性的另一个问题是它保护您免受来自不同站点的请求,而不是不同来源的请求。这是一种更宽松的保护,因为(例如)https://foo.example.orghttps://bar.example.org 被视为同一个站点,尽管它们是不同的来源。实际上,如果您依赖同站保护,则必须信任您站点中的所有子域名。

有关 SameSite 限制的更多详细信息,请参阅绕过 SameSite cookie 限制

防御总结清单

  • 了解您网站中实现状态更改请求的位置,这些请求使用会话 cookie 来检查是哪个用户发出了请求。
  • 实施本文档中描述的至少一种主要防御措施。
    • 如果您使用 <form> 元素发出这些请求,请确保您使用的是支持 CSRF 令牌的 Web 框架,并加以使用。
    • 如果您使用 fetch()XMLHttpRequest 等 JavaScript API 发出状态更改请求,请确保它们不是简单请求。
    • 无论您使用哪种机制发出请求,都请考虑使用 Fetch metadata 来禁止跨站请求。
  • 避免使用 GET 方法发出状态更改请求。
  • 如果可能,将会话 cookie 的 SameSite 属性设置为 Strict,如果必须,则设置为 Lax

另见