お問い合わせのレート制限を、外部ストアなしで実装した理由
#Security#Next.js
このサイトのお問い合わせフォームは、メールを送る。連打されると、迷惑メールの踏み台になったり、メールサービスの送信上限を使い切ったりする。管理画面(Decap CMS)のログイン処理も、総当たりの対象になりうる。
そこで、レート制限を入れた。この記事では、外部のストアを使わない方式を選んだ理由と、その方式の限界を書く。判断の記録は ADR-0003 にある。
何をしているか
- クライアントは、
x-forwarded-forの先頭のアドレスで識別する。なければx-real-ip、どちらも取れなければ共通のunknownとする。 - 固定窓のカウンタを
Mapに持つ。お問い合わせは、IP ごとに 10 分で 5 回まで。管理画面の OAuth は、IP ごとに 10 分で 10 回まで。キーに接頭辞を付けて、2 つの枠を混ぜない。 - 超えると 429 を返す。画面側は、送信失敗として表示する。
なぜ、外部のストアを使わなかったか
サイトは小規模で、データストアを持っていない。Redis や Upstash を足すと、外部サービスの契約と設定が増える。通常の利用は、数回の送信で終わる。この規模には、過剰だと判断した。
CAPTCHA も使っていない。利用者の負担が増えるためで、今は honeypot(人間には見えない入力欄)とレート制限、本文の大きさの上限(16KB)、入力の検証、メール側の送信上限で、多重に守っている。
限界
この方式には、はっきりした弱点がある。
- サーバーレスの環境では、インスタンスごとにカウンタが別になる。コールドスタートでも消える。複数のインスタンスに分散した攻撃は、完全には止められない。
x-forwarded-forは、信頼できるプロキシ(このサイトでは Vercel)の背後でだけ、正しい値になる。
どちらも、実際に濫用が見られたときに困る。そうなった場合は、共有ストアに移すと決めてある。checkRateLimit の呼び出し側は変えずに、実装だけを差し替えられる形にしてある。
次にやること
共有ストアへの移行は、Issue として起こしてある。契約や費用が絡むので、実際に濫用が見られるか、アクセスが増えてから進める。それまでは、この方式の限界を、ADR に書いたまま運用する。