Wicket Ajax Request の複数送信をBlockする


Wicket の AjaxButton で mail 送信要求を送る 問い合わせフォームを実装していたのですが、
ボタン連打で連続リクエスト送信ができてしまったので、
その回避方法を考えます。


  • Wicket 7.5
    <dependency>
        <groupId>org.apache.wicket</groupId>
        <artifactId>wicket-core</artifactId>
        <version>7.5.0</version>
    </dependency>

エラーの発生しているフォーム

Wicket Form についてよくわかっていなかったので調べてみました。 | Monotalk
で作成したフォームでエラーが発生しています。


エラーの発生から考える実装時に考慮しなければならないポイント(個人調べ)

以下の考慮ポイントがあるように感じました。
各考慮ポイントを実装していきます。

  • AjaxButton に対しての連打対策 (client 側) 現在の実装だと連打可能なので、連打ができないようにする必要があります。
    これは client 側へ実装します。

  • AjaxButton に対しての連打対策 (server 側) client 側の対応だけだと、直接 Submit には対応できないので、
    サーバ側のプログラムで、連続リクエストに対する対応を実施します。2
    [2] Wicket は Form の URL が自動生成されるので、この攻撃自体が結構難しいかと思いますが、
    不可能ではないため対策をしておきます。


AjaxButton に対しての連打対策

以下の記事が見つかりました。

AjaxButton#updateAjaxAttributes をオーバーライドして、
以下の処理を実装します。

  • Validation の実行後に、確認ダイアログを出力する。

  • 確認ダイアログ実行後は、処理完了まで、ボタンは非活性化する。

Validation の実行後に、確認ダイアログを出力する。

Validation の後に確認ダイアログを出したいですが、
Validation の実行タイミングを、add(new AjaxFormValidatingBehavior("blur", Duration.ONE_SECOND)); で指定していたので、
Button クリック前の入力フィールドへの入力タイミングで実行されます。
このため、特に考慮不要でした。
Qiita の記事の通り、onPrecondition()で設定すれば、OK でした。

    @Override
    protected void updateAjaxAttributes(AjaxRequestAttributes attributes) {
        super.updateAjaxAttributes(attributes);
        final AjaxCallListener ajaxCallListener = new AjaxCallListener();
        ajaxCallListener.onPrecondition("return confirm('" + getString("messageMailSendConfirm") + "');");
        attributes.getAjaxCallListeners().add(ajaxCallListener);
    }

確認ダイアログ実行後は、処理完了まで、ボタンは非活性化する。

Request 送信直前 onBeforeSend で Button を非活性、
Request 送信完了後 onComplete で Button を活性化し、再フォーカス
するようにしました。

Validation でエラーの場合、ボタンを非活性にするとかを実装していないので、
エラー発生時も、Button クリック可能で、 Button クリックで、再度 Validation が走りエラーとなります。
UI 的にはアレな感じもしますが、とりあえずの目的は果たせたので、一旦これでいきます。

    @Override
    protected void updateAjaxAttributes(AjaxRequestAttributes attributes) {
        super.updateAjaxAttributes(attributes);
        // AjaxButton は (というかButtonは) setOutputMarkupId(true) なので、特に呼び出さなくてOK
        final AjaxCallListener ajaxCallListener = new AjaxCallListener();
        ajaxCallListener.onPrecondition("return confirm('" + getString("messageMailSendConfirm") + "');");
        ajaxCallListener.onBeforeSend("$('#" + getMarkupId() + "').prop('disabled',true);");
        ajaxCallListener.onComplete("$('#" + getMarkupId() + "').prop('disabled',false);$('#" + getMarkupId() + "').focus();");
        attributes.getAjaxCallListeners().add(ajaxCallListener);
    }

以下の記事を参考にしました。

Github で、 Jqueryを使ったもっと頑張っている実装がありましたので、
そちらのリンク先も貼り付けておきます。

補足. AjaxCallListener のメソッド呼び出し順序

AjaxCallListener には、ライフサイクルメソッド? が多数あり、
実行順序がよくわからなかったので、確認した結果を以下に記載します。
感覚的には、メソッド名通りの動作となります。

メソッド名呼び出し順序説明
onInit(final CharSequence init)1AjaxButton のクリック直後に呼び出されます。
onBefore(final CharSequence before)2onInit() の呼び出し後に呼び出されます。
onPrecondition(final CharSequence before)3onBefore() 呼び出し後に呼び出されます。確認ダイアログを挿入すると、キャンセルクリックで後処理の中断ができます。
onPrecondition(final CharSequence before)4onBefore() 呼び出し後に呼び出されます。確認ダイアログを挿入すると、キャンセルクリックで後処理の中断ができます。
onBeforeSend(final CharSequence beforeSend)5onPrecondition() の呼び出し後に呼び出されます。リクエスト送信直前
onAfter(final CharSequence beforeSend)5onBeforeSend() の呼び出し後に呼び出されます。リクエスト送信直後
onSuccess(final CharSequence beforeSend)6onAfter() の呼び出し後、リクエストが成功した場合に呼び出されます。
onFailure(final CharSequence beforeSend)6onAfter() の呼び出し後、リクエストが失敗した場合に呼び出されます。
onComplete(final CharSequence beforeSend)7onSuccess()onFailure() の呼び出し後、呼び出されます。
onDone(final CharSequence beforeSend)8onComplete() の呼び出し後、呼び出されます。また、onPrecondition()Falseの場合にも呼び出しが行われます。

補足. TextField の EnterKey クリックで、Submit するのを防ぐ

AjaxPreventSubmitBehavior.javaTextFieldBehavior として追加すると、EnterKeyクリックでSubmitするのを防いでくれます。
ので、TextField に 追加しました。

    // eMail
    TextField eMail = new RequiredTextField("email");
    eMail.setLabel(Model.of(getString("eMailLabelText")));
    eMail.add(StringValidator.maximumLength(150));
    eMail.add(EmailAddressValidator.getInstance());
    eMail.add(new AjaxPreventSubmitBehavior());
    add(eMail);

サーバ側の Submit URL に対する連打対策

続いて Submit URL に対する連打対策について、考えます。


DOS アタックを防ぐ

Dos Attack 的なものを防ぐだと、HTTP サーバ、Servlet Filter あたりでまずやるものなイメージがあります。
AP Server? は Dropwizard を使っているので、 Dropwizard で、そこあたりを実装することを考えました。
この記事内で書くのは、Wicket から外れるので、別記事にしました。

また、Wicket の RequestCycleListener で実装したものを公開している方がいましたので、
そちらのリンクを記載しておきます。

DOS FIlter を Wicket に適用する

通常のリクエストについては、org.eclipse.jetty.servlets.DoSFilter による制御で上手く動きましたが、
Ajax Request については、WicketFilter の処理が行われないため、Wicket の javascript で想定外のエラーが発生しました。3
[3] Servlet Filter として、DoSFilter を WicketFilterよりも、先に定義しているためです。
このため、DoS 制御の主処理は、DoSFilter でやりつつも、エラーハンドリングは、RequestCycleListener で行うようにしました。

以下、作成した、ソースコードの抜粋です。

  • Application.java
    // RequestCycleListener の登録
    getRequestCycleListeners().add(new DoSPreventRequestCycleListener());
  • DoSPreventRequestCycleListener.java

    • onBeginRequest で、実装を行いましたが、throw しても、エラーとして認識されず、onRequestHandlerResolved で処理を行うようにしました。

    • Ajax リクエストでない場合のみ、Exceptionをスロー、Ajax の処理は、AjaxButton内で記載するようにしました。
      実は、onRequestHandlerResolved 内で記載してもうまくいくかもしれません。

    • HTTP ERROR CODE 429 は、 Too Many Requests です。
      DoSFilter のデフォルトのエラーコードが、429 のため踏襲しています。

    • チェックエラー時は、httpServletRequest.removeAttribute("DoSFilter.Tracker"); で属性削除しています。
      これは、削除しないと、無限ループに陥り、StackOverFlowが発生したためです。4
      [4] 処理として、不安定な処理かと、、もっといい方法があるかもしれません。

import org.apache.wicket.request.IRequestHandler;
import org.apache.wicket.request.cycle.AbstractRequestCycleListener;
import org.apache.wicket.request.cycle.RequestCycle;
import org.apache.wicket.request.http.WebRequest;
import org.apache.wicket.request.http.flow.AbortWithHttpErrorCodeException;

import javax.servlet.http.HttpServletRequest;

/**
 * DoSPreventRequestCycleListener
 */
public class DoSPreventRequestCycleListener extends AbstractRequestCycleListener {
    @Override
    public void onBeginRequest(RequestCycle cycle) {
        // Do Nothing...
    }

    @Override
    public void onRequestHandlerResolved(RequestCycle cycle, IRequestHandler
            handler) {
        // Cast WebRequest
        WebRequest webRequest = (WebRequest) cycle.getRequest();
        // Ajax リクエストではない通常のリクエストの場合、DoSFilter.Trackerが設定されている場合、Denyする。
        if (!webRequest.isAjax()) {
            HttpServletRequest httpServletRequest = (HttpServletRequest) webRequest.getContainerRequest();
            Object tracker = httpServletRequest.getAttribute("DoSFilter.Tracker");
            if (tracker != null) {
                httpServletRequest.removeAttribute("DoSFilter.Tracker");
                throw new AbortWithHttpErrorCodeException(429, "Too Many Requests");
            }
        }
    }
}
  • AjaxButton.java
    • Form に追加したAjaxButtonの処理で、onSubmit() で、DoSFilter.Trackerが存在したら、エラーとする処理を追加しました。
    AjaxButton sendMessage = new AjaxButton("sendMessage", Model.of(getString("messageButtonLabelText"))) {
        private static final long serialVersionUID = -8204140666393922700L;

        @Override
        protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
            HttpServletRequest httpServletRequest = (HttpServletRequest) form.getRequest().getContainerRequest();
            Object tracker = httpServletRequest.getAttribute("DoSFilter.Tracker");
            if (tracker != null) {
                httpServletRequest.removeAttribute("DoSFilter.Tracker");
                error(getString("messageMailDoubleSendError"));
                target.add(form);
                return;
            }
            // メール送信要求を登録
            ContactMailTaskManage contactMailTaskManage = (ContactMailTaskManage) form.getModelObject();
            mailTaskService.execute(contactMailTaskManage);
            // Formに対してのオペレーション
            token.regenerateToken();
            success(getString("messageMailSendSucceed"));
            target.add(form);
        }
    }
  • DoSFilter の定義値 Wicket 側で制御させるために、プロパティ値を以下のような設定としました。

  • delayMs を 0 にする。 delayMs を 0 にすることで、リクエストを遅延させる処理も行わず、Filter でException を送出しない設定となります。
    この際、属性DoSFilter.Tracker が設定されるため、この属性値の有無で、DoS検知にヒットしたかどうかを、判断するようにしました。


単純な2重送信を防止する

連打防止ではなく、単純な2重送信防止として、tokenを発行するHidden Filed の拡張クラスを作成しました。5
[5] 戻るボタンクリック後の制御もできるかもですが、そちらは試しておりません。

  • SynchTokenField
public class SynchTokenField extends HiddenField {

    private static final long serialVersionUID = 5172245669993637549L;

    private String token;

    /**
     * Construct.
     *
     * @param id
     */
    public SynchTokenField(String id) {
        super(id);
    }

    @Override
    protected void onInitialize() {
        super.onInitialize();
        setType(String.class);
        setRequired(true);
        setModel(Model.of(regenerateToken()));
        add(new IValidator<String>() {
            @Override
            public void validate(IValidatable<String> validatable) {
                log.info("validatable.getValue() = {} || token = {}", validatable.getValue(), token);
                if (token == null || !token.equals(validatable.getValue())) {
                    ValidationError validation = new ValidationError(getString("messageMailDoubleSendError"));
                    error(validation);
                }
            }
        });
    }

    protected final void onComponentTag(final ComponentTag tag) {
        super.onComponentTag(tag);
        tag.put("value", token);
    }

    public String regenerateToken() {
        token = UUID.randomUUID().toString();
        return token;
    }
}
  • AjaxButton#onSubmit() onSubmit()内で、メイン処理終了後に、regenerateToken() でtokenを再生成します。
    2度押しの場合、2度目はtokenが異なるので、validation エラーとなります。
    タイミング的に、2回走る可能性はありえるので、同期化は行ったほうがよさそうです。6
    [6] 要は、以下のコードだとバグりそうに思ってます。。もうちょっといい感じにできないかなと
    @Override
    protected void onSubmit(AjaxRequestTarget target, Form<?> form) {
        HttpServletRequest httpServletRequest = (HttpServletRequest) form.getRequest().getContainerRequest();
        Object tracker = httpServletRequest.getAttribute("DoSFilter.Tracker");
        if (tracker != null) {
            httpServletRequest.removeAttribute("DoSFilter.Tracker");
            error(getString("messageMailDoubleSendError"));
            target.add(form);
            return;
        }
        // メール送信要求を登録
        ContactMailTaskManage contactMailTaskManage = (ContactMailTaskManage) form.getModelObject();
        mailTaskService.execute(contactMailTaskManage);
        // Formに対してのオペレーション
        token.regenerateToken();
        success(getString("messageMailSendSucceed"));
        target.add(form);
    }

まとめ

Wicket で、2度押し制御を実装しました。以下、まとめます。

  • 通常のリクエストでは、PRG パターンで、F5 更新は防げるが、戻るボタンクリックで再送信、ボタン連打は防げないので、考慮が必要。
    また、Submitされた側での制御よりも、戻ってきたら、[有効期限切れです。]にしたほうがいいかもしれない。

  • Ajax リクエストだと、より、Wicket の制御で守られないので、独自で実装が必要。

  • DosFilter などを使うと、WicketのFilterの処理とぶつかる、Wicket の Ajax の部品の想定外の処理とぶつかって、制御が若干面倒くさい。

  • Wicket6 から、CsrfPreventionRequestCycleListener.java が追加されて、これはこのような用途にも使えるかもしれない。

  • Form でのToken発行は、基本的に、Stateless なページで使うもののような気はするが、Submit時に、tokenをNullにしておけば、
    戻った後は、Session上のtokenはNullだけど、HTML上のtokenはあるになって、戻る制御は効きそうな気はする。

だらだらと書きましたが、以上です。

コメント