メインコンテンツまでスキップ

二重送信の防止

· 約8分

送信ボタンのダブルクリック、ネットワークタイムアウト後の自動リトライ、ブラウザバックからの再送信——いずれも同じフォームがサーバーで二度処理される原因になりえます。Token(トークン)機構を使えば、CSRF 攻撃と二重送信を効果的に防げます。フォーム送信の際、サーバーが Token を生成して Redis に保存し、フォームの隠しフィールドや URL パラメータとしてクライアントに渡し、送信時に検証して使い終わったら即座に削除する、という流れです。

なぜこの問題に取り組む価値があるのか

二重送信のビジネス上の影響は、見た目以上に深刻なことが多いものです。注文 API が二度処理されれば注文が二件になり、決済リクエストが二度リトライされれば二重課金になります。フロントエンドで送信ボタンをグレーアウトしても、うっかりクリックは防げますが、ネットワーク層の自動リトライは防げませんし、悪意を持って組み立てられたリクエストならなおさらです。だからこそ、この防御は最終的にサーバー側で行う必要があります。

一方 CSRF 攻撃は、攻撃者がユーザーのブラウザを誘導して標的サイトへリクエストを送らせるものです。これが成立してしまうのは、ブラウザが Cookie を自動的に付与するからです。Token は Cookie に置かないため、第三者のサイトからはこの値を取得できません。したがって、一つの Token 機構でこの二種類の問題を同時に防ぐことができます。

基本原理

Token 機構は、Web アプリケーションで二重送信と CSRF 攻撃を防ぐためによく使われる手法です。基本的な考え方は、フォーム送信のたびにサーバーが Token を生成して Redis に保存し、その Token をフォームの隠しフィールドまたは URL パラメータとしてクライアントに渡す、というものです。クライアントはフォーム送信時にこの Token を一緒にサーバーへ送ります。サーバーはフォームを処理する際にこの Token が正しいかを確認し、処理が終わったらこの Token を削除します。

二つの防御目標には、仕組みの上でそれぞれ要点があります。二重送信の防止は「検証が通ったら即座に削除する」ことに支えられています。同じ Token が二度目に到達したときにはすでに存在しないため、リクエストは拒否されます。CSRF の防止は「Token が正規のページにしか配布されない」ことに支えられています。攻撃者が組み立てたリクエストにはこの値が含まれません。Redis はここで二つの役割を担います。一つは集中管理——マルチインスタンス構成でもどのマシンからでも検証を完結できること。もう一つは有効期限の管理——TTL を利用して、消費されなかった Token を自動的に片付けることです。

実装手順

1)サーバー側で Token を生成し、Redis に保存します。Token は乱数や UUID などで生成でき、十分にランダムかつ一意であることを保証する必要があります。書き込み時にあわせて有効期限も設定します。

# NX で既存の Token を上書きしないことを保証し、EX で有効期限(秒)を設定して、無効な Token の長期滞留を防ぐ
SET submit:token:<tokenValue> 1 EX 600 NX

2)この Token をクライアントに渡します。フォーム送信であれば隠しフィールドとして、AJAX リクエストであれば HTTP ヘッダーとして渡せます。

3)クライアントはフォーム送信時に、この Token を一緒にサーバーへ送信します。フォーム送信であれば、フォームの submit イベントに JavaScript コードを追加して Token を隠しフィールドとして付与できます。AJAX リクエストであれば、リクエスト送信前に Token を HTTP ヘッダーとして付与できます。

4)サーバーはフォームを処理する際、この Token が正しいかを検証します。Token が正しければ有効なリクエストとして処理を続行し、そうでなければ二重送信または CSRF 攻撃とみなして処理を拒否すべきです。

5)フォームの処理が完了したら、サーバーは Redis からこの Token を削除し、再利用を防ぎます。

手順 4 と 5 の「検証」と「削除」は、必ずひとつのアトミックな操作として行わなければなりません。GET してから DEL する二段階の実装だと、並行する二つのリクエストが同時に検証を通過してしまい、二重送信防止が機能しなくなります。Lua スクリプトを使えば、Redis 上で検証と削除をアトミックに完結できます。

-- 検証して削除:Token が存在すれば削除して 1 を返し、存在しなければ 0 を返す
-- スクリプト全体が Redis 内でアトミックに実行され、並行リクエストのうち成功するのは一つだけ
if redis.call('GET', KEYS[1]) then
return redis.call('DEL', KEYS[1])
else
return 0
end

セキュリティ上の注意点

Token 機構は二重送信と CSRF 攻撃を効果的に防げますが、Token 自体の安全性を守ることに注意しないと、攻撃者に悪用される恐れがあります。具体的には、次の点に注意が必要です。

Token は十分にランダムかつ一意でなければなりません。そうでないと、攻撃者に推測されたり再利用されたりする恐れがあります。

Token は転送経路の安全を守る必要があります。そうでないと、攻撃者に傍受・改ざんされる恐れがあります。転送は必ず HTTPS を使い、ログに記録される URL に Token を含めないようにしましょう。

Token には有効期限を設定する必要があります。そうでないと、攻撃者に再利用される恐れがあります。

Token は Redis 上での保存の安全も守る必要があります。そうでないと、攻撃者に窃取・改ざんされる恐れがあります。Redis インスタンスをパブリックネットワークに公開せず、アクセス認証を有効にしましょう。

ハマりどころと注意点

警告

検証と削除を二段階に分けて書くのが、最もよくある誤った実装です。負荷試験中やユーザーの素早いダブルクリック時に、二つのリクエストがどちらも GET の検証を通過し、それぞれビジネス処理を一度ずつ実行してしまいます。必ず Lua スクリプトか同等の手段でアトミック性を保証してください。

有効期限は、ユーザーが実際にフォームを埋めるのにかかる時間を踏まえて決めましょう。短すぎると、長いフォームを入力している間に Token が期限切れになり、送信が必ず失敗します。長すぎると、無効な Token が Redis に溜まっていきます。

検証に失敗したときは、フロントエンドで明確なフィードバックを返し、ページを再読み込みして Token を取得し直してから再送信するようユーザーを誘導すべきです。リクエストを黙って破棄してしまうと、ユーザーは「押したのに反応がない」と感じて連打を続けるだけです。

また、削除のタイミングもよく考える必要があります。ビジネス処理が失敗したときに Token を返却してユーザーの即時リトライを許すかどうかは、そのビジネスが冪等性に何を求めるか次第で、統一的な答えはありません。

まとめ

Token 機構の核心は「ワンタイムの証憑」です。サーバーが発行し、Redis に保存し、送信時に検証し、使い終わったら即座に削除する——この一連の流れで、二重送信と CSRF という二つの問題を同時に解決します。実装時に重点的に押さえるべきは四つ——Token のランダム性、転送と保存の安全、適切な有効期限、そして検証・削除のアトミック性です。これらを押さえれば、低コストで効果の明確な汎用的防御手段になります。

COMMENTS