Django Rest Framework で CORS の設定が効かないと思い込んでいた話

Django Rest Framework で開発していて、クライアントとの連携用に CORS の設定が必要になった。同じ localhost でも、どうやらポートが違うと CORS エラーが出るみたいで厄介。

Sponsored Link

django-cors-headers の設定

少し調べたところ、django-cors-headers というのを入れればいいらしい。

pipenv install django-cors-headers

ということで、pipenv でインストールした。

その後、settings.py を開いて、INSTALLED_APPScorsheaders を、MIDDLEWARE の上の方に corsheaders.middleware.CorsMiddleware を追記する。

私の場合は Whitenoise という Django 上で動く静的ファイルサーバーを追加していたので、以下のようになった。

# Application definition

INSTALLED_APPS = [
    'whitenoise.runserver_nostatic',  # WhiteNoise
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'rest_framework',  # Django Rest Framework
    'corsheaders',  # Django CORS Headers
    'app',
]

MIDDLEWARE = [
    'django.middleware.security.SecurityMiddleware',
    'corsheaders.middleware.CorsMiddleware',  # Django CORS Headers
    'whitenoise.middleware.WhiteNoiseMiddleware',  # WhiteNoise
    'django.contrib.sessions.middleware.SessionMiddleware',
    'django.middleware.common.CommonMiddleware',
    'django.middleware.csrf.CsrfViewMiddleware',
    'django.contrib.auth.middleware.AuthenticationMiddleware',
    'django.contrib.messages.middleware.MessageMiddleware',
    'django.middleware.clickjacking.XFrameOptionsMiddleware',
]

あとはデバッグ時だけ CORS を全開にするようにする。
settings.py の上の方に DEBUG = True と定義されているので、それを判定に使う。

# DEBUG 時のみ、CORS を許可する
if DEBUG is True:
    CORS_ALLOW_ALL_ORIGINS = True

このように CORS_ALLOW_ALL_ORIGINS を True に設定すると、他の設定は全てすっとばして CORS を有効化してくれるらしい。
CORS_ALLOW_ALL_ORIGINS を設定した場合、他の一切の設定が無視されるので、細かく設定をしたい場合は別途設定する必要があると思う。

盛大な勘違い

これで全てうまくいくはずだったんだけど、いざブラウザで JSON API を叩いてみても、いわゆる CORS ヘッダーである Access-Control-Allow-Origin ヘッダーが付与されていない。

さらに、Whitenoise が提供している静的ファイルに関しては、Access-Control-Allow-Origin: * となっていて CORS が許可されていて、不可解。

ネットの情報で「Middleware の順番が悪い」だとか書いてあったので順番を上げ下げしたり設定を変えたりやったけど、結局効果なし。

散々調べた結果、以下の2つの結論にたどり着いた。

django-cors-headers はリクエストに Origin ヘッダーが付与されていないとレスポンスに CORS ヘッダーを付与しない

…つまりどういうことかというと、「XHR (axios) や fetch 以外のアクセスでは、Access-Control-Allow-Origin ヘッダーを付与しない」ということ。

まず、ブラウザは通常のアクセス(= JavaScript による HTTP リクエストではない)では、Origin ヘッダーを付与しないらしい。

そもそも Origin ヘッダーとは、詳細なパスが公開されない Referrer ヘッダーのようなもので[1]最近は Referrer ヘッダーもデフォルトでパスが公開されないようになってきたので大して変わらなくなってきてはいる、そのリクエストがどのドメイン:ポートから来たのかを判定するためのもの。

Origin - HTTP | MDN
Origin リクエストヘッダーは、どこがフェッチの原点であるかを示します。パス情報は含まれず、サーバー名のみが含まれます。これは、 CORS リクエストと、同様に POST リクエストでも送信されます。 Referer ヘッダーと似ていますが、パス全体が公開されるわけではない点が異なります。

クロスドメインリクエスト(= XHR や fetch が実行されているドメイン:ポートと異なる場合)の場合、どのブラウザでも Origin ヘッダーが付与されるらしい。

つまり、Origin ヘッダーがリクエストにある場合、概ねブラウザから XHR や fetch で HTTP リクエストされていると判断することができる(はず)。

django-cors-headers/middleware.py at main · adamchainz/django-cors-headers
Django app for handling the server headers required for Cross-Origin Resource Sharing (CORS) - django-cors-headers/middleware.py at main · adamchainz/django-cor...

実際、django-cors-headers のコードを読んでみても、Origin ヘッダーがあるかどうかで CORS ヘッダーの有効化を判定しているように見える。

つまり、最初から問題など発生していなかったのだ。
単に XHR や fetch 以外の Origin ヘッダーが付与されないリクエストでは、そもそも CORS の制限がなく CORS ヘッダーを付与する必要がないため、Access-Control-Allow-Origin ヘッダーが付与されない、というだけの話だったらしい。一件落着。

Whitenoise は、デフォルトで全ての静的ファイルに CORS ヘッダーを付与する

django-cors-headers が実は何の問題もなかったことが判明したところで、試しに CORS_URLS_REGEXCORS_ORIGIN_WHITELIST 設定を使って、/api/ 以下のパスにしか CORS を適用しないようにしてみた。

このとき、CORS_ALLOW_ALL_ORIGINS は消しておくこと。前述したように、この設定があると他の一切の設定がガン無視されるため。

すると、Django 自体が提供する index.html とかにはちゃんと適用されたのだが、Whitenoise が提供する静的ファイルに関しては、/api/ 以下以外のパスに置かれているのにも関わらず、CORS ヘッダーが有効になっていた。

これはどういう事かと思い Whitenoise のドキュメントを見てみると、ちゃんと書いてあった。

WHITENOISE_ALLOW_ALL_ORIGINS デフォルト: True

すべての静的ファイルの Access-Control-Allow-Origin: * ヘッダーを送信するかどうかを切り替えます。
これにより、静的ファイルのクロスオリジンリクエストが可能になります。つまり、静的ファイルはCDN経由で提供されているため、別のドメインで提供されている場合でも、期待どおりに機能し続けます。これがないと、静的ファイルはほとんど機能しますが、Firefoxでのフォントの読み込み、キャンバス要素の画像へのアクセス、またはその他の不思議なことに問題が発生する可能性があります。
W3Cは、この動作がパブリックにアクセス可能なファイルに対して安全であると明示的に述べています

http://whitenoise.evans.io/en/stable/django.html

…つまり、「パブリックにアクセス可能な静的ファイルはリクエストされたところで何ら影響をもたらさないし、CORS ヘッダーを付与しないとなぜか Firefox とかでバグるのでデフォルトで付与してる、オフにしたいなら明示的に設定できるよ」ってことみたい。

ログイン中のサイトの API を他のサイトから叩けたとしたら、何だってやり放題になってしまう。なぜ CORS という制約があるかと言えば、そういったセキュリティ上の脅威から守るためだ。
だからパブリックに公開されていて、さらに叩いたところで Web サービス側への変更が行われない静的ファイルは CORS が許可されていようと問題ない、という趣旨らしい。

確かにその通りで、もし外部からリクエストされるのがまずい静的ファイルなのなら、そもそもログインしたユーザーしかアクセスできないようにするか、URL を 推測不可能なものにするべきなのだろう。

Should Whitenoise set CORS headers by default for all files? · Issue #5 · evansd/whitenoise
Currently, Whitenoise sets an Access-Control-Allow-Origin: * header by default for all font files. This was done so that your fonts continue to work in Firefox ...

この件に関してはは GitHub の Issue にも上がっていた。2014 年のものなのでかなり古め(Issue 番号から見ても、Whitenoise の開発初期にできたものであることが伺える)。

どうやら当時は CORS の整備が全てのブラウザで整っていなかったようで、CORS ヘッダーを付与しないと一部のブラウザでうまく動かない、という事例がいくつかあったらしい。
2021 年現在もデフォルトでそうなっているのはその名残りなのだろう。

ただ、2014 年当時から 7 年が経過し、現在では Firefox でも CORS 周りの問題は解決されているはず。
IE 絡みの問題もあるらしいけど、開発中のプロダクトでは IE をそもそもサポートしないので、これも問題ない。

ということで、なんとなく CORS ヘッダーを無効にしておくことにした。

# Whitenoise で提供される静的ファイルに CORS を許可しない
WHITENOISE_ALLOW_ALL_ORIGINS = False

settings.py のどこかにこのように追記しておけば、Whitenoise が提供する静的ファイルに CORS ヘッダーが付与されるのをオフにできる。

感想

CORS って色々ややこしいし面倒くさいな…って感想。

ただ、CORS はセキュリティのためには必要不可欠な機能であるということもよく理解できた。
かなりはまって調査に時間を使ってしまったけれど、結果的にそのあたりの理解をより深めることができたので、まあ良しとしておく。

References

References
1 最近は Referrer ヘッダーもデフォルトでパスが公開されないようになってきたので大して変わらなくなってきてはいる

コメント