Django Rest FrameworkとCSRF
TL;DR
- Django Rest Frameworkは提供している全ての View にデフォルトで
csrf_exempt
を仕込んでやがるので気をつけましょう。 - nginx で Cookie 転送するために
proxy_set_header Host $host;
を設定しているとポート番号が落ちてOriginチェック通らないのでproxy_set_header Host $http_host;
にしましょう。
事の経緯
社内向けの内製システム開発をしていて、ローカル環境の構築にDocker ComposeでSolidJSのSPAがViteで走っているコンテナとDjangoの開発サーバが走っているコンテナを作って nginx のコンテナを間に入れてフロントエンドとバックエンド両方のリバースプロキシとして運用していました。バックエンドが完成しフロントエンドを作り始め、取り急ぎユーザ作成とログインの導線を動作確認。うまくいったため業務を終わりました。
翌朝、ログイン画面からのリダイレクトを仕込んで動くか確認しようとしたところ失敗、エラーは以下の通り。
CSRF Failed: Origin checking failed - http://localhost:7000 does not match any trusted origins.
おや?昨日は出ていなかったCSRFのエラーが。何もしていないはずなのに突然死ぬとはこれいかに...... ということでとりあえず色々と確認。CSRF_TRUSTED_ORIGIN
を設定してみたり X-XSRF-TOKEN
(DjangoのCSRFミドルウェアはデフォルト X-CSRFToken
ですが気分で変えました)をリクエストに仕込んでみたり。しかし何をやってもうまくいかない。変化があったとすればログインしたことでCookieがついているくらい。まさかと思いCookieを全部消してみたところなんとエラーは消えました。
その後もしばらく確認していてどうやらセッションのCookieがある時だけOriginのチェックに失敗するらしいことがわかりました。もはやドキュメントからは何もわからないのでとりあえずCSRFのミドルウェアのコードを読んでみることに。
ここでポイントなのはOriginチェックで死ぬのは442-446行目の以下の部分に該当する場合のみということです。
でもこのチェックをしているコードを読んでみてもセッションがあるときだけ失敗するような挙動は見当たりません。
つまりうまくいっているケースではこのコードパスにたどり着いていないと考えるのが妥当です。となると以下の4つの条件分岐のどこかで早期リターンしているということになります。
ミドルウェアを書いてリクエストの中身をログに出してみたところ、なんと csrf_exempt
が True
になっていました。これはおかしいです。なぜならDjangoのViewは csrf_exempt
は明示的につけないといけず、デフォルトではattributeが生えていないので False
になるはずです。ここで「あ、もしかしてDjango Rest Frameworkがやってんな?」という疑念が生まれました。そして検索してみたところ以下のような記事が......
「嘘だろ?」という気持ちでコードを見に行ったら......
なんと「session使って認証してる場合はきっちりCSRF対応されるよ、それ以外の認証方式の場合にはcsrf_exemptにするよ」と書いてありました。確かにRESTのフレームワークである以上第三者が使う前提で設計するというのはわからなくもないですがなんともまあ想定外のトラップでした。とはいえこれで「非ログイン時にうまくいく理由」はわかりましたが、「ログイン時にうまくいかない理由」はまだわかりません。なぜならどのみちOriginの検証に失敗しているからです。
というわけでもう一度Originチェックのコードを洗い、該当しそうな変数を片っ端から確認したところ request.get_host()
が参照している request.META['HTTP_HOST']
からポート番号が落ちていることがわかりました。ブラウザ側のリクエストのヘッダではきちんとポート番号が入っているので nginx が落としているということになります。その線で調べてみると $host
変数はポート番号が落ちて $http_host
は落ちないということがわかり、proxy_set_header Host $host;
となっていたところを proxy_set_header Host $http_host;
に変えたら無事うまくいきましたとさ。