• 701
    いいね
  • 2
    コメント
この記事は最終更新日から1年以上が経過しています。

この記事はシステムエンジニア Advent Calendar 2015 - Qiitaの記事です。

弊社アーキ部で@kawasimaさんに教えてもらったさいきょうの二重サブミット対策について書いていきます!

二重サブミットが発生するケース

不正な更新リクエストが発生するケースとして、以下のものが考えられます。

  1. サブミットボタンをダブルクリックする
  2. 戻るボタンで戻って、再度保存ボタンを押す
  3. 完了ページでブラウザリロードする
  4. CSRF攻撃による不正な更新リクエスト

1. サブミットボタンをダブルクリックする

確定ボタンをダブルクリックすることによって、ユーザが意図していないリクエストが発生してしまうケース。

ダブルクリック.png

2. 戻るボタンで戻って、再度保存ボタンを押す

処理完了画面から戻るボタンで前の画面に遷移し、再び確定ボタンを押すケース。
本来は入力➡︎確認➡︎完了の画面遷移が適切だが、その画面遷移になっていない。

戻るボタン.png

3. 完了ページでブラウザリロードする

完了ページでブラウザのリロードをすることで、再びリクエストが発生するケース。
スマフォブラウザだとよく起こります。別のアプリに移動してブラウザに戻ってくると、キャッシュアウトしているので、リクエストが再送されるためです。

リロード.png

4. CSRF攻撃による不正な更新リクエスト

悪意のあるサイトが、ブラウザに保存しているログイン情報を使用して勝手に意図しない更新処理を発生させるケース。

csrf.png

二重サブミット対策

上記のような不正リクエスト(以下、二重サブミット)の対策として、以下の手段があります。

  1. トークンによるチェック
  2. JavaScriptでのサブミットボタンのDisable化
  3. PRGパターン

1. トークンによるチェック

防ぐことができる処理

不正リクエスト発生パターン 有効性
1. サブミットボタンをダブルクリックする
2. 戻るボタンで戻って、再度保存ボタンを押す
3. 完了ページでブラウザリロードする
4. CSRF攻撃による不正な更新リクエスト

つまり、だいたいトークンチェックにてカバーできる!

概要

リクエストに対して一意になるランダムな値を、トークンとして発行します。
画面からくるトークンの値とサーバにて保持しているトークンの値をチェックすることで、正常な遷移を行っていない場合の処理をふせぐことができます。

トークンチェック.png

やりかた

各フレームワークにてトークンチェックは簡単に行うことができます。
たとえば、Springの場合はSpring Securityを使用することでトークンチェックを実現することができます。

http://terasolunaorg.github.io/guideline/5.0.1.RELEASE/ja/Security/CSRF.html

また、Railsの場合は、

class ApplicationController < ActionController::Base
  protect_from_forgery
end

と書いておけば、全てのPOSTリクエストでauthenticity_tokenのパラメータが無いと、InvalidAuthenticityTokenがraiseされるようになります。

WARNING!! ユーザ操作による二重登録防止を防ぐようにすること

トークンチェックにてNGになった場合、単に画面に「トークンエラー」だけ表示すると、ユーザは登録に失敗したと思うかもしれません。
そうすると正規のルートで(カートなどに戻って)、再注文するかもしれません。
これはシステム的には防ぎようがなく、ユーザもシステムからのメッセージトリガーで二重注文したわけなので、当然ながら悪気はありません。

トークンエラー時にエラーを画面表示をするのではなく正常終了に見せると、ユーザからの問合せを減らすことができます。
ユーザに不要な心配をさせないためには、入力ー確認ー完了画面で同じトークンをトランザクションIDとして使用し、「トランザクションIDが同じであれば必ず同じレスポンスを返す」ように実装していくといいです。1
入力ー確認ー完了でトークンを同じにすることで、戻るボタンで画面を戻ってリクエストを再送信しても、完了画面以外はエラーにならず(!)、ユーザビリティが確保されます。
例えば、確認画面に「入力画面へ戻る」ボタンをつけなくても、ブラウザの戻るボタンで遷移することができ、トークンエラーにもなりません!

以下のように、トークンを更新対象のテーブルにも保存しておいて、
トークンエラーが発生した時に更新対象のテーブルにトークンがあれば、通常の完了ページを表示するようにします。

order
注文番号
トークン
注文日時

ただし、トークンチェックを通過したスレッドがトランザクションをコミットする前に、別スレッドで上記チェック処理が走ってしまうと、該当のレコードが無いのでエラーを表示せざるを得ません。

完璧にやろうとすると、スレッド間でメッセージをやりとりする必要があります。 CompletableFutureを使って実装します。

private static final Map<UUID, CompletableFuture<Order>> futures = new ConcurrentHashMap<>();

@PersistenceContext(unitName = "example")
    private EntityManager em;
...
protected void doPost(HttpServletRequest request, HttpServletResponse response)
            throws ServletException, IOException {
    String requestedToken = request.getParameter("csrf_token");
    UUID token = UUID.fromString(requestedToken);
    HttpSession session = request.getSession(false);
    synchronized(session) {
        String sessionToken = (String) session.getAttribute("csrfToken");
        if (sessionToken != null) {
            if (requestedToken.equals(sessionToken)) {
                final String itemCd = request.getParameter("item_cd");
                futures.put(token, CompletableFuture.supplyAsync(() -> {
                    Order order = new Order();
                    order.setItemCd(itemCd);
                    order.setToken(token.toString());
                    return orderService.register(order);
                }));
            } else {
                throw new ServletException("トークンが一致しません");                    
            }
            session.removeAttribute("csrfToken");
        }
    }
    CompletableFuture<Order> future = futures.get(token);
    Order order;
    if (future == null) {
        try {
            order = em.createNamedQuery("Order.findByToken", Order.class)
                .setParameter("token", requestedToken)
                .getSingleResult();
        } catch (NoResultException ex) {
            throw new ServletException("Invalid token", ex);
        }
    } else {
        try {
            order = future.get(30, TimeUnit.SECONDS);
        } catch (InterruptedException | ExecutionException | TimeoutException ex) {
            throw new ServletException(ex);
        } finally {
            futures.remove(token);                
        }
    }

全量はこちら
https://github.com/syobochim/sandbox/tree/master/doubleSubmit

2. JavaScriptでのサブミットボタンのDisable化

防ぐことができる処理

不正リクエスト発生パターン 有効性
1. サブミットボタンをダブルクリックする
2. 戻るボタンで戻って、再度保存ボタンを押す ×
3. 完了ページでブラウザリロードする ×
4. CSRF攻撃による不正な更新リクエスト ×

概要

サーバで完璧にトークンエラーの発生を防ぐのは難しいので、クライアントサイドで2重にリクエスト送信しない仕組みを入れておきます。

ボタンを押したときにボタンを非活性にすることで、ダブルクリックにてリクエストを二重に送信してしまう事象をふせぎます。

ふせぐ!.png

やりかた

以下のように、submitしたときにボタンをdisable化します。

$("form").submit(function() {
  $(":submit", this).prop("disabled", true);
});

ただ、回線が遅く遷移前にブラウザの停止ボタンを押した場合は、ボタンを二度と押せなくなってしまうため、↑の方式に一定時間でボタンをenabledにするようにした方が親切です。

$("form").submit(function() {
  var self = this;
  $(":submit", self).prop("disabled", true);
  setTimeout(function() {
    $(":submit", self).prop("disabled", false);
  }, 10000);
});

3. PRGパターン

防ぐことができる処理

不正リクエスト発生パターン 有効性
1. サブミットボタンをダブルクリックする ×
2. 戻るボタンで戻って、再度保存ボタンを押す ×
3. 完了ページでブラウザリロードする
4. CSRF攻撃による不正な更新リクエスト ×

概要

PRGパターンと呼ばれる仕組みを利用します。

https://en.wikipedia.org/wiki/Post/Redirect/Get

Post!Redirect!Get!PRG!
RPG (1).png

PRGパターンで防ぐことができるのは完了ページをリロードしたときの不正リクエストですが、
システムエラーが表示されてしまうと、ユーザ動作による二重登録に繋がりかねないので、なるべくシステムエラーをユーザに返さない仕組みが必要です。

やりかた

更新リクエストを処理したら、HTMLのページを返すのはなく、完了画面へリダイレクトさせます。 このとき、302ではなく303でリダイレクトさせないと、IE9で遷移に失敗するケースがあります。
HTTP STATUSコードについては、kawasimaさんが以下の記事を書いてくれています。

http://qiita.com/kawasima/items/e48180041ace99842779

APIによる更新

Ajax経由でAPIを実行し更新をするような場合も、原則はHTMLを返す場合と同じです。

X-Requested-With

X-Requested-With による対策で、比較的簡単にCSRFを防ぐことができそうです。
http://blog.a-way-out.net/blog/2015/03/23/stateless-csrf-protection/

X-Requested-Withでサイトに脆弱性がない場合は大丈夫そうですが、XSSの脆弱性があるとそこを突いて不正更新リクエストを送ることができます。したがって可能であればトークンによる対策と併用した方がよいでしょう。

内部用API

シングルページアプリケーションなどで内部利用を行うAPIを使う場合の対策を書いていきます。
基本的には上記の対策で対応が可能になります。
ただ、シングルページアプリケーションである場合はPRGパターンは適用不要です。

不正リクエストの対策 有効性
1. トークンによるチェック
2. JavaScriptでのサブミットボタンのDisable化
3. PRGパターン ×

1. トークンによるチェック

HTMLを発行したタイミングでトークンを作成し、画面側に渡しておきます。それを更新APIに載せて送ります。
これで、通常の更新遷移と同じようにユーザの操作をしないと、正規のトークンが取得できないようになります。

2. JavaScriptでのサブミットボタンのDisable化

通常の画面と同じようにボタンを押したタイミングでボタンを非活性にし、ユーザのダブルクリックを防止します。
Ajaxであれば通信完了時に呼ばれるcomplete関数があるので、それを使用すれば、ボタンがずっと非活性のままになることはありません。

http://semooh.jp/jquery/cont/doc/ajax_event/

外部用API

上記に記載している対策は、自サイトの画面ありきの話なので、外部に更新のAPIを公開する場合は不正リクエストを防ぐことができません。

不正リクエストの対策 有効性
1. トークンによるチェック ×
2. JavaScriptでのサブミットボタンのDisable化 ×
3. PRGパターン ×

そのため、外部に公開したAPIは不正リクエストに対する対策ではなく、悪意を持つ人によってAPIをフィッシングなどで悪用されてしまった場合の対策をする必要があります。
例えば以下方法があります。

  • EV SSL証明書を使う
  • APIキーによる利用制限と不正利用の監視

コード

https://github.com/syobochim/sandbox/tree/master/doubleSubmit

おわり

と、いう感じで、長くなりましたが以下の方法で実現出来る、さいきょうの二重サブミット対策でした!
1. トークンによるチェック
2. JavaScriptでのサブミットボタンのDisable化
3. PRGパターン

二重サブミット対策については、トークンチェックしてダメなら画面遷移してエラーにする!ボタンを押したら非活性にする!
くらいの知識しかありませんでしたが、画面遷移してエラーが表示されていても、ユーザはどうしたらいいかわからないし、ボタン非活性にしても、何か問題が起こった時に二度と押せないボタンになっちゃう、など、もろもろ考慮が足りていませんでした
ユーザのことを考えていくとこんなに考慮できるんだ!という学びがいっぱいでした!
@kawasimaさんに圧倒的SpecialThanksです!

ちなみに、二重サブミット対策はちゃんと適用範囲を見極めて実施していきましょう。画面遷移などのリクエストにも、むやみやたらとつけないように!!

注釈


  1. ※トークンチェックを行うフレームワークはありますが、「入力-確認-完了の一連の遷移で同じトークンを使う」ようにするためには作り込みが必要(そういうことができるフレームワークがあれば教えていただきたいです)