※2020年時点の内容です!
チーム開発でDjangoを使うことになって、自分は使うのが初めてなので急いで勉強中です。
Djangoでは、html上に{% csrf_token %}
と記述すると、CSRF対策になるとされています。この記述がどんなふうにCSRF対策になっているかをメモしていきます。
CSRFのフロー
まずはCSRFのフローを見ていきます。 ここでは、例としてTwitterのような投稿型のウェブサイトを想定して(Twitterとかはだいぶ違うけど、イメージとして)、CSRFによる乗っ取り攻撃を例にしてみます。
①正常なフロー
まずはサービスの正常な機能例です。ユーザーがサービスにログインして投稿を行うフローを示します。
④ではセッションidはCookieによって渡されます。⑥ではCookieによって渡されたセッションidとサーバー側のセッションidを比較します。これによって、サーバーからセッションidを受け取っていない外部からの投稿を受け付けません。
②攻撃例のフロー
CSRFでの攻撃例は以下の通りです。
攻撃用のスクリプトを含んだHTMLの内容については今回は詳しくは触れませんが、外部で作成した投稿をユーザー側に残っているセッションを利用してアップロードさせているという感じです。セッションidはCokkieによって保持されているので、ユーザーの手元から投稿されるような形ならば、スクリプトによって自動で投稿されてもその投稿は正規のセッションidを含みます。
一般的なCSRF対策
上の図より、CSRFの原因は、セッションによる本人確認が不十分であることがあげられます。
このことから、セッション以外で、本人確認を行う何か別の隠しステータスをリアルタイムで用意する必要がある、という発想が出てきます。
これを上の図2に対し実装してみます。
この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
70 def _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
57 def _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
32 CSRF_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
)の引数です)、その直後でもsecret
がmask
になっただけで同じことをやっていますね。
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
には、secret
とmask
の要素一つ一つの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
41 def _get_new_csrf_string():
42 return get_random_string(CSRF_SECRET_LENGTH, allowed_chars=CSRF_ALLOWED_CHARS)
CSRF_SECRET_LENGTH
は、
django/middleware/csrf.py
30 30 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='...'):
54 def 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
41 def _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
57 def _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
70 def _get_new_csrf_token():
71 return _mask_cipher_secret(_get_new_csrf_string())
上の_mask_cipher_secret
の引数secret
に、_get_new_csrf_string()
、つまりランダムに文字をピックアップして文字列として返すやつを指定しているという構造です。mask
とは別の場所で呼ぶことでmask
とsecret
が異なる文字列になるようにしているのかな?
以上が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