総当たり対策のレート制限は失敗だけ数える — 成功を「カウンターのリセット」に使うと1アカウントで無効化される
セキュリティ
設計判断
API設計
判断
運用
認証の総当たり対策としてレート制限を置くとき、判定と加算の設計を分けて考える必要がある。素直にミドルウェアの入口で無条件に加算すると、成功したログインも1回消費するため、同じ利用者が短時間に複数回ログインしただけで締め出される。トークン期限切れ・複数端末・再認証のある構成では現実的に起こる。
判断基準
- 総当たり対策の制限は「失敗した試行」だけを数える。守りたいのは「当たるまで繰り返す」行為であって、正規の利用回数ではない。
- ただし 成功を「カウンターのリセット」に使ってはいけない(重要。下の落とし穴を参照)。成功は確定済みの失敗数に一切触れず、自分が取った枠を返すだけにする。
- 一方、登録スパム対策のような「作成そのものを抑えたい」制限は、成功も含めて全試行を数えるのが正しい。同じ仕組みに見えても数え方の正解が逆になるため、目的をエンドポイントごとに言語化してから実装する。
- この差は将来の実装者が「片方に合わせて統一」しがちなので、意図的な差であることをテストとして固定する(成功応答でもカウントが増える/増えないを別々に検証する)。
- 制限の鍵は「経路(bucket)× 主体」で構成する。複数のエンドポイントが同じ鍵空間を共有すると、片方への攻撃がもう片方の正規利用者を巻き添えにする。認証が不要な経路の拒否応答(機能フラグ off の 404 等)は、そもそも認証試行ではないので数えない。
落とし穴
- 成功でカウンターを削除・ゼロ化すると、制限が丸ごと無効化される。 攻撃者が有効な資格情報を一つ(自分のアカウント)持っていれば、「他人へ上限手前まで試行 → 自分のアカウントでログイン成功 → カウンターが消える」を繰り返すだけで、同じ鍵から無制限に総当たりできる。「正規利用者を締め出さない」という意図は、成功が失敗数に触れない構造(成功は自分の予約を返すだけ)で完全に満たせるため、リセットは不要。
- 「成功した主体と同じ鍵の失敗数だけ消す」という複合キー案も採らない。1つの発信元から主体(メールアドレス等)を変えるだけで枠が増えるため、パスワードスプレーに対してかえって弱くなる。
- 上限判定時に枠を予約しないと、並行リクエストで一斉に突破される。 「現在値が上限未満なら通し、処理の後で加算する」設計は、同時に届いた多数のリクエストが全て同じ現在値を読んで全て通過する。判定と同時に枠を確保し(処理中カウントを別に持ち、判定は確定分+処理中の合計で行う)、完了時に確定分へ振り替えるか予約を返す。
- 失敗のみ数えるには、ハンドラーの実行後に結果を見る必要がある。素直に「後続処理を呼んだ直後に加算」と書くと、下流が異常終了して制御が戻らない経路で加算が飛ばされる。異常終了を誘発するリクエストは何度でも通過でき、制限そのものを回避できる。加算は必ず遅延実行(スコープ離脱時に必ず走る仕組み)で行い、異常終了も失敗として数える。
- 鍵に使う発信元識別子そのものが偽装可能だと、上記をすべて正しく実装しても迂回される。信頼できる経路から得た値だけを使い、値としての妥当性(単一のアドレスとして解釈できるか)も検証したうえで正規化する。検証しないと、任意文字列がそのまま鍵になって毎回別枠が取れるうえ、保持テーブルが無制限に増える。
検証方法
- 失敗を上限まで繰り返した後、次が拒否されること。
- 成功を上限回数ぶん繰り返しても、次のログインが通ること(成功が枠を消費しない)。
- 失敗を上限手前まで積んだ後に成功させても、確定済みの失敗数が消えていないこと(上限までの残りが増えていない)。この1本が「成功でリセット」への退行を検知する。
- 上限付近で並行リクエストを多数投げ、ハンドラーへ到達した数が上限を超えないこと。直列の連打では判定と加算の分離を検知できない。
- 異常終了するハンドラーへの連続試行が、最終的に拒否されること。この1本が抜けると回避経路が残る。
- 別の経路の失敗が、この経路の枠を消費していないこと(鍵の分離)。