※2020年時点の内容です!

チーム開発でDjangoを使うことになって、自分は使うのが初めてなので急いで勉強中です。

Djangoでは、html上に{% csrf_token %}と記述すると、CSRF対策になるとされています。この記述がどんなふうにCSRF対策になっているかをメモしていきます。

CSRFのフロー

まずはCSRFのフローを見ていきます。 ここでは、例としてTwitterのような投稿型のウェブサイトを想定して(Twitterとかはだいぶ違うけど、イメージとして)、CSRFによる乗っ取り攻撃を例にしてみます。

①正常なフロー

まずはサービスの正常な機能例です。ユーザーがサービスにログインして投稿を行うフローを示します。

sequenceDiagram ユーザー->>ウェブサーバ(正規):①ログインする/id,passwordを渡す ウェブサーバ(正規)->>ウェブサーバ(正規):②id,passwordを認証する ウェブサーバ(正規)->>ウェブサーバ(正規):③セッションを作成 ウェブサーバ(正規)->>ユーザー:④セッションを渡す、ログイン後ページを表示 ユーザー->>ウェブサーバ(正規):⑤投稿を作成、投稿データとセッションを渡す ウェブサーバ(正規)->>ウェブサーバ(正規):⑥セッションidの一致確認後、投稿をアップロード
図1:サービスの正常な機能例

④ではセッションidはCookieによって渡されます。⑥ではCookieによって渡されたセッションidとサーバー側のセッションidを比較します。これによって、サーバーからセッションidを受け取っていない外部からの投稿を受け付けません。

②攻撃例のフロー

CSRFでの攻撃例は以下の通りです。

sequenceDiagram ユーザー->>ウェブサーバ(正規):①ログインする/id,passwordを渡す ウェブサーバ(正規)->>ウェブサーバ(正規):②id,passwordを認証する ウェブサーバ(正規)->>ウェブサーバ(正規):③セッションを作成 ウェブサーバ(正規)->>ユーザー:④セッションを渡す、ログイン後ページを表示 攻撃者-->ユーザー:⑤攻撃用のURLを踏ませる(勝手に踏んでもらう) ウェブサーバ(攻撃用)->>ユーザー:⑥攻撃用のHTMLを返す ユーザー->>ユーザー:⑦攻撃用HTMLに含まれている攻撃用スクリプトによって投稿が作成されている ユーザー->>ウェブサーバ(正規):⑧(攻撃用スクリプトによって)投稿データを渡す ウェブサーバ(正規)->>ウェブサーバ(正規):⑨セッションidの一致確認後、投稿をアップロード
図2:CSRF攻撃の例

攻撃用のスクリプトを含んだHTMLの内容については今回は詳しくは触れませんが、外部で作成した投稿をユーザー側に残っているセッションを利用してアップロードさせているという感じです。セッションidはCokkieによって保持されているので、ユーザーの手元から投稿されるような形ならば、スクリプトによって自動で投稿されてもその投稿は正規のセッションidを含みます。

一般的なCSRF対策

上の図より、CSRFの原因は、セッションによる本人確認が不十分であることがあげられます。

このことから、セッション以外で、本人確認を行う何か別の隠しステータスをリアルタイムで用意する必要がある、という発想が出てきます。

これを上の図2に対し実装してみます。

sequenceDiagram ユーザー->>ウェブサーバ(正規):①ログインする/id,passwordを渡す ウェブサーバ(正規)->>ウェブサーバ(正規):②id,passwordを認証する ウェブサーバ(正規)->>ウェブサーバ(正規):③セッションを作成 ウェブサーバ(正規)->>ウェブサーバ(正規):④ランダムな文字列を作成(以後TOKENと呼ぶ) ウェブサーバ(正規)->>ユーザー:⑤セッションid、TOKENを渡す、ログイン後ページを表示 攻撃者-->ユーザー:⑥攻撃用のURLを踏ませる(勝手に踏んでもらう) ウェブサーバ(攻撃用)->>ユーザー:⑦攻撃用のHTMLを返す ユーザー->>ユーザー:⑧攻撃用HTMLに含まれている攻撃用スクリプトによって投稿が作成されている ユーザー->>ウェブサーバ(正規):⑨(攻撃用スクリプトによって)投稿データを渡す ウェブサーバ(正規)->>ウェブサーバ(正規):⑩セッションidは一致するが、TOKENが取得できないのでアプロードできない
図3:CSRF攻撃の例

このTOKENは、セッションidとは別にhidden で送ります。 そのためスクリプトによって自動で投稿されてもTOKENの値は含まれずに、正規のサーバーによってはじくことができます。

Djangoの{% csrf_token %}

本題です。 書いてある通り、CSRF_TOKENというトークンを発行します。

HTMLにどう展開されているのかを見てみます。例えば、テンプレートに

<form action="auth" method="post">
    {% csrf_token %}

と記述すると、HTMLでは、

<input type='hidden' name='csrfmiddlewaretoken' value='値' />

のように展開されます。

このhiddenを指定されているcsrfmiddlewaretokenというフィールドが、先に挙げたセッション以外の、本人確認を行う何か別の隠しステータス、上でいうTOKENにあたります。

もう少し詳しく、django.middleware.csrf.pyのソース を見ていきます。

① _get_new_csrf_token()

django/middleware/csrf.py

70def _get_new_csrf_token():
71 return _mask_cipher_secret(_get_new_csrf_string())

csrfトークンを生成しているっぽい部分です。_mask_cipher_secretなる関数がcsrfトークンの正体っぽいので探してみます。

② _mask_cipher_secret(secret)

django/middleware/csrf.py

57def _mask_cipher_secret(secret):
58 """
59 Given a secret (assumed to be a string of CSRF_ALLOWED_CHARS), generate a
60 token by adding a mask and applying it to the secret.
61 """
62 mask = _get_new_csrf_string()
63 chars = CSRF_ALLOWED_CHARS
64 pairs = zip((chars.index(x) for x in secret), (chars.index(x) for x in mask))
65 cipher = ''.join(chars[(x + y) % len(chars)] for x, y in pairs)
66 return mask + cipher

なんかいろいろ書いてあります。まず、

django/middleware/csrf.py

62 mask = _get_new_csrf_string()

という新たな関数が登場しています。これは後ほど見ていきます。

その下で出てくるCSRF_ALLOWED_CHARSに関しては、

django/middleware/csrf.py

32CSRF_ALLOWED_CHARS = string.ascii_letters + string.digits

と定義されています。 string.ascii_letters は、 文字列定数"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz"ですね。 string.digits は、 文字列定数"0123456789"です。 要するに、CSRF_ALLOWED_CHARSは、アルファベット全てと数字0~9を全部含んだ文字列定数だということです。一体これだけの文字を集めて何をするのでしょう...❓

django/middleware/csrf.py

64 pairs = zip((chars.index(x) for x in secret), (chars.index(x) for x in mask))

これをもう少し細か見てみます。

chars.index(x) for x in secret

の部分では、secretの要素一つ一つの、charsにおけるインデックス(何番目か)を頭から順番に表しています(secretはこの関数、 _mask_cipher_secret(secret)の引数です)、その直後でもsecretmaskになっただけで同じことをやっていますね。

zip()は、pythonで用意されている組み込み関数です、引数で与えられたイテラブルを一つにまとめたイテレータを返します。具体的には、

zip_example.py

x = [1, 2, 3]
y = [4, 5, 6]
zipped = zip(x, y)
list(zipped)
[(1, 4), (2, 5), (3, 6)]

みたいな感じです。

つまり、pairsには、secretmaskの要素一つ一つのcharsにおけるインデックスが頭から順にペアになったイテレータが代入されるということです。

65 cipher = ''.join(chars[(x + y) % len(chars)] for x, y in pairs)

joinは文字列の結合です。

''.join()

というのは、各要素の間を''に、つまりスペースなしで結合するという宣言です。

要するにcipherには、% len(chars)によってchars[]の最大要素数を超えないようにしながらpairsの2要素を足した数をインデックスとしたcharsの値をどんどんつなげていた文字列が代入されるという感じですかね?どんだけバラバラにしたいんでしょうね、cipherというだけのことはありますね。

このcipherと、まだ正体のわかっていないmaskを結合したものが_mask_cipher_secret(secret)の返り値です。

③ _get_new_csrf_string()

次に、_get_new_csrf_token()のときに_mask_cipher_secret()の引数として渡していたり、_mask_cipher_secret()の中身でも登場したりする_get_new_csrf_string()も探してみます。

django/middleware/csrf.py

41def _get_new_csrf_string():
42 return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS)

CSRF_SECRET_LENGTHは、

django/middleware/csrf.py

3030 CSRF_SECRET_LENGTH = 32

と定義されていて、 CSRF_ALLOWED_CHARSは、前述のとおりアルファベット全てと数字0~9を全部含んだ文字列定数です。

get_ramdom_stringは、別のファイルで定義されていました。

django/utils/cripto.py

52 # RemovedInDjango40Warning: when the deprecation ends, replace with:
53 # def get_random_string(length, allowed_chars='...'):
54def get_random_string(length=NOT_PROVIDED, allowed_chars=(
55 'abcdefghijklmnopqrstuvwxyz'
56 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
57)):
58 """
59 Return a securely generated random string.
60 The bit length of the returned value can be calculated with the formula:
61 log_2(len(allowed_chars)^length)
62 For example, with default `allowed_chars` (26+26+10), this gives:
63 * length: 12, bit length =~ 71 bits
64 * length: 22, bit length =~ 131 bits
65 """
66 if length is NOT_PROVIDED:
67 warnings.warn(
68 'Not providing a length argument is deprecated.',
69 RemovedInDjango40Warning,
70 )
71 length = 12
72 return ''.join(secrets.choice(allowed_chars) for i in range(length))

このようになっています。詳しく説明も書かれていてわかりやすいですね。要するに、

django/middleware/csrf.py

41def _get_new_csrf_string():
42 return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS)

では、およそ180ビット長でCSRF_ALLOWED_CHARSの中からランダムでピックアップされた文字の文字列が返されるということです。

④ まとめ

これらを踏まえて順に遡ってみます。

django/middleware/csrf.py

57def _mask_cipher_secret(secret):
58 """
59 Given a secret (assumed to be a string of CSRF_ALLOWED_CHARS), generate a
60 token by adding a mask and applying it to the secret.
61 """
62 mask = _get_new_csrf_string()
63 chars = CSRF_ALLOWED_CHARS
64 pairs = zip((chars.index(x) for x in secret), (chars.index(x) for x in mask))
65 cipher = ''.join(chars[(x + y) % len(chars)] for x, y in pairs)
66 return mask + cipher

これの

62 mask = _get_new_csrf_string()

では、先ほど眺めた"およそ180ビット長でCSRF_ALLOWED_CHARSの中からランダムでピックアップされた文字の文字列が返される"関数を使って文字列を取得しています。

64 pairs = zip((chars.index(x) for x in secret), (chars.index(x) for x in mask))
65 cipher = ''.join(chars[(x + y) % len(chars)] for x, y in pairs)
66 return mask + cipher

maskの正体がわかったところでもう一度見てみます。 (64): secret(引数)の文字列の各要素のchars(アルファベット、数字が順番に入ってる)におけるインデックスと、mask(ランダムにアルファベットが入ってるやつ)の各要素のcharsにおけるインデックスがzipによってまとめられてpairsとされて、 (65): pairsの要素二組を足した整数番目のcharsの要素(charsの大きさで割ったあまりを入れておくことでcharsの要素数を超えないようにしている)を、pairsの頭から最後まで順番に結合して文字列にしたものをcipherとし、 (66): (バラバラの)maskと、(もっとバラバラの)cipherを結合した文字列を返している、 といった具合です。注釈どおりのことをやっていますね。

これでようやく一番最初まで遡れます。

django/middleware/csrf.py

70def _get_new_csrf_token():
71 return _mask_cipher_secret(_get_new_csrf_string())

上の_mask_cipher_secret の引数secretに、_get_new_csrf_string()、つまりランダムに文字をピックアップして文字列として返すやつを指定しているという構造です。maskとは別の場所で呼ぶことでmasksecretが異なる文字列になるようにしているのかな?

以上がCSRF_TOKENの構造です。ただランダムに文字列を生成しているのではなく、暗号らしくだいぶバラバラにしているのがわかったと思います。

ただHTML上でランダムな文字列を生成してそれをTOKENとするだけでは、乱数生成の手法によっては再現可能になってしまう危険性があるところを、Djangoでは{% csrf_token %}と記述するだけでこんな 面倒くさい 堅牢そうな文字列をCSRF対策のトークンとして実装することができる、というわけです。本当に書き得ですね。

終わりに

便利ですねぇ

CSRFについては、僕も全然わかっていない部分があったりして、かなり適当な記述になってしまったので、補完してください。

参考

https://docs.djangoproject.com/en/3.1/ref/csrf/

http://www.htmq.com/html/input_hidden.shtml

https://github.com/django/django/blob/master/django/middleware/csrf.py

https://medium-company.com/%E3%82%AF%E3%83%AD%E3%82%B9%E3%82%B5%E3%82%A4%E3%83%88%E3%83%AA%E3%82%AF%E3%82%A8%E3%82%B9%E3%83%88%E3%83%95%E3%82%A9%E3%83%BC%E3%82%B8%E3%82%A7%E3%83%AA/