詰まったこと
Play Framework を使っていたら form リクエストのところで下記のエラーにハマった。
[warn] p.filters.CSRF - [CSRF] Check failed because no or invalid token found in body for /user/add/input/submit
[warn] p.filters.CSRF - [CSRF] Check failed with NoTokenInBody for /user/add/input/submit
クリーンアーキテクチャとDDDの学習を兼ねたサンプル実装してたところで遭遇したもので、目的と少し外れるが、セキュリティに関する部分はちゃんと確認しておきたいと思っていたところなので、ついでに調査しとく。
CSRF(Cross-Site Request Forgeries)
CSRFイメージ。大体こんな感じ。
原因
徳丸本より
- form要素のaction属性にはどのドメインのURLでも指定できる
- クッキーに保管されたセッションIDは、対象サイトに自動的に送信される
対策
徳丸本より。下記の2つを実施
- CSRF対策の必要なページを区別する
- 正規利用者の意図したリクエストを確認できるように実装する
今回は「正規利用者の意図したリクエストを確認できるように実装」の部分をPlay Frameworkがやってくれているが、その部分の設定をちゃんとしていないのがおそらく原因かなと。
Play Framework での設定
とりあえず、公式 → Protecting against Cross Site Request Forgery
Play Frameworkデフォルトだと下記の場合が全て真の時に、CSRFのチェックが走る模様
- リクエストメソッドが、
GET
,HEAD
,OPTIONS
ではない - リクエストヘッダに
Cookie
もしくはAuthorization
が1つ以上ある - CORSフィルターがリクエスト元を信頼するような設定になっていない
とりあえず、PlayはPOSTリクエスト時にはCSRF対策をする必要がある。
PlayでCSRF対策を施すには、リクエストに対してCSRFTokenを付与する必要がある。
グローバルに設定する場合は下記をapplication.confに追記する。
play.filters.enabled += "play.filters.csrf.CSRFFilter"
Note: As of Play 2.6.x, the CSRF filter is included in Play’s list of default filters that are applied automatically to projects. See the Filters page for more information.
って記載があるから、2.6以上は、デフォルトでONなのかな?とちょっと思った。
tempalteにCSRF対策を施す
CSRF対策が必要なformリクエストを利用したい場合、RequestHeader を implicit パラメタとして渡す必要がある。
https://www.playframework.com/documentation/2.8.x/ScalaCsrf#Defining-an-implicit-Requests-in-Templates
@(...)(implicit request: RequestHeader)
通常はMessageProviderwインスタンスを必要とする、form helper を一緒に使うので下記のように書く模様
@(...)(implicit request: MessagesRequestHeader)
formリクエストにCSRFトークンをリクエストに付与するためには、routes.Controller.method
をCSRFで囲んであげればいいみたい。
下記がその例
@import helper._
@form(CSRF(routes.ItemsController.save())) {
...
}
私が学習用に書いているやつだと下記のような感じになった。
@import viewmodels.user.add.UserAddInputViewModel
@import viewmodels.user.add.UserAddForm.UserAddFormData
@import views.html.helper.options
@import views.html.helper.CSRF
@(model: UserAddInputViewModel, form: Form[UserAddFormData])(implicit request: MessagesRequestHeader)
@main("UserAddInput") {
<div>
@helper.form(action = CSRF(routes.UserController.addInputSubmit())) {
@helper.inputText(form("name"))
@helper.inputRadioGroup(field = form("roleId"), options("admin" -> "管理者", "member" -> "一般"))
<input type="submit" value="Register" />
}
</div>
}
これから生成されるものが下記のようなHTML
<!DOCTYPE html>
<html lang="en">
<head>
...
</head>
<body>
<div>
<form action="/user/add/input/submit?csrfToken=7e4b6f4c5c36a7fd735ce78f0ab21c34992a321a-1599291342214-86d7a239d0d4b8741be888ca"
method="POST">
<dl class=" " id="name_field">
<dt><label for="name">name</label></dt>
<dd>
<input type="text" id="name" name="name" value=""/>
</dd>
<dd class="info">Minimum length: 3</dd>
<dd class="info">Maximum length: 10</dd>
</dl>
<dl class=" " id="roleId_field">
<dt><label for="roleId">roleId</label></dt>
<dd>
<span class="buttonset" id="roleId">
<input type="radio" id="roleId_admin" name="roleId" value="admin"/>
<label for="roleId_admin">管理者</label>
<input type="radio" id="roleId_member" name="roleId" value="member"/>
<label for="roleId_member">一般</label>
</span>
</dd>
</dl>
<input type="submit" value="Register"/>
</form>
</div>
</body>
</html>
formリクエストを見ると、csrfTokenが付与されているのがわかる。
<form action="/user/add/input/submit?csrfToken=7e4b6f4c5c36a7fd735ce78f0ab21c34992a321a-1599291342214-86d7a239d0d4b8741be888ca"
method="POST">
こうすれば、templateからCSRF対策ができる。
ActionごとにCSRFフィルタリングを適用する
グローバルにやりたくない場合は、Actionごとに定義することもできるみたいだが、今回はあまり興味ないのでスルー
追記
下記のように書くと、hiddenパラメータとして、CSRF Tokenが渡せることに気づいた。
@helper.form(action = routes.UserController.updateInputSubmit(model.id)) {
@helper.CSRF.formField // <- これを追記しておく
@helper.inputText(form("name"), '_label -> "UserName")
<input type="submit" value="Register" />
}