前回 はエラーハンドリングを組み込んでみました。今回は CSRF 対策を共通処理っぽく入れてみたいと思います。
コードはこちら
CSRF
これです。
SpringBoot だと @EnableWebSecurity
付ければ有効になるアレです。
Micronaut の場合は自分でやる必要がありそうです。なるほど。
View をレンダリングする時にデータを埋め込む
html を返す Controller それぞれで csrf トークンを生成してサーバ上の Session とレスポンスに設定する処理を書いてもいいですけど、人間なのでうっかり忘れることもあると思います。なので、共通的な処理として定義した方が良さそうだと考えました。
ViewModelProcessor
JavaDoc には
Implementers of ViewModelProcessor process the ModelAndView and modify it prior to rendering by either adding or removing entries.
と書いてあります。レンダリングの前に処理を差し込むことができそうです。
@Slf4j @Singleton public class CsrfViewModelProcessor implements ViewModelProcessor { public static final String CSRF_PARAMETER_KEY = "csrfToken"; @Override public void process( @Nonnull HttpRequest<?> request, @Nonnull ModelAndView<Map<String, Object>> modelAndView) { if (Objects.equals(request.getMethodName(), "GET")) { // 1 setCsrfForGet(request, modelAndView); } else { setCsrfForPost(request, modelAndView); } } private void setCsrfForGet( // 2 HttpRequest<?> request, ModelAndView<Map<String, Object>> modelAndView) { var modelOpt = modelAndView.getModel(); if (modelOpt.isEmpty()) { return; } var sessionOpt = SessionForRequest.find(request); if (sessionOpt.isEmpty()) { return; } sessionOpt.ifPresent( session -> { var csrf = UUID.randomUUID().toString(); session.put(CSRF_PARAMETER_KEY, csrf); var responseMap = modelOpt.orElseThrow(() -> new AssertionError("invalid")); responseMap.put(CSRF_PARAMETER_KEY, csrf); }); } private void setCsrfForPost( // 3 HttpRequest<?> request, ModelAndView<Map<String, Object>> modelAndView) { var requestParameterOpt = request.getBody(Map.class); if (requestParameterOpt.isEmpty()) { return; } var modelOpt = modelAndView.getModel(); if (modelOpt.isEmpty()) { return; } @SuppressWarnings("unchecked") var requestParameter = (Map<String, Object>) requestParameterOpt.orElseThrow(() -> new AssertionError("invalid")); var responseMap = modelOpt.orElseThrow(() -> new AssertionError("invalid")); var csrf = (String) requestParameter.get(CSRF_PARAMETER_KEY); responseMap.put(CSRF_PARAMETER_KEY, csrf); } }
ViewModelProcessor を implements して @Singleton
つけると有効になります。
- request パラメータの method で処理を分けます
- GET の時は csrf 用の token を作成し、Session とレスポンスに設定します
- GET だけで良いかはケースバイケースかも
- POST の時はリクエストパラメータの csrf 用の token をレスポンスに設定します
- POST だけで良いかはケースバイケースです
- formタグは、GET と POST メソッドしかサポートしておらず、Micronaut は SpringBoot で言うところの HiddenHttpMethodFilter の仕組みがないので、このコードでは GET と POST しか送らない強い気持ちでやってます。*2
html 側に <input type="hidden" name="csrfToken" th:value="${csrfToken}"/>
的なのを設定するとレンダリングした html に埋め込まれているのが確認できます。
POST の時に csrf チェック
チェックは filter を使ってみましょう。
OncePerRequestHttpServerFilter
コードは以下のような形になります。
@Slf4j @RequiredArgsConstructor @Filter(patterns = "/**", methods = HttpMethod.POST) // 1 public class CsrfFilter extends OncePerRequestHttpServerFilter { private final ViewsRenderer viewsRenderer; @Override public int getOrder() { return LOWEST_PRECEDENCE; } @Override protected Publisher<MutableHttpResponse<?>> doFilterOnce( HttpRequest<?> request, ServerFilterChain chain) { if (validateCsrfRequest(request)) { // 2 return chain.proceed(request); } return Publishers.just( HttpResponse.status(HttpStatus.FORBIDDEN) .body(viewsRenderer.render("forbidden", Collections.EMPTY_MAP)) .contentType(MediaType.TEXT_HTML)); } private boolean validateCsrfRequest(HttpRequest<?> request) { if (Objects.equals(request.getUri(), UriBuilder.of("/login").build())) { // 3 return true; } try { var session = SessionForRequest.find(request) .orElseThrow(() -> new IllegalStateException("Session is empty.")); var sessionValue = session .get(CsrfViewModelProcessor.CSRF_PARAMETER_KEY, String.class) .orElseThrow( () -> new IllegalStateException( "Session(" + CsrfViewModelProcessor.CSRF_PARAMETER_KEY + ") is empty.")); @SuppressWarnings("unchecked") Map<String, Object> body = request.getBody(Map.class).orElse(Map.of()); var requestValue = (String) body.get(CsrfViewModelProcessor.CSRF_PARAMETER_KEY); return Objects.equals(sessionValue, requestValue); // 4 } catch (IllegalStateException e) { log.info("Invalid request: {}", e.getMessage(), e); return false; } } }
OncePerRequestHttpServerFilter を extends して @Filter
つけると有効になります。
- patterns や methods で適応条件を指定できます
- csrf リクエストが正しいかチェックし、正しければ後続処理を行います
- 不正だった場合、
src/main/resources/views/forbidden.html
をレンダリングするようにしています
- 不正だった場合、
- 本来はログインの時も CSRF トークンチェックを入れた方が良いと思うのですが、むやみに Session 作られたくないのでひとまず除外しました
- session 上に格納している CRSF トークンとリクエストパラメータで設定している CSRF トークン値を比較します
Micronaut を起動してログイン成功した後に http://localhost:8080/tasks/add にアクセスして、csrfToken
の値を書き換えて
登録するボタンをクリックすると
filter のチェックでエラー画面がレスポンスされました。
まとめ
今回は共通処理について書きました。SpringBoot のように至れり尽くせり感は無いですが、欲しけりゃ自分で好きなように作りな、というこスタンスは嫌いではありません。Web アプリ面倒ですね...。