跨站请求伪造 (CSRF)

Spring 为抵御 跨站请求伪造 (CSRF) 攻击提供了全面的支持。在以下章节中,我们将探讨

文档的这一部分讨论了 CSRF 防护的通用主题。有关基于 servletWebFlux 应用的 CSRF 防护的具体信息,请参阅相关章节。

什么是 CSRF 攻击?

理解 CSRF 攻击的最好方法是看一个具体的例子。

假设你的银行网站提供一个表单,允许将资金从当前登录用户转移到另一个银行账户。例如,转账表单可能看起来像这样:

转账表单
<form method="post"
	action="/transfer">
<input type="text"
	name="amount"/>
<input type="text"
	name="routingNumber"/>
<input type="text"
	name="account"/>
<input type="submit"
	value="Transfer"/>
</form>

对应的 HTTP 请求可能看起来像这样:

转账 HTTP 请求
POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876

现在假设你登录了你的银行网站,然后,在没有注销的情况下,访问了一个恶意网站。该恶意网站包含一个带有以下表单的 HTML 页面:

恶意转账表单
<form method="post"
	action="https://bank.example.com/transfer">
<input type="hidden"
	name="amount"
	value="100.00"/>
<input type="hidden"
	name="routingNumber"
	value="evilsRoutingNumber"/>
<input type="hidden"
	name="account"
	value="evilsAccountNumber"/>
<input type="submit"
	value="Win Money!"/>
</form>

你喜欢赢钱,所以你点击了提交按钮。在这个过程中,你无意中向恶意用户转移了 100 美元。这是因为,虽然恶意网站无法看到你的 Cookie,但与你的银行相关的 Cookie 仍然随请求一起发送。

更糟糕的是,整个过程可以通过 JavaScript 自动化。这意味着你甚至不需要点击按钮。此外,当你访问一个受到 XSS 攻击 的诚实网站时,这也很容易发生。那么我们如何保护用户免受此类攻击呢?

应对 CSRF 攻击的防护

CSRF 攻击之所以可能发生,是因为受害者网站发送的 HTTP 请求与攻击者网站发送的请求完全相同。这意味着没有办法拒绝来自恶意网站的请求,而只允许来自银行网站的请求。为了防止 CSRF 攻击,我们需要确保请求中包含一些恶意网站无法提供的东西,以便我们可以区分这两个请求。

Spring 提供了两种机制来防止 CSRF 攻击:

这两种防护都需要 安全方法是只读的

安全方法必须是只读的

为了使 任一种防护 起作用,应用程序必须确保 “安全”的 HTTP 方法是只读的。这意味着使用 HTTP GETHEADOPTIONSTRACE 方法的请求不应该改变应用程序的状态。

Synchronizer Token Pattern

防止 CSRF 攻击的主要且最全面的方法是使用 Synchronizer Token Pattern。此解决方案是确保每个 HTTP 请求除了我们的会话 Cookie 外,还需要在 HTTP 请求中包含一个安全的随机生成值,称为 CSRF 令牌。

提交 HTTP 请求时,服务器必须查找预期的 CSRF 令牌,并将其与 HTTP 请求中的实际 CSRF 令牌进行比较。如果值不匹配,则应拒绝该 HTTP 请求。

这种方法奏效的关键在于实际的 CSRF 令牌应位于 HTTP 请求中浏览器不会自动包含的部分。例如,要求在 HTTP 参数或 HTTP 头中包含实际的 CSRF 令牌将防止 CSRF 攻击。要求在 Cookie 中包含实际的 CSRF 令牌不起作用,因为 Cookie 会由浏览器自动包含在 HTTP 请求中。

我们可以放宽要求,仅对每个更新应用程序状态的 HTTP 请求强制要求实际的 CSRF 令牌。为此,我们的应用程序必须确保 安全 HTTP 方法是只读的。这提高了可用性,因为我们希望允许从外部网站链接到我们的网站。此外,我们不希望在 HTTP GET 请求中包含随机令牌,因为这可能导致令牌泄露。

考虑一下当使用 Synchronizer Token Pattern 时, 我们的示例 会如何改变。假设实际的 CSRF 令牌必须位于名为 _csrf 的 HTTP 参数中。我们的应用程序的转账表单将如下所示:

Synchronizer Token 表单
<form method="post"
	action="/transfer">
<input type="hidden"
	name="_csrf"
	value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
<input type="text"
	name="amount"/>
<input type="text"
	name="routingNumber"/>
<input type="hidden"
	name="account"/>
<input type="submit"
	value="Transfer"/>
</form>

表单现在包含一个带有 CSRF 令牌值的隐藏输入字段。外部网站无法读取 CSRF 令牌,因为同源策略确保恶意网站无法读取响应。

对应的转账 HTTP 请求将如下所示:

Synchronizer Token 请求
POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded

amount=100.00&routingNumber=1234&account=9876&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721

你会注意到 HTTP 请求现在包含带有安全随机值的 _csrf 参数。恶意网站将无法提供正确的 _csrf 参数值(必须在恶意网站上明确提供),当服务器比较实际 CSRF 令牌和预期 CSRF 令牌时,转账将失败。

SameSite 属性

一种新的防止 CSRF 攻击 的方法是在 Cookie 上指定 SameSite 属性。服务器在设置 Cookie 时可以指定 SameSite 属性,以指示当请求来自外部站点时,不应发送该 Cookie。

Spring Security 不直接控制会话 Cookie 的创建,因此它不直接支持 SameSite 属性。Spring Session 为基于 Servlet 的应用程序提供了 SameSite 属性的支持。Spring Framework 的 CookieWebSessionIdResolver 为基于 WebFlux 的应用程序提供了开箱即用的 SameSite 属性支持。

例如,带有 SameSite 属性的 HTTP 响应头可能看起来像这样:

SameSite HTTP 响应
Set-Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly; SameSite=Lax

SameSite 属性的有效值有:

  • Strict:指定后,任何来自 同站点 的请求都会包含 Cookie。否则,Cookie 不包含在 HTTP 请求中。

  • Lax:指定后,当请求来自 同站点 或来自顶级导航且 方法是只读的 时,Cookie 会被发送。否则,Cookie 不包含在 HTTP 请求中。

考虑一下 我们的示例 如何使用 SameSite 属性进行保护。银行应用程序可以通过在会话 Cookie 上指定 SameSite 属性来防止 CSRF 攻击。

在会话 Cookie 上设置 SameSite 属性后,浏览器会继续向来自银行网站的请求发送 JSESSIONID Cookie。然而,浏览器将不再向来自恶意网站的转账请求发送 JSESSIONID Cookie。由于会话不再存在于来自恶意网站的转账请求中,应用程序将受到 CSRF 攻击的保护。

使用 SameSite 属性防止 CSRF 攻击时,需要注意一些重要的 考虑事项

SameSite 属性设置为 Strict 提供了更强的防御能力,但也可能让用户感到困惑。考虑一个一直登录社交媒体网站 social.example.com 的用户。该用户在 email.example.org 收到一封包含社交媒体网站链接的电子邮件。如果用户点击该链接,他们理应期望能直接登录社交媒体网站。然而,如果 SameSite 属性设置为 Strict,Cookie 将不会被发送,因此用户将无法登录。

另一个需要注意的显而易见的因素是,为了使 SameSite 属性保护用户,浏览器必须支持 SameSite 属性。大多数现代浏览器都 支持 SameSite 属性。然而,仍在使用的旧浏览器可能不支持。

基于这个原因,我们通常建议将 SameSite 属性作为一种深度防御手段,而不是唯一的 CSRF 攻击防护措施。

何时使用 CSRF 防护

你应该何时使用 CSRF 防护?我们的建议是,对于任何可能由普通用户通过浏览器处理的请求,都应使用 CSRF 防护。如果你正在创建一个只供非浏览器客户端使用的服务,你可能希望禁用 CSRF 防护。

CSRF 防护和 JSON

一个常见的问题是:“我需要保护 JavaScript 发起的 JSON 请求吗?” 简短的答案是:取决于具体情况。但是,你必须非常小心,因为存在可能影响 JSON 请求的 CSRF 攻击。例如,恶意用户可以使用 以下表单创建一个带有 JSON 的 CSRF 攻击

带有 JSON 的 CSRF 表单
<form action="https://bank.example.com/transfer" method="post" enctype="text/plain">
	<input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
	<input type="submit"
		value="Win Money!"/>
</form>

这将产生以下 JSON 结构:

带有 JSON 的 CSRF 请求
{ "amount": 100,
"routingNumber": "evilsRoutingNumber",
"account": "evilsAccountNumber",
"ignore_me": "=test"
}

如果应用程序不验证 Content-Type 头,就会暴露在这种攻击之下。根据设置的不同,一个验证 Content-Type 的 Spring MVC 应用仍然可能通过将 URL 后缀更新为 .json 来被利用,如下所示:

带有 JSON 的 CSRF Spring MVC 表单
<form action="https://bank.example.com/transfer.json" method="post" enctype="text/plain">
	<input name='{"amount":100,"routingNumber":"evilsRoutingNumber","account":"evilsAccountNumber", "ignore_me":"' value='test"}' type='hidden'>
	<input type="submit"
		value="Win Money!"/>
</form>

CSRF 与无状态浏览器应用

如果我的应用程序是无状态的怎么办?这并不一定意味着你受到了保护。事实上,如果用户不需要在 Web 浏览器中为某个特定请求执行任何操作,他们很可能仍然容易受到 CSRF 攻击。

例如,考虑一个使用自定义 Cookie 来存储所有状态以进行认证的应用程序(而不是 JSESSIONID)。当发生 CSRF 攻击时,自定义 Cookie 会像 我们之前示例 中发送 JSESSIONID Cookie 的方式一样随请求一起发送。此应用程序容易受到 CSRF 攻击。

使用 basic 认证的应用程序也容易受到 CSRF 攻击。应用程序之所以脆弱,是因为浏览器会自动在任何请求中包含用户名和密码,就像 我们之前示例 中发送 JSESSIONID Cookie 的方式一样。

CSRF 考虑事项

在实现 CSRF 攻击防护时,有一些特殊考虑事项需要考虑。

登录

为了防止 伪造登录请求,登录 HTTP 请求应该受到 CSRF 攻击的保护。保护伪造登录请求是必要的,这样恶意用户就无法读取受害者的敏感信息。攻击过程如下:

  1. 恶意用户使用其凭据执行 CSRF 登录。受害者现在以恶意用户的身份进行认证。

  2. 然后,恶意用户欺骗受害者访问被入侵的网站并输入敏感信息。

  3. 信息与恶意用户的帐户关联,因此恶意用户可以使用自己的凭据登录并查看受害者的敏感信息。

确保登录 HTTP 请求受到 CSRF 攻击保护的一个可能的复杂问题是,用户可能会遇到会话超时,导致请求被拒绝。会话超时对于不期望登录需要会话的用户来说是令人惊讶的。更多信息请参考 CSRF 与会话超时

注销

为了防止伪造注销请求,注销 HTTP 请求应该受到 CSRF 攻击的保护。保护伪造注销请求是必要的,这样恶意用户就无法读取受害者的敏感信息。有关攻击详情,请参阅 这篇博客文章

确保注销 HTTP 请求受到 CSRF 攻击保护的一个可能的复杂问题是,用户可能会遇到会话超时,导致请求被拒绝。会话超时对于不期望注销需要会话的用户来说是令人惊讶的。更多信息请参阅 CSRF 与会话超时

CSRF 与会话超时

通常情况下,预期的 CSRF 令牌存储在会话中。这意味着,一旦会话过期,服务器将找不到预期的 CSRF 令牌并拒绝该 HTTP 请求。有许多选项(每种都有权衡)可以解决超时问题:

  • 解决超时问题的最佳方法是使用 JavaScript 在表单提交时请求 CSRF 令牌。然后使用 CSRF 令牌更新表单并提交。

  • 另一种选择是使用一些 JavaScript 告诉用户他们的会话即将过期。用户可以点击一个按钮来继续并刷新会话。

  • 最后,预期的 CSRF 令牌可以存储在 Cookie 中。这使得预期的 CSRF 令牌可以比会话更长时间存在。

    有人可能会问,为什么预期的 CSRF 令牌默认不存储在 Cookie 中。这是因为存在已知的攻击,其中可以通过另一个域设置头部(例如,指定 Cookie)。这就是 Ruby on Rails 不再在存在头部 X-Requested-With 时跳过 CSRF 检查 的原因。有关如何执行此攻击的详细信息,请参阅 这个 webappsec.org 帖子。另一个缺点是,通过移除状态(即超时),你失去了在令牌被泄露时强制使其失效的能力。

Multipart (文件上传)

保护 multipart 请求(文件上传)免受 CSRF 攻击会导致一个 先有鸡还是先有蛋 的问题。为了防止发生 CSRF 攻击,必须读取 HTTP 请求的主体以获取实际的 CSRF 令牌。然而,读取主体意味着文件已经被上传,这意味着外部网站可以上传文件。

将 Spring Security 的 CSRF 防护与 multipart/form-data 文件上传结合使用有两种选择:

每种选择都有其权衡。

在将 Spring Security 的 CSRF 防护与 multipart 文件上传集成之前,你应该首先确保在没有 CSRF 防护的情况下可以进行上传。有关使用 Spring 的 multipart 表单的更多信息,请参阅 Spring 参考手册的 1.1.11. Multipart Resolver 章节和 MultipartFilter Javadoc

将 CSRF 令牌放在主体中

第一个选项是将实际的 CSRF 令牌包含在请求主体中。通过将 CSRF 令牌放在主体中,主体在执行授权之前被读取。这意味着任何人都可以将临时文件放在你的服务器上。但是,只有授权用户才能提交由你的应用程序处理的文件。一般来说,这是推荐的方法,因为临时文件上传对大多数服务器的影响可以忽略不计。

将 CSRF 令牌包含在 URL 中

如果不允许未经授权的用户上传临时文件是不可接受的,另一种选择是将预期的 CSRF 令牌作为查询参数包含在表单的 action 属性中。这种方法的缺点是查询参数可能会泄露。更普遍而言,将敏感数据放在主体或头部以确保其不被泄露被认为是最佳实践。你可以在 RFC 2616 Section 15.1.3 Encoding Sensitive Information in URI’s 中找到更多信息。

HiddenHttpMethodFilter

一些应用程序可以使用表单参数来覆盖 HTTP 方法。例如,以下表单可以将 HTTP 方法视为 delete 而不是 post

CSRF Hidden HTTP Method 表单
<form action="/process"
	method="post">
	<!-- ... -->
	<input type="hidden"
		name="_method"
		value="delete"/>
</form>

覆盖 HTTP 方法发生在过滤器中。该过滤器必须放在 Spring Security 的支持之前。请注意,覆盖只发生在 post 请求上,所以这实际上不太可能引起任何真正的问题。但是,确保它放在 Spring Security 的过滤器之前仍然是最佳实践。