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) 1 AjaxButton のクリック直後に呼び出されます。
onBefore(final CharSequence before) 2 onInit() の呼び出し後に呼び出されます。
onPrecondition(final CharSequence before) 3 onBefore() 呼び出し後に呼び出されます。確認ダイアログを挿入すると、キャンセルクリックで後処理の中断ができます。
onPrecondition(final CharSequence before) 4 onBefore() 呼び出し後に呼び出されます。確認ダイアログを挿入すると、キャンセルクリックで後処理の中断ができます。
onBeforeSend(final CharSequence beforeSend) 5 onPrecondition() の呼び出し後に呼び出されます。リクエスト送信直前
onAfter(final CharSequence beforeSend) 5 onBeforeSend() の呼び出し後に呼び出されます。リクエスト送信直後
onSuccess(final CharSequence beforeSend) 6 onAfter() の呼び出し後、リクエストが成功した場合に呼び出されます。
onFailure(final CharSequence beforeSend) 6 onAfter() の呼び出し後、リクエストが失敗した場合に呼び出されます。
onComplete(final CharSequence beforeSend) 7 onSuccess()onFailure() の呼び出し後、呼び出されます。
onDone(final CharSequence beforeSend) 8 onComplete() の呼び出し後、呼び出されます。また、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はあるになって、戻る制御は効きそうな気はする。

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

コメント