Wicket の AjaxButton で mail 送信要求を送る 問い合わせフォームを実装していたのですが、
ボタン連打で連続リクエスト送信ができてしまったので、
その回避方法を考えます。
前提. 使用している Wicket, EclipseLink の Version
- 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.java を TextField
の Behavior
として追加すると、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
が存在したら、エラーとする処理を追加しました。
- Form に追加したAjaxButtonの処理で、onSubmit() で、
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はあるになって、戻る制御は効きそうな気はする。
だらだらと書きましたが、以上です。
コメント