九月的认证里程碑里,邮箱验证码、找回密码和 Google 登录一起收尾。review 第一轮揪出的东西里,有一条跟限流有关,写下来是因为它跟「配几个数字」完全不是一回事。
先看代码顺序
better-auth 的 email-otp 插件在端点里先调 resolveOTP(email-otp/routes.mjs:31),无条件插一行验证码;发信的 sendVerificationOTP 回调在那之后才跑。而消费时,consumeVerificationValue(internal-adapter.mjs:818-860)永远只认「该 identifier 最新的一行」,并在消费时把全部行 deleteMany 掉。
旧代码把限流判断挂在发信回调里,也就是挂在那次插入之后。一旦命中:
resolveOTP已经写了一行新的码;- 回调判断限流,拒发,这行码从没进过任何人的信箱;
- 用户输入手里那封信上的旧码,匹配到的是最新那行,
INVALID_OTP; - 两行一起被删光。
人被卡死:信箱里的码已死,冷却窗内又要不到新码,而错误信息说「验证码错误」,把账算在用户头上。
挪到插入之前
修法是挡在 hooks.before。dispatch 支持 before 钩子返回一个不含 context 键的对象来短路(runBeforeHooks 末尾 + dispatchAuthEndpoint 的 else if (before)),endpoint handler 完全不跑,那一行根本不会被写。
三条不变式都留着,而且各有断言:
- 被限流的响应与「邮箱没注册」的响应逐字节一致(状态码 + body + content-type 都比);
- 被限流不发信;
- 窗口内第一次仍然只发一封。判断跑在插入之前以后,原来那个
assertOtpRateExcludingCurrent的 -1 补偿也一并删了。
限流层自己会变成注册预言机
第二版的问题更隐蔽。resolveOTP 给已注册邮箱留一行持久的 verification 行,但 better-auth 对未注册邮箱会把刚插的行立刻删掉(防枚举,request-password-reset 和 forget-password/email-otp 都这样)。如果命中限流时抛 429,连打两次同一个地址,状态码序列 (200,429) 还是 (200,200) 就直接告诉攻击者这个邮箱有没有账号。
在 identifier 格式修对之前,计数器是哑的,不保护也不泄露;修对之后计数器第一次真的开始数到已注册邮箱,副作用才变得可观测。
| 路径 | 命中限流时 | 理由 |
|---|---|---|
| 找回密码 | 静默不发信,端点照常 200 | 「被限流」与「邮箱不存在」必须不可区分 |
| 邮箱验证 | 429 | sign-up 端点本来就用 USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL 单次泄露同一事实,而 /verify 的重发按钮需要真实反馈 |
这张表还有个附注:谁哪天把 sign-up 那个泄露堵上,必须同时把邮箱验证这条 429 也改成静默短路,否则就是堵了一半。
被拒的请求不能留行
小时配额数的是 verification 表的行。旧实现里被拒的请求也会留一行,于是攻击者连打 5 次就能把受害者的配额填满——只有 1 封信真的发出去,人却被锁一小时。
判断挪到 resolveOTP 之前以后,被拒不留行。回归里专门有一条:
expect(await countOtpRows('forget-password', email)).toBe(1)改回去会数到 2。配额数的重新是「真发出去的信」。
抢救一条一次性的路径
同一轮里还有抢注仲裁。Google 登录撞上一个未验证邮箱时,会在 databaseHooks.account.create.after 里把抢注者的密码删掉。函数只推敲过「两次 delete 之间崩掉」,没推敲过「函数体里任何一处抛错」:
create.after → queueAfterTransactionHook(with-hooks.mjs:33 没给 onError,原样 re-throw)→ createWithHooks → linkAccount → 泛用 catch,返回 unable to link account。而 account 行已经提交,抛错撤不掉它。
用户看到一次登录失败,重试 → 走已链接分支(link-account.mjs 135–181),那条分支不触发钩子、却会把 emailVerified 置 true。终局:邮箱已验证、受害者用 Google 登进来了、抢注者的密码照常能用,而且事后无法辨认——它跟「本来就已验证的账号接上 Google」长得一模一样。
修法是把函数体整个包 try/catch,吞掉、不上抛,只打一条带 userId 的 [auth] 抢注仲裁失败 … 日志。不上抛的理由写进注释了:抛也撤不回已提交的链接,只会多一次莫名的登录失败,而重试走的是那条连日志都不打的分支,等于把洞藏得更深。测试 mock 掉 db.transaction 让它抛,断言返回 false、日志里有 userId、密码还在。
「请先验证邮箱」得点得动
同一轮还有人踩到另一条:注册后关掉标签页,第二天回来发帖被 403 挡住,提示「请先验证邮箱后再发布内容。」——一句没有任何可点内容的死话,/verify 全站只从注册页和 Google 按钮链出去。就算摸到了 /verify,页面开口就说「验证码已发到你的邮箱」,而这一页从不在加载时发码,那句是假话。
改成 email_unverified 渲染成「文案 + 指向 /verify 的链接」,换了八个用户侧渲染点。/verify 的文案改成「填入你收到的 6 位验证码……没收到就点下面的按钮要一个」,三语同改;重发按钮从 ghost 提到 outline。不做进页自动发码,那会让每次刷新烧掉一次按邮箱的小时配额。
空串不是「没配」
部署侧还有一条同类的。文档四处写着「漏传邮件变量不报错,只是行为不对,MAIL_TRANSPORT 没传会默认成 console」。这是假的:Compose 把宿主未设置的变量替换成空串,而 ?? 'console' 和 .default() 都只认 undefined,parseMailEnv({ MAIL_TRANSPORT: '' }) 直接抛,表现是 api 容器起不来。
崩溃是更好的行为,所以不给空串开豁免,改成把契约写清:这组变量必须存在且非空,看到容器起不来先查邮件配置。mail/config.test.ts 两条断言钉住它。
测试和 e2e 的脚本里则硬前缀 MAIL_TRANSPORT=console。shell 环境变量优先于 --env-file,没有这个前缀的话,本地把 MAIL_TRANSPORT 配成 resend 之后:跑一次测试就往真实邮箱发一批信,e2e 还会因为读不到 stdout 里的验证码直接失败。
收尾
限流不是一组数字,是写序里的一个位置。位置错了,它拦住的不是攻击者,是用户手里那封信。
后来这几条都变成了断言:429 的字节、只发一封、被拒不留行、失败留 userId、空串必崩。它们单独看都挺琐碎,但合起来才说明限流被放在了正确的地方。
