原文:https://blog.miguelgrinberg.com/post/csrf-protection-without-tokens-or-hidden-form-fields
这篇文章介绍了一种更简单的现代CSRF防护方法,不需要传统的令牌(token)或隐藏表单字段。通俗地说:
传统方法的问题
以前防CSRF攻击需要:
- 生成随机令牌
- 在表单里藏个隐藏字段
- 验证令牌是否匹配
- 管理cookies和session
非常麻烦!现代方法:利用浏览器自带的header
核心思路:现代浏览器(2023年3月后的版本)会自动在每个请求中加入一个叫
Sec-Fetch-Site的header,这个header有4个可能的值:
same-origin- 请求来自同一个网站same-site- 来自同一域名但不同子域名cross-site- 来自完全不同的网站(危险!)none- 用户直接发起的请求关键点:JavaScript无法伪造这个header的值,所以可以信任它!
最终方案
服务器端只需要做:
- 检查
Sec-Fetch-Siteheader,如果是cross-site就拒绝请求- 可选:是否信任子域名(通过配置决定是否拒绝
same-site)- 兼容老浏览器:对于不支持
Sec-Fetch-Site的旧浏览器,用Originheader作为备用方案就这么简单!不需要生成令牌,不需要隐藏字段,不需要复杂的验证逻辑。
优势
- 实现简单,代码量少
- 不需要管理令牌状态
- 性能更好
- 对大多数现代浏览器有效
这就是"无令牌的CSRF防护"的核心思想:让浏览器告诉你请求来自哪里,然后只接受"自己人"的请求。
几个月前,我收到了一位互联网用户的请求,希望我为我的小型Web框架Microdot添加CSRF防护,我认为这是个很棒的想法。
当我在11月初开始做这项工作时,我原本预期需要处理反CSRF令牌、双重提交cookie和隐藏表单字段,基本上就是我们多年来用来构建CSRF防御的传统元素。我确实是沿着这条繁琐的路线开始的。但后来我遇到了一些人处理CSRF攻击的新方法,这种方法要简单得多,我将在下面描述。
实现安全功能
一个经常被分享的建议是,你永远不应该自己实现安全功能。相反,你应该寻找由日复一日思考安全问题的人构建的成熟解决方案。
不幸的是,作为Microdot的主要(也是唯一)维护者,我没有现有解决方案的生态系统可用。尽管我乐于接受外部贡献,但框架的大部分内容都是我自己从零开始构建的。所以在这种情况下,就像之前的许多次一样,我觉得我别无选择,只能违背标准建议,自己编写CSRF防护代码,因为如果我不做,这个功能就不会被构建。
当你需要构建安全功能时,第一步是什么?查看OWASP对此事的看法。
因此,在11月初,我打开了OWASP的CSRF防护速查表页面,看看CSRF防护领域有什么新的和有趣的内容。我发现没有什么重大变化。
根据OWASP的说法,你能获得的最佳CSRF防护(在我查看时)仍然是围绕使用反CSRF令牌的想法构建的。所以我开始为Microdot实现这一点。
CSRF力量中的干扰
我在CSRF实现上取得了愉快的进展,然后在12月初,另一位互联网用户在Flask仓库上提出了一个issue,建议Flask添加对"现代"CSRF防护的支持。现代?怎么会有OWASP没有提到的新的CSRF防护方法?
这让我陷入了一个博客文章和讨论的兔子洞,跨越Go和Ruby社区,还有OWASP GitHub仓库本身关于这个方法的长篇讨论,最终产生了一个pull request,在CSRF速查表中添加了对这个方法的提及,就在我访问这个页面寻求指导的几周后。
现代CSRF防护
所谓的"现代"方法来防护CSRF攻击是基于Sec-Fetch-Site header,所有现代桌面和移动浏览器都会在它们发送给服务器的请求中包含这个header。根据Mozilla的说法,自2023年3月以来发布的所有浏览器都支持这个header。
Sec-Fetch-Site header可以有四个值之一:
same-origin,当请求来自与目标服务器相同的源时same-site,当请求来自同一站点,但不完全是与目标服务器相同的源(例如不同的子域名)时cross-site,当请求来自与目标服务器不匹配的源时none,当请求由用户发起时
这个header的值无法通过JavaScript设置,所以服务器可以假设:a) 如果这个header存在,那么客户端是一个Web浏览器,b) header的值是可信的。所以基本上,服务器可以拒绝带有这个header设置为cross-site的请求,从本质上说,这就是你需要做的全部来防护CSRF!
看到这个之后,我暂停了基于令牌的CSRF实现工作,花了几个小时来实现这种现代方法。一如既往,魔鬼在细节中,所以让我们看看我还需要做什么来构建一个完整的解决方案。
首先,在某些情况下,共享相同注册域的子域可能独立运行,因此,一个子域可能会通过CSRF攻击另一个子域,这并非不可能。根据应用程序对其他子域的信任程度,服务器可能希望阻止带有Sec-Fetch-Site header设置为same-site的请求。在Microdot中,我添加了一个allow_subdomains参数来涵盖这种情况。我决定在安全方面采取谨慎态度,所以默认值是False,这意味着来自子域的请求也会被阻止。
另一个大问题是,不是每个人都在使用实现了这个header的最新浏览器。查看Sec-Fetch-Site header的浏览器兼容性,你可以看到大多数浏览器很久以前就实现了这个功能,在2019年到2021年之间,只有一个值得注意的例外:Safari。苹果在2023年将这个header添加到其浏览器中,所以可以合理地假设仍有用户在运行不支持它的旧浏览器。
一个选择是拒绝所有没有Sec-Fetch-Site header的请求。这让每个人都安全,但当然,会有一些不满意的旧设备用户无法使用你的应用程序。另外,这也会拒绝非浏览器的HTTP客户端。如果这对你的用例不是问题,那很好,但总体上这不是一个好的解决方案。
从我查看这个方法的其他实现中收集到的信息来看,一个被接受的解决方案是在Sec-Fetch-Site未实现时使用Origin header作为后备,因为这个header已经存在更长时间了。最后添加它的主要浏览器是2019年的Firefox桌面版,以及2020年的Edge和Firefox移动版。与Sec-Fetch-Site一样,Origin header也是一个受限制的header,由浏览器设置,所以它也可以用来确定请求来自哪里。
使用Origin header的问题是,知道哪个是适用于Web应用程序的正确源并不总是容易的。标准选项是将Origin header的值与Host header的值进行比较,但Host只包含主机名和端口,而Origin还包含方案(scheme)。此外,Host header在通过反向代理时会被覆盖。所以比较这两个header实际上并不容易。
另一个更直接的选择是要求用户明确配置预期的源名称。为了保持简单,在Microdot中,我选择了明确配置,为此我链接到现有的跨源资源共享(CORS)支持。CORS功能已经维护了一个允许的源列表,所以我的CSRF逻辑会自动信任这些源。我决定现在不让自己复杂化,不添加对Host header检查的支持,但也许将来我会添加这个。
Filippo Valsorda,一位活跃在Go生态系统中的安全开发者(也是流行的mkcert工具的作者)写了一篇博客文章关于这个方法,如果你想了解更多细节,你可能想看看。他似乎是第一个提出这种方法的人,并为Go标准库实现了它。
另外,如果你感兴趣,请随意查看我在Microdot中实现的CSRF防护。看看文档、代码和一个示例,如果你有任何改进或修复建议,请告诉我。
让我们重新审视OWASP
如我上面提到的,OWASP的CSRF防护速查表页面在12月初更新,将使用Sec-Fetch-Site header纳入防护方法列表。但这个方法目前被列为纵深防御机制,而不是完整的解决方案,我认为这很奇怪。
我参考了OWASP GitHub仓库中的讨论,这导致了最近对速查表页面的更改。该讨论的几位参与者建议将这种方法升级为基于令牌的标准方法的完整替代方案。OWASP维护者最初持怀疑态度,但在话题的最后,他们表示同意。关闭讨论的pull request将这个解决方案作为基于令牌方法的替代方案添加,但后来的更改进行了重大更新,包括降级为纵深防御。我希望这只是一个误解,OWASP的人员将恢复所有相关方同意的内容。
无论如何,我认为在Microdot中,从完全没有CSRF支持到这一步是一个巨大的进步,也符合项目的极简主义精神。我将密切关注OWASP CSRF速查表页面,看看他们对这种新防护方法的最终结论,如果他们最终将其保留为纵深防御,我仍然有一个基本完成的双重提交反CSRF令牌实现,可以引入到我的项目中。
结论
我最喜欢在开源工作中的一点是,所有工作都是公开进行的,所以它是一个可以搜索和审查的永久记录。我的CSRF防护之旅开始时是一项在密码学和cookie使用方面有些繁琐的练习,但后来由于一个意外的线索,它变成了一个有趣和令人兴奋的学习机会。