Open1

Django Rest FrameworkとCSRF

Kotone/NanoKotone/Nano

TL;DR

  1. Django Rest Frameworkは提供している全ての View にデフォルトで csrf_exempt を仕込んでやがるので気をつけましょう。
  2. 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のミドルウェアのコードを読んでみることに。

https://github.com/django/django/blob/0dd29209091280ccf34e07c9468746c396b7778e/django/middleware/csrf.py#L420-L489

ここでポイントなのはOriginチェックで死ぬのは442-446行目の以下の部分に該当する場合のみということです。

https://github.com/django/django/blob/0dd29209091280ccf34e07c9468746c396b7778e/django/middleware/csrf.py#L442-L446

でもこのチェックをしているコードを読んでみてもセッションがあるときだけ失敗するような挙動は見当たりません。

https://github.com/django/django/blob/0dd29209091280ccf34e07c9468746c396b7778e/django/middleware/csrf.py#L277-L301

つまりうまくいっているケースではこのコードパスにたどり着いていないと考えるのが妥当です。となると以下の4つの条件分岐のどこかで早期リターンしているということになります。

https://github.com/django/django/blob/0dd29209091280ccf34e07c9468746c396b7778e/django/middleware/csrf.py#L421-L438

ミドルウェアを書いてリクエストの中身をログに出してみたところ、なんと csrf_exemptTrue になっていました。これはおかしいです。なぜならDjangoのViewは csrf_exempt は明示的につけないといけず、デフォルトではattributeが生えていないので False になるはずです。ここで「あ、もしかしてDjango Rest Frameworkがやってんな?」という疑念が生まれました。そして検索してみたところ以下のような記事が......

https://qiita.com/romgaran/items/af48606b7188f420f24c

「嘘だろ?」という気持ちでコードを見に行ったら......

https://github.com/encode/django-rest-framework/blob/2510456817d9d2840a4080e69a0efb8a4da423ac/rest_framework/views.py#L121-L144

なんと「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; に変えたら無事うまくいきましたとさ。