2026-08-10 レート制限を認証後のServer Actionから始める(Issue #37)
- 課題: リポジトリ全体にレート制限の実装がなく、未認証で叩ける入口(
GET /api/auth/nonce/POST /api/auth/verify)と、認証後に無制限に呼べるServer Action(submitApplication/checkin/transitionApplication)があった。データの正しさが壊れる問題ではなく、公開したときにコストと運営の可視性が削られる問題。
コードを読んで分かった前提
Issueの記述より状況が具体的だった。判断はこの2点に依っている。
- 失敗する署名検証はDBを触らない。
app/api/auth/verify/route.tsのmembers.upsertByAddress()は検証成功後にだけ呼ばれる。連打で増えるのはCPUと関数実行回数だけ。 - nonceの使い捨ては攻撃者には効かない。 サーバーはnonceをiron-sessionの封緘Cookieにしか持たず、サーバー側ストアがない。攻撃者はnonceを1回取得したあと、同じ封緘Cookieを送り続ければ検証を何度でも走らせられる。
session.nonce = undefinedが効くのは Set-Cookie を受け入れる行儀のよいクライアントだけ。
決定
1. 未認証の入口と認証後の入口を分け、まず認証後だけを扱う
アプリ内のレート制限は「関数実行の課金」を減らさない。 429を返す判断をするには関数が起動している必要がある。課金を止められるのはエッジ側だけなので、未認証の入口はホスティング側(Vercelのファイアウォール等)の層で扱う。ここは本番構成の判断(docs/decisions/2026-08-06-phase-1-local-validation.md でデプロイ延期中)とセットになるため、このPRには含めない。
この分割は「未認証側を後回しにしてよい」という意味ではない。 本番公開前に決める必要があり、別Issueとして残す。
2. 認証後の入口はSupabase PostgreSQLで数える。Redis等は入れない
| 選択肢 | 採否 |
|---|---|
| Supabase PostgreSQL | 採用。 これらのActionはもともとDBを触るので往復が追加コストにならない |
| プロセス内メモリ | 不採用。Vercelはインスタンスをまたげず、保証にならない |
| Upstash / Vercel KV | 不採用。ベンダーとシークレットが増え、「Redisが落ちたとき通すか止めるか」の判断も抱える。現状はデータの正しさではなくコストの問題で、その対価に見合わない |
未認証の入口をDBで数えるのは逆効果になる。 secp256k1の復元はCPUで1ms未満、Supabaseへの往復は数ms。検査のほうが守る対象より高くつき、外部から無料でDB書き込みを強制できる形になる。認証後の入口に限れば、この逆転は起きない。
3. 数える単位はウォレットアドレス
IPではなくアドレスにした。IP単位だと同一ネットワークのメンバーがまとめて詰まる(Issueが挙げていた懸念)。認証後なので安定した識別子が手に入り、これはIPにはない利点になる。
管理者の状態更新は管理者のアドレスで数える。
4. 数え上げと判定はDB側の1文で行う
consume_rate_limit() は insert ... on conflict do update で「加算して返す」までを1文にまとめ、その結果を上限と比べる。読み取り→加算→書き込みを別々のSQLで行うと、同時に呼ばれたときに上限を越えて通ってしまう。 on conflict do update は対象行のロックを取るので、この1文の中で直列化される。
固定ウィンドウを採った。スライディングウィンドウは履歴を行として持つ必要があり、ノイズを減らすための仕組みが自分でノイズを増やす。 ウィンドウ境界での揺れ(最悪で上限の2倍が短時間に通る)は、今回の目的に対して許容できる。
5. 上限値
| 入口 | 上限 | 単位 |
|---|---|---|
submitApplication | 5回 / 24時間 | メンバー |
checkin | 20回 / 24時間 | メンバー |
transitionApplication | 120回 / 1時間 | 管理者 |
「自動化された連打だけを止め、人間の正当な操作は届かない」水準で決めた。 状態更新が1時間120回なのは、運営が申請30件をまとめて捌く場合(1件あたり審査・Allowlist・配布で3操作 = 90回)を想定したもの。ここを絞りすぎると、ノイズを減らす代わりに正当なまとめ作業を弾く。
6. 認可の直後、処理の前に数える
止めたいのは成功する操作ではなく、DBの制約に弾かれ続ける呼び出しのほう(完走していない申請、同日2回目のチェックイン、遷移ルールに反する更新)。処理の後ろに置くと数え漏らす。transitionApplication では全申請を読む listAll() より前に置いた。
DBへ到達できないときは例外がそのまま伝わり、通してしまわない。これらのActionはいずれにせよ同じDBを必要とするので、ここだけ通しても先へ進めない。
副次的な変更
vitest.config.ts に fileParallelism: false を追加した。 統合テストは1つのローカルSupabaseを共有するため、ファイルを並列に走らせると片方の truncateAll がもう片方の実行中の行を消す。2ファイル目を足した時点で外部キー違反として表面化した。単体テストは十分速いので、分けずに全体を直列にしている。
含めなかったもの
- 未認証の入口(
/api/auth/nonce//api/auth/verify)のレート制限。 上記1のとおり、本番構成の判断とセットで別途扱う - CAPTCHA等
- WAF・DDoS対策そのもの
- 超過時に「あと何秒待てばよいか」を返すこと。固定ウィンドウの残り時間を返す形にはできるが、まず上限が実際に必要な水準かを見てから決めたい
残る課題
rate_limits の行は (bucket × 利用者数) で頭打ちになるため放置しても増え続けないが、使われなくなった行を消す仕組みはない。 規模が問題になった時点で、ウィンドウを過ぎた行の定期削除を足す。