跨站请求伪造(CSRF)
Spring 提供了全面的支持,用于防范跨站请求伪造(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 请求可能如下所示:
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 攻击:
-
在您的会话 Cookie 上指定 SameSite 属性
|
这两种保护措施都要求安全方法必须是只读的。 |
安全方法必须是只读的
要使任一 CSRF 防护机制生效,应用程序必须确保“安全”的 HTTP 方法是只读的。
这意味着使用 HTTP GET、HEAD、OPTIONS 和 TRACE 方法的请求不应更改应用程序的状态。
同步器Tokens模式
防范 CSRF 攻击最主要且最全面的方法是使用同步Tokens模式(Synchronizer Token Pattern)。 该解决方案确保每个 HTTP 请求除了会话 Cookie 外,还必须包含一个称为 CSRF Tokens的安全随机生成值。
当提交一个 HTTP 请求时,服务器必须查找预期的 CSRF Tokens,并将其与 HTTP 请求中的实际 CSRF Tokens进行比较。 如果两个值不匹配,则应拒绝该 HTTP 请求。
此机制生效的关键在于,实际的 CSRF Tokens应位于 HTTP 请求中浏览器不会自动包含的部分。 例如,要求在 HTTP 参数或 HTTP 头中提供实际的 CSRF Tokens,即可防范 CSRF 攻击。 而要求在 Cookie 中提供实际的 CSRF Tokens则无效,因为 Cookie 会被浏览器自动包含在 HTTP 请求中。
我们可以放宽要求,仅对每个会更新应用程序状态的 HTTP 请求强制使用实际的 CSRF Tokens。 为此,我们的应用程序必须确保安全的 HTTP 方法是只读的。 这样做可以提升可用性,因为我们希望允许从外部网站链接到我们的网站。 此外,我们不希望在 HTTP GET 请求中包含随机Tokens,因为这可能导致Tokens泄露。
考虑一下当我们使用同步Tokens模式(Synchronizer Token Pattern)时,我们的示例会发生怎样的变化。
假设实际的 CSRF Tokens必须位于名为 _csrf 的 HTTP 参数中。
我们应用程序的转账表单将如下所示:
<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 Tokens。 由于同源策略确保恶意网站无法读取响应,因此外部站点无法获取 CSRF Tokens。
对应的用于转账的 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&_csrf=4bfd1575-3ad1-4d21-96c7-4ef2d9f86721
您会注意到,现在的 HTTP 请求中包含了一个名为 _csrf 的参数,其值为一个安全的随机值。
恶意网站将无法提供正确的 _csrf 参数值(该值必须在恶意网站上显式提供),当服务器将实际的 CSRF Tokens与预期的 CSRF Tokens进行比对时,转账操作将会失败。
SameSite 属性
防范CSRF 攻击的一种新兴方法是在 Cookie 上指定SameSite 属性。
服务器在设置 Cookie 时可以指定 SameSite 属性,以表明该 Cookie 在来自外部站点的请求中不应被发送。
|
Spring Security 不直接控制会话 cookie 的创建,因此它不支持 SameSite 属性。
Spring Session 为基于 Servlet 的应用程序提供了对 |
一个带有 SameSite 属性的 HTTP 响应头示例如下所示:
Set-Cookie: JSESSIONID=randomid; Domain=bank.example.com; Secure; HttpOnly; SameSite=Lax
SameSite 属性的有效值包括:
-
Strict:当指定此选项时,来自同站(same-site)的任何请求都会包含该 Cookie。 否则,HTTP 请求中不会包含该 Cookie。 -
Lax:当指定此属性时,如果请求来自同站,或者请求来自顶层导航且方法为只读,则会发送 Cookie。 否则,该 Cookie 不会包含在 HTTP 请求中。
考虑一下我们的示例如何使用SameSite属性进行保护。
银行应用程序可以通过在会话 Cookie 上指定SameSite属性来防范 CSRF 攻击。
在我们的会话 Cookie 上设置了 SameSite 属性后,浏览器在来自银行网站的请求中仍会继续发送 JSESSIONID Cookie。
然而,当请求来自恶意网站的转账操作时,浏览器将不再发送 JSESSIONID Cookie。
由于来自恶意网站的转账请求中不再包含会话信息,应用程序因此受到保护,免受 CSRF 攻击。
在使用 https://tools.ietf.org/html/draft-west-first-party-cookies-07#section-5 属性防范 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 请求?” 简短的回答是:视情况而定。 然而,你必须非常小心,因为存在一些 CSRF 攻击可以影响 JSON 请求。 例如,恶意用户可以通过使用以下表单构造针对 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 结构
{ "amount": 100,
"routingNumber": "evilsRoutingNumber",
"account": "evilsAccountNumber",
"ignore_me": "=test"
}
如果应用程序未对 Content-Type 请求头进行验证,就会面临此漏洞的威胁。
根据具体配置,即使一个 Spring MVC 应用程序对 Content-Type 进行了验证,攻击者仍可能通过将 URL 后缀修改为以 .json 结尾的方式实施攻击,如下所示:
<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)与无状态浏览器应用
如果我的应用程序是无状态的怎么办? 这并不一定意味着你就受到保护。 事实上,如果用户在发起某个请求时无需在网页浏览器中执行任何操作,他们很可能仍然容易受到 CSRF 攻击。
例如,考虑一个使用自定义 Cookie 的应用程序,该 Cookie 包含了用于身份验证的全部状态信息(而不是使用 JSESSIONID)。 当发生 CSRF 攻击时,该自定义 Cookie 会随请求一起发送,其发送方式与我们前面示例中 JSESSIONID Cookie 的发送方式相同。 此应用程序容易受到 CSRF 攻击。
使用基本身份验证(Basic Authentication)的应用程序也容易受到 CSRF 攻击。 该应用程序之所以存在漏洞,是因为浏览器会自动在任何请求中包含用户名和密码,其方式与我们之前示例中发送 JSESSIONID Cookie 的方式相同。
CSRF 注意事项
在实现针对 CSRF 攻击的防护时,有一些特殊事项需要考虑。
登录
为了防止伪造登录请求,登录的 HTTP 请求应受到 CSRF 攻击防护。 防止伪造登录请求是必要的,以避免恶意用户读取受害者的敏感信息。 该攻击的实施方式如下:
-
恶意用户使用其自己的凭据执行 CSRF 登录。 受害者现在已被认证为该恶意用户。
-
然后,恶意用户诱骗受害者访问被攻破的网站并输入敏感信息。
-
该信息与恶意用户的账户相关联,因此恶意用户可以使用自己的凭据登录并查看受害者的敏感信息。
确保登录 HTTP 请求免受 CSRF 攻击的一个潜在复杂情况是,用户可能会遇到会话超时,从而导致请求被拒绝。 对于那些未预料到登录操作还需要有效会话的用户来说,会话超时会令人感到意外。 更多信息请参阅 CSRF 与会话超时。
正在退出
为了防止伪造注销请求,注销的 HTTP 请求应受到 CSRF 攻击防护。 防止伪造注销请求是必要的,以免恶意用户读取受害者的敏感信息。 有关该攻击的详细信息,请参阅这篇博客文章。
确保注销(logout)HTTP 请求免受 CSRF 攻击的一个潜在复杂情况是,用户可能会遇到会话超时,从而导致该请求被拒绝。 对于那些并未预料到自己拥有需要注销的会话的用户来说,会话超时会令人感到意外。 更多信息,请参阅 CSRF 与会话超时。
跨站请求伪造(CSRF)与会话超时
大多数情况下,预期的 CSRF Tokens存储在会话(session)中。 这意味着一旦会话过期,服务器就找不到预期的 CSRF Tokens,从而拒绝该 HTTP 请求。 有多种方案(每种方案都有其权衡取舍)可用于解决超时问题:
-
缓解超时问题的最佳方法是在表单提交时使用 JavaScript 请求 CSRF Tokens。 然后使用该 CSRF Tokens更新表单并提交。
-
另一种选择是使用一些 JavaScript 代码,在用户会话即将过期时提醒用户。 用户可以点击一个按钮以继续操作并刷新会话。
-
最后,预期的 CSRF Tokens可以存储在 Cookie 中。 这使得预期的 CSRF Tokens的生命周期可以超过会话的有效期。
有人可能会问,为什么默认情况下预期的 CSRF Tokens不存储在 Cookie 中。 这是因为已知存在一些攻击手段,允许其他域设置请求头(例如用于指定 Cookie 的头部)。 这也是 Ruby on Rails 不再在存在 X-Requested-With 头部时跳过 CSRF 检查 的原因。 有关如何实施此类攻击的详细信息,请参见 webappsec.org 上的这个讨论帖。 另一个缺点是,如果移除了状态(即超时机制),一旦Tokens被泄露,你将失去强制使其失效的能力。
多部分(文件上传)
保护多部分请求(文件上传)免受 CSRF 攻击会导致一个先有鸡还是先有蛋的问题。 为了防止 CSRF 攻击的发生,必须读取 HTTP 请求的主体以获取实际的 CSRF Tokens。 然而,读取请求主体意味着文件已被上传,这就使得外部网站能够上传文件。
在使用 multipart/form-data 时,有两种方式可以启用 CSRF 保护:
每种选项都有其权衡取舍。
|
在将 Spring Security 的 CSRF 保护与多部分文件上传集成之前,您应首先确保可以在没有 CSRF 保护的情况下进行上传。
有关在 Spring 中使用多部分表单的更多信息,请参阅 Spring 参考文档中的 1.1.11. 多部分解析器 章节以及 |
将 CSRF Tokens放入请求体中
第一种选择是在请求正文中包含实际的 CSRF Tokens。 通过将 CSRF Tokens置于请求正文中,系统会在执行授权之前读取请求体。 这意味着任何人都可以在您的服务器上放置临时文件。 然而,只有经过授权的用户才能提交由您的应用程序处理的文件。 通常,这是推荐的做法,因为临时文件上传对大多数服务器的影响微乎其微。
在 URL 中包含 CSRF Tokens
如果不允许未授权用户上传临时文件,一种替代方案是在表单的 action 属性中将预期的 CSRF Tokens作为查询参数包含进去。 这种方法的缺点是查询参数可能会被泄露。 更一般地说,最佳实践是将敏感数据放在请求体(body)或请求头(headers)中,以确保其不会被泄露。 您可以在 RFC 2616 第 15.1.3 节:在 URI 中编码敏感信息 中找到更多信息。
隐藏 HTTP 方法过滤器
某些应用程序可以使用表单参数来覆盖 HTTP 方法。
例如,以下表单可以将 HTTP 方法视为 delete 而不是 post。
<form action="/process"
method="post">
<!-- ... -->
<input type="hidden"
name="_method"
value="delete"/>
</form>
重写 HTTP 方法是在一个过滤器中完成的。
该过滤器必须放置在 Spring Security 支持之前。
请注意,重写仅发生在 post 请求上,因此实际上不太可能引发任何真正的问题。
不过,最佳实践仍然是确保将其置于 Spring Security 的过滤器之前。