9月の認証マイルストーンでは、メール認証コード、パスワードリセット、Google ログインをまとめて仕上げた。レビュー1回目で指摘されたものの中にレート制限に関する項目があり、これが「数字をいくつか設定する」話とは全く別物だったので書き残しておく。
まずコードの順序を見る
better-auth の email-otp プラグインは、エンドポイント内でまず resolveOTP(email-otp/routes.mjs:31)を呼び、無条件で検証コードを1行挿入する。送信を行う sendVerificationOTP コールバックはその後に実行される。一方、消費時には consumeVerificationValue(internal-adapter.mjs:818-860)が常に「その identifier の最新の1行」だけを対象とし、消費時に全行を deleteMany する。
旧コードはレート制限の判定を送信コールバックに、つまり挿入の後にぶら下げていた。一度ヒットすると:
resolveOTPはすでに新しいコードを1行書き込んでいる;- コールバックがレート制限を判定して送信を拒否するが、この行は誰の受信箱にも届いていない;
- ユーザーが手元のメールに書かれた古いコードを入力すると、マッチするのは最新の行なので
INVALID_OTP; - 2行ともまとめて削除される。
ユーザーは詰む。受信箱のコードはすでに無効、クールダウン期間内に新しいコードも取得できず、しかもエラーメッセージは「認証コードが正しくありません」と言って、責任をユーザーに押し付ける。
挿入の前に移す
修正は hooks.before の前でブロックすること。dispatch は before フックが context キーを含まないオブジェクトを返すことでショートサーキットできる(runBeforeHooks の末尾 + dispatchAuthEndpoint の else if (before))。endpoint handler は一切実行されず、その行はそもそも書き込まれない。
3つの不変条件はすべて維持され、それぞれにアサーションがある:
- レート制限されたレスポンスは「メール未登録」のレスポンスとバイト単位で一致する(ステータスコード + body + content-type をすべて比較);
- レート制限された場合は送信しない;
- ウィンドウ内の最初の1回は依然として1通だけ送信される。判定が挿入の前に走るようになったので、以前あった
assertOtpRateExcludingCurrentの -1 補正も一緒に削除した。
レート制限層自体が登録オラクルになる
第2版の問題はもっと隠微だった。resolveOTP は登録済みメールには永続的な verification 行を残すが、better-auth は未登録メールに対しては挿入した行を即座に削除する(列挙対策で、request-password-reset も forget-password/email-otp もそう)。レート制限ヒット時に 429 を投げると、同じアドレスに2回続けて叩いてステータスコード列が (200,429) か (200,200) かで、攻撃者にそのメールにアカウントがあるかどうかがそのまま分かってしまう。
identifier の形式が正しくなるまでは、カウンターは機能しておらず、保護もせず漏洩もしない。正しくなって初めてカウンターが登録済みメールを数え始め、副作用が観測可能になる。
| 経路 | レート制限ヒット時 | 理由 |
|---|---|---|
| パスワードリセット | 静かに送信せず、エンドポイントは通常どおり 200 | 「レート制限された」と「メールが存在しない」は区別できてはならない |
| メール認証 | 429 | sign-up エンドポイントはもともと USER_ALREADY_EXISTS_USE_ANOTHER_EMAIL で同じ事実を単発で漏らしており、/verify の再送ボタンには本物のフィードバックが必要 |
この表には注記がある:いつか誰かが sign-up の漏洩を塞ぐときは、同時にメール認証のこの 429 も静かなショートサーキットに変えなければならない、さもなければ半分だけ塞いだことになる。
拒否されたリクエストに行を残してはならない
時間あたりのクォータは verification テーブルの行を数える。旧実装では拒否されたリクエストも1行残るので、攻撃者が5回続けて叩けば被害者のクォータを埋めてしまう——実際に送られるのは1通だけなのに、ユーザーは1時間ロックされる。
判定を resolveOTP の前に移したことで、拒否時に行は残らない。リグレッションには専用のものが1つある:
expect(await countOtpRows('forget-password', email)).toBe(1)元に戻すと 2 が数えられる。クォータが数えるのは「実際に送られたメール」だ。
一度きりの経路を救う
同じラウンドには乗っ取り仲裁もある。Google ログインが未検証のメールに衝突したとき、databaseHooks.account.create.after 内で乗っ取り側のパスワードを削除する。関数は「2回の delete の間でクラッシュする」ことしか検討されておらず、「関数本体のどこかで例外が投げられる」ことは検討されていなかった:
create.after → queueAfterTransactionHook(with-hooks.mjs:33 が onError を渡さず、そのまま re-throw)→ createWithHooks → linkAccount → 汎用 catch で unable to link account を返す。しかし account 行はすでにコミット済みで、例外を投げてもそれを取り消せない。
ユーザーにはログイン失敗が1回見え、リトライすると → リンク済み分岐(link-account.mjs 135–181)に進み、その分岐はフックを発火させないが emailVerified を true にする。結末:メールは検証済み、被害者は Google でログインできてしまい、乗っ取り側のパスワードはそのまま使える、しかも事後に見分けがつかない——「もともと検証済みのアカウントに Google を接続した」場合と全く同じに見えるのだ。
修正は関数本体を丸ごと try/catch で包み、握りつぶして上に投げず、userId 付きの [auth] 抢注仲裁失败 … ログを1行だけ出す。投げない理由はコメントに書いた:投げてもコミット済みのリンクは取り消せず、訳の分からないログイン失敗が1回増えるだけで、しかもリトライはログすら出さないあの分岐を通るので、穴をより深く隠すことになる。テストでは db.transaction をモックして例外を投げさせ、false を返し、ログに userId があり、パスワードが残っていることをアサートする。
「先にメールを認証してください」は押せなければならない
同じラウンドで、別の落とし穴を踏んだ人もいる。登録後にタブを閉じ、翌日戻って投稿しようとすると 403 で止められ、「メールアドレスを確認してから投稿してください。」と表示される——クリックできるものが何もない、行き止まりの一文だ。/verify はサイト全体で登録ページと Google ボタンからしかリンクされていない。仮に /verify にたどり着いたとしても、ページの冒頭には「確認コードをあなたのメールに送信しました」と書かれているのに、このページは読み込み時にコードを送ることは決してない。つまりその一文は嘘だ。
email_unverified を「文言 + /verify へのリンク」としてレンダリングするように変更し、ユーザー側の 8 つのレンダリング箇所を差し替えた。/verify の文言は「届いた 6 桁の確認コードを入力してください……届かない場合は下のボタンから再送をリクエストしてください」に変更し、3 言語すべてで同じ修正を適用した。再送ボタンは ghost から outline に格上げした。ページ表示時に自動でコードを送ることはしない。それをすると、リロードのたびにメールアドレス単位の時間あたりクォータを 1 回消費してしまう。
空文字列は「未設定」ではない
デプロイ側にも同じ類のものがある。ドキュメントのあちこちに「メール関連の変数を渡し忘れてもエラーにはならず、挙動がおかしくなるだけ。MAIL_TRANSPORT を渡さなければデフォルトの console になる」と書かれている。これは嘘だ。Compose はホスト側で未設定の変数を空文字列に置き換えるが、?? 'console' と .default() はどちらも undefined しか受け付けず、parseMailEnv({ MAIL_TRANSPORT: '' }) はそのまま例外を投げる。結果として api コンテナが起動しない。
クラッシュするほうがましな挙動なので、空文字列に例外は設けず、代わりに契約を明文化した。この変数群は存在し、かつ空でないことが必須であり、コンテナが起動しないときはまずメール設定を確認すること。mail/config.test.ts の 2 つのアサーションがそれを固定している。
テストと e2e のスクリプトでは MAIL_TRANSPORT=console を必ず前置している。シェルの環境変数は --env-file より優先されるため、この接頭辞がないと、ローカルで MAIL_TRANSPORT を resend に設定した後、テストを 1 回走らせるたびに実際のメールボックスへ大量のメールが送られ、e2e は stdout から確認コードを読み取れずにそのまま失敗してしまう。
まとめ
レート制限は数字の集合ではなく、書き込み順序の中の一つの位置だ。位置を間違えれば、それが止めるのは攻撃者ではなく、ユーザーの手元にあるあの一通のメールになる。
その後、これらの項目はすべてアサーションになった。429 のバイト列、送信は 1 通だけ、拒否時に行を残さない、失敗時に userId を残す、空文字列なら必ずクラッシュする。一つ一つを見れば些細なことばかりだが、合わせて初めてレート制限が正しい位置に置かれていることを示す。
