你的密码重置表单是一门免费的邮件大炮
密码重置表单大概是你名下最无趣的端点。它也是唯一一个攻击者可以对准任何人的端点。我们的重置表单有一阵子没有针对每个邮箱的速率限制,而这件事之所以要紧,原因并不是你会猜到的那个。
当你听到「未经身份验证的端点,没有速率限制」时,本能反应是想到暴力破解。但重置请求上没有什么可以暴力破解的。你不是在猜密码。你提交一个邮箱地址,系统就发出一个链接。猛敲它并不能让你进入任何账户。那么,谁受了伤?
受害者是第三方。输入别人的地址,[email protected],点击发送,重复。每一次请求都会向一个你无法控制的真实收件箱发出一封真实的邮件。你把我们的重置表单征用成了一枚邮件炸弹,对准了一个从未注册过任何东西的人。而因为邮件来自我们,伤害同时落在两个地方。受害者的收件箱被塞满。而我们的发送信誉也挨了一击,因为来自我们域名的大量垃圾邮件,正是邮箱服务商紧盯的东西。数量一多,我们的正当邮件、验证链接、真正的密码重置,就会开始落进我们每一个真实客户的垃圾箱。一个未经身份验证的端点,不需要凭据,对攻击者也没有任何成本,却损害了我们所有用户共享的一项资源。
于是你给它加上速率限制。针对每个邮箱地址,这样单个目标就不会被淹没。很简单。只不过那个显而易见的实现,悄悄开了另一个洞。
如果你只在地址确实拥有账户时才施加限制、才去做那些工作,那么这个端点对真实账户和编造账户的表现就不一样。一个能看出这种差异的攻击者,无论是从响应、状态码,还是仅仅从时间上,现在就手握一个预言机(判定器),用来判断哪些邮箱地址在你这里注册了。重置表单正是因为这个原因而成为经典的账户枚举泄露点:针对邮件炸弹的那个天真修复,反而制造了枚举漏洞。
真正的修复必须同时做两件事,而这两半是相互独立的。第一,无论账户是否存在,都对规范化后的邮箱地址施加速率限制。限制键就是地址本身,转成小写并去除空格,这样 Victim@ 和 victim@ 就共用同一个桶,而且计数器要在你查找任何东西之前就先施加。这个计数器必须存放在持久化的共享存储里,而不是单个进程的内存中,否则它会在重启时蒸发,在多个实例之间也起不了任何作用。第二,每一种情况下都给出完全相同的响应:相同的状态、相同的「如果该地址拥有账户,我们已经发出一个链接」的提示、相同的时间形态,无论这个地址是不是你的用户。你依然只在背后确实有账户时才真正发出邮件。但外部观察者能看到的一切,不会因为那个秘密而发生任何变化。速率限制保护第三方;恒定的响应保护「谁拥有账户」这一事实。
这条普遍的教训,远不止于密码重置。任何一个会造成现实世界副作用的未经身份验证的端点,发送一封邮件、发送一条短信、调用一个 webhook,都不仅仅是你攻击面的一部分。它是一件对准接收端任何人的武器,而开火的账单落在你的信誉上,而不是攻击者的信誉上。要按被作用的对象来做速率限制,也就是接收方,而不是调用方。而且做的时候,不要让秘密状态的有无改变观察者所看到的东西。
密码重置表单感觉就像管道设施。而它正是那一截会乐呵呵地、一声令下就朝全世界任何人喷射的管道。就照这个来给它装上计量。
在不泄露谁拥有账户的前提下,给一个未经身份验证的副作用端点做计量,比看上去更棘手。Authagonal 交付的重置流程,已经内置速率限制、并且对枚举安全,这样你的发件人信誉就永远不会离毁灭只差一个 curl 循环。