DjangoでSameSite属性を扱う方法


2020年2月にリリースされるGoogle Chrome 80から、SameSite属性がないCookieはSameSite=Laxとして扱われるようになります。

詳細は以下Google公式サイトを参照してください。

Google Developers Japan: 新しい Cookie 設定 SameSite=None; Secure の準備を始めましょう

今回は、DjangoでSameSite属性に対応するにはどうすればいいのかについて解説したいと思います。

SameSite属性についての詳しい解説については省略します。詳しくは以下の記事を参照してください。

SameSite cookies explained

Cookie の性質を利用した攻撃と Same Site Cookie の効果 | blog.jxck.io

DjangoでSameSiteに対応するには

Djangoでは2.1からCookieにSameSite属性を設定する機能が追加されました。

ただし、2.1は2019年12月2日をもってサポートを終了しています。公式サイトの「Supoorted Versions」を確認して、現時点でサポートされているバージョンを使うようにしましょう。

2.1からはHttpResponse.set_cookiesamesiteが追加されました。

また、以下の設定が使えるようなっています。

3.0からは以下も追加されました。

サンプルアプリケーションを作って実際の挙動を検証

では、実際にアプリケーションを作ってSameSite属性の挙動を確認してみましょう。サンプルコードで使っている環境は以下のとおりです。

  • Python 3.8.1
  • Django 3.0.2
  • django-extensions 2.2.5
  • Werkzeug 0.16.0
  • pyOpenSSL 19.1.0
  • Google Chrome 79.0.3945.130

SameSite=Noneを設定する場合はSecure属性が必須なので、django-extensionsWerkzeugpyOpenSSLでローカル環境でもhttpsでアクセスできるようにしています。

samesite_exampleというプロジェクトを作成して、以下のようにコードを編集します。

samesite_example/settings.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# 編集が必要な箇所のみ記載

INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',

    'django_extensions',  # これを追加
]

ALLOWED_HOSTS = [
    'mysite.private',  # これを追加
]

samesite_example/urls.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
from django.contrib import admin
from django.urls import path

from . import views

urlpatterns = [
    path('set-cookies/', views.set_cookies),
    path('get-cookies/', views.get_cookies),
    path('admin/', admin.site.urls),
]

samesite_example/views.py

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
from django.http import HttpResponse
from django.utils import timezone
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt


def set_cookies(request):
    formatted_now = timezone.now().isoformat()
    response = HttpResponse('OK')
    response.set_cookie('example-lax', formatted_now, secure=True, samesite='Lax')
    response.set_cookie('example-strict', formatted_now, secure=True, samesite='Strict')
    response.set_cookie('example-none', formatted_now, secure=True)
    # Noneの場合だけ書き方を変える必要がある(理由は後述)
    response.cookies['example-none']['samesite'] = 'None'
    return response


@xframe_options_exempt
@csrf_exempt
def get_cookies(request):
    keys = [
        'example-lax',
        'example-strict',
        'example-none',
    ]
    rows = []
    for key in keys:
        value = request.COOKIES.get(key, '')
        rows.append(f'{key}={value}')
    res = HttpResponse('<br>'.join(rows))
    res['Access-Control-Allow-Origin'] = 'http://othersite.private:3000'
    res['Access-Control-Allow-Credentials'] = 'true'
    return res

Django 2.2・3.0では、HttpResponse.set_cookieに渡せるsamesiteの値はLaxStrictのみです。Noneは渡せません(渡すとValueErrorが発生します)。

以下プルリクエストでNoneを渡せるよう対応してmasterブランチにはマージ済みですが、リリースは3.1を予定しているようです。1

https://github.com/django/django/pull/11894

また、Djangoアプリケーションとは別に以下のindex.htmlファイルを用意します。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Other Site</title>
</head>
<body>
  <h1>Other Site</h1>
  <h2>Case 1 (a tag)</h2>
  <a target="_blank" href="https://mysite.private:8000/get-cookies/">https://mysite.private:8000/get-cookies/</a>
  <h2>Case 2 (iframe tag)</h2>
  <iframe src="https://mysite.private:8000/get-cookies/" frameborder="0"></iframe>
  <h2>Case 3 (XMLHttpRequest GET Request)</h2>
  <p id="case3-output">Result</p>
  <script>
    (function() {
      function reqListener () {
        let elem = document.getElementById('case3-output');
        elem.innerHTML = this.responseText;
      }
      let xhr = new XMLHttpRequest();
      xhr.withCredentials = true;
      xhr.addEventListener('load', reqListener);
      xhr.open('GET', 'https://mysite.private:8000/get-cookies/');
      xhr.send();
    })();
  </script>
  <h2>Case 4 (XMLHttpRequest POST Request)</h2>
  <p id="case4-output">Result</p>
  <script>
    (function() {
      function reqListener () {
        let elem = document.getElementById('case4-output');
        elem.innerHTML = this.responseText;
      }
      let xhr = new XMLHttpRequest();
      xhr.withCredentials = true;
      xhr.addEventListener('load', reqListener);
      xhr.open('POST', 'https://mysite.private:8000/get-cookies/');
      xhr.send();
    })();
  </script>
  <h2>Case 5 (GET Form)</h2>
  <form action="https://mysite.private:8000/get-cookies/" method="GET">
    <button>Send</button>
  </form>
  <h2>Case 6 (POST Form)</h2>
  <form action="https://mysite.private:8000/get-cookies/" method="POST">
    <button>Send</button>
  </form>
</body>
</html>

/etc/hosts(WindowsならC:\Windows\System32\drivers\etc\hosts)に以下を書きます。

127.0.0.1       mysite.private othersite.private

以下コマンドでアプリケーションを起動します。

  • mysite.privateで表示させるサイト
    • Djangoプロジェクトの直下でpython manage.py runserver_plus --cert-file cert.crt
  • othersite.privateで表示させるサイト
    • index.htmlがあるディレクトリの直下でpython3.8 -m http.server -b 127.0.0.1 3000

https://mysite.private:8000/set-cookies/ をブラウザで開いてください。ブラウザ上にSameSite=LaxSameSite=StrictSameSite=Noneを指定されたCookieが作られます。

https://mysite.private:8000/set-cookies/を開いた直後のCookieの値

https://mysite.private:8000/set-cookies/を開いた直後のCookieの値

次に、https://mysite.private:8000/get-cookies/をブラウザで開いてください。上記URLで作られたCookieが送られ、それぞれが表示されているはずです。

https://mysite.private:8000/get-cookies/を開いた直後の画面の内容

https://mysite.private:8000/get-cookies/を開いた直後の画面の内容

今度はhttp://othersite.private:3000/からクロスサイトでhttps://mysite.private:8000/get-cookies/にリクエストを送ってみましょう。

http://othersite.private:3000/では6種類のケースを用意して、それぞれ検証しています。

http://othersite.private:3000/を開いた直後の画面の内容

http://othersite.private:3000/を開いた直後の画面の内容

上記の結果を表にまとめます(「送信する」は太字にしました)。

SameSite Case 1 (a tag) Case 2 (iframe tag) Case 3 (XMLHttpRequest GET Request) Case 4 (XMLHttpRequest POST Request) Case 5 (GET Form) Case 6 (POST Form)
Lax 送信する 送信しない 送信しない 送信しない 送信する 送信しない
Strict 送信しない 送信しない 送信しない 送信しない 送信しない 送信しない
None 送信する 送信する 送信する 送信する 送信する 送信する

NoneはどのケースでもCookieを送信する(つまり従来どおり)、Strictは逆にどのケースでも送信しない、LaxNoneStrictの中間のようなルールです。

ほとんどのウェブサービスではLaxが適切な値になるはずですが、ブログパーツのようにクロスサイトでのリクエストが必要な場合はNone、セキュリティ要件が厳しい場合はStrictを検討したほうがいいでしょう。


  1. Djangoのコミッターfelixxmさんのコメントを参照: https://github.com/django/django/pull/11894#issuecomment-565306945 ↩︎


comments powered by Disqus