はじめに
PythonのFlaskでWebサーバを作る際に、Webブラウザ等のWebクライアントでセッション維持させるにはFlaskのsessionモジュールが使えます。Pythonのソース内でsessionモジュールにKeyValueを渡してあげると、各クライアントのWebブラウザ上でそれらのKeyValueがセッションCookieによって維持されます。
しかしながらFlaskのsessionモジュールを使ってWebサーバを単に実装しただけでは、sessionモジュールを介して作成したKeyValueが本当にセッションCookieに格納されているのかが筆者はよくわかりませんでした。ググってWeb情報を調査しても、多くはWebサーバ側の挙動ばかりが書かれており、疑問を解決してくれるものがすぐに見つからず...
そこで実際にWebクライアント側でセッションCookieを解読して、Flaskのsessionモジュールで作成したKeyValueが含まれていることを確認してみました。本記事ではその内容を共有します。
動作確認した各ツールのバージョン
$ python --version
Python 3.9.7
$ pip show Flask | grep ^Version
Version: 3.0.3
TL;DR
- セッションCookieは 「ペイロード.タイムスタンプ.署名」 の順番でドッド(.)で区切られて構成される
- ペイロード : Webサーバで代入したKeyValueがBase64エンコードされている
- タイムスタンプ : セッションCookieを発行した時間のタイムスタンプのバイナリデータがBase64エンコードされている
- 署名 : 「ペイロード」と「タイムスタンプ」が署名されている(はず)
- ペイロードやタイムスタンプをクライアント側で改ざんしたり、サーバ側の秘密鍵を変更したりすると、サーバはクライアントが投げたセッションCookieの検証に失敗する
Webサーバの実装&起動
まずはセッションCookieを生成するWebサーバを作ってみます。
以下のソースは、Webクライアントがuser_id
のキーを格納したセッションCookieを持っていたら何もせず、持っていなければキーがuser_id
で値が1
のセッションCookieを発行するものです。
尚、セッションCookieの名前はapp.config['SESSION_COOKIE_NAME']
で "mycookie" になるように明示的に設定しています。何も指定しないと "session" というCookie名になるようです。
from flask import Flask, session
app = Flask(__name__)
app.config['SECRET_KEY'] = 'secret_key'
app.config['SESSION_COOKIE_NAME'] = 'mycookie'
@app.route('/')
def root():
response_text = None
if 'user_id' in session:
response_text = 'Session cookie accepted.\n'
else:
session['user_id'] = '1'
response_text = 'No user_id key in session.\nSession cookie created.\n'
return response_text
if __name__ == '__main__':
app.run(debug=True)
ソースの準備が出来たら、以下のようにWebサーバを起動してみます。
$ python session-simple.py
* Serving Flask app 'session-simple'
* Debug mode: on
WARNING: This is a development server. Do not use it in a production deployment. Use a production WSGI server instead.
* Running on http://127.0.0.1:5000
Press CTRL+C to quit
* Restarting with stat
* Debugger is active!
* Debugger PIN: 743-413-040
Webサーバが端末上でフォアグラウンドで起動するので、CTRL+C等で終了せずにこのままの状態にしておきましょう。
Webクライアントから諸々確認
セッションCookieの発行確認
WebブラウザでセッションCookieを確認するのは面倒なので、別の端末でcurlを使って次のように確認します。
$ curl -i http://127.0.0.1:5000/
HTTP/1.1 200 OK
Server: Werkzeug/3.0.2 Python/3.9.7
Date: Sat, 27 Apr 2024 00:13:38 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 24
Vary: Cookie
Set-Cookie: mycookie=eyJ1c2VyX2lkIjoiMSJ9.ZixDMg.ayizbtdJueWh69UFDrwviF0dCSo; HttpOnly; Path=/
Connection: close
No user_id key in session.
Session cookie created.
確かに"Set-Cookie"の行で、"mycookie"というCookie名で何らかの文字列が返されているのがわかります。
セッションCookieの中身を確認
セッションCookieの値は以下のように、2つのドット(.)で区切られた文字列になっています。
eyJ1c2VyX2lkIjoiMSJ9.ZixDMg.ayizbtdJueWh69UFDrwviF0dCSo
一つ目はKeyValueがペイロードとして格納
ヤマ勘で、先頭からドットで区切られるまでの文字列をBase64デコードしてみると、以下のようにFlaskのWebサーバでsession['user_id'] = '1'
と設定したKeyValueを見事に(いやまぐれで)抽出できました!
$ echo 'eyJ1c2VyX2lkIjoiMSJ9' | base64 -d
{"user_id":"1"}
どうやら、セッションCookieの先頭文字列がWebサーバで設定したKeyValueのようです。
上のケースでは、Base64エンコードされた文字列の長さがたまたま4の倍数だったためにうまくデコードできましたが、そうでない場合は文字列全体の長さが4の倍数になるように"="でパディングする必要があります。bashコマンドで"="でパディングしてデコードしたい場合は、こちらの記事が参考になると思います。
OpenID Connect(OIDC)を知っている方だと「これってJWT(JSON Web Token)っぽくない?」 と思われるかもしれません。そうだとすると、最初の文字列はHeaderに相当し、署名アルゴリズム等が書かれているはずです。が、実際はセッションのKeyValueが入っていたためJWTとは似て非なるものということがわかりました...
残り2つのデータは...?
そうすると、残りの2つのドットで区切られた文字列は何を意味しているのでしょうか?さっきと同じヤマ勘でBase64デコードしてみても、よくわからない文字列しか出力されません。
$ echo 'ZixDMg====' | fold -w 4 | sed '$ d' | tr -d '\n' | base64 -d
f,C2
$ echo 'ayizbtdJueWh69UFDrwviF0dCSo====' | fold -w 4 | sed '$ d' | tr -d '\n' | base64 -d
k(nI/] *
そこで同じリクエストを3回投げて、発行されるCookieがどう変わるかを確認してみましょう。
$ for i in {1..3}; do
> curl -I http://127.0.0.1:5000/ 2> /dev/null | grep '^Set-Cookie' | awk '{print $2}'; sleep 1
> done
mycookie=eyJ1c2VyX2lkIjoiMSJ9.ZiyCOw.JZSUlB1r5MY1Ee29GwInH3g0aXw;
mycookie=eyJ1c2VyX2lkIjoiMSJ9.ZiyCPA.2cmve-QLgeYZK8vGUK1lc9gnEwM;
mycookie=eyJ1c2VyX2lkIjoiMSJ9.ZiyCPQ.DucVulDdjx-8AbWITV9CuenZAzw;
後ろの2つのドットで区切られた文字列は毎回変わっています。なるほど、結局よくわからん...。
2つ目のデータはタイムスタンプ
もう一度Web情報をしっかり調べたら、「2つ目にはセッションCookieを発行した時のタイムスタンプ」が格納されていることがわかりました。さっきBase64エンコードされてよくわからない文字列が出てきたのは、バイナリデータがそのままテキスト出力されてしまったからだったようです。
そこで以下のシェルスクリプトを作って、2つ目の文字列から時刻を復元できるか確認してみます。
#!/bin/bash
# Base64デコード後に16進数のテキストに変換
timestamp16=$(echo "$1"==== | fold -w 4 | sed '$ d' | tr -d '\n' | base64 -d | xxd -p)
echo "hexadecimal timestamp : $timestamp16"
# 16進数を10進数に変換
timestamp10=$((16#$timestamp16))
echo "decimal timestamp : $timestamp10"
# タイムスタンプを時刻に変換
datetime=$(date -d @"$timestamp10")
echo "datetime : $datetime"
先ほどBase64デコードに失敗した文字列を上のシェルスクリプトに入力すると、めでたく時刻を抽出できました!
$ ./decode-timestamp.sh 'ZiyCOw'
hexadecimal timestamp : 662c823b
decimal timestamp : 1714192955
datetime : 2024年 4月 27日 土曜日 13:42:35 JST
3つ目のデータは署名
最後の3つ目。
FlaskのSessionsに記載された説明を見てみると、「Flaskは署名つきCookieを使っている」と記載があります。また、secret_keyの部分で、「暗号化コンポーネントはsecret_keyを使ってcookie等に署名できる」と書かれています。
ということは、以下の最後のayizbt...F0dCSo
の部分
eyJ1c2VyX2lkIjoiMSJ9.ZixDMg.ayizbtdJueWh69UFDrwviF0dCSo
は、session
のKeyValueとタイムスタンプを、app.config['SECRET_KEY']
によって署名したデータである可能性が高そうです。
さらに調べていくと、署名には itsdangerousというモジュールが使われていることがわかりました。実際にペイロードとタイムスタンプを署名してみて、本当にFlaskが発行するセッションCookieの署名と一致するかどうかを確認したいところですが、時間がかかりそうなのと記事が無駄に長くなりそうだという理由で一旦割愛します。
24/05/26 追記
Flaskのsessionのソースで、itsdangerousのURLSafeTimedSerializerでセッションCookieの値を作っている箇所を見つけました。
実験
セッションCookieに署名が含まれている(はずだという)ことが、調査によりわかりました。そうだとすると、以下のようなケースでクライアントからWebサーバ投げたセッションCookieは検証に失敗すると推測されます。
- ケース1 : クライアントで
session
のKeyValueを改ざんした場合 - ケース2 : Webサーバで、
app.config['SECRET_KEY']
の値を変更した場合
実際に失敗するかどうかを確かめてみましょう。
ケース1 : クライアントでsession
のKeyValueを改ざんしてみる
まずは今までと同じようにクライアントからWebサーバにリクエストして、セッションCookieをファイル保存します。
$ curl -c cookie1.txt http://127.0.0.1:5000/
No user_id key in session.
Session cookie created.
cookie.txtは次のような内容になっています。
# Netscape HTTP Cookie File
# https://curl.se/docs/http-cookies.html
# This file was generated by libcurl! Edit at your own risk.
#HttpOnly_127.0.0.1 FALSE / FALSE 0 mycookie eyJ1c2VyX2lkIjoiMSJ9.ZiyR0w.ceHY554g7z4YftOLmblQCx-DfiI
とりあえずは、このcookieを付与してリクエストすると、以下のようにWebサーバは正しいセッションCookieが来たと認識してくれます。
$ curl -b cookie1.txt http://127.0.0.1:5000/
Session cookie accepted.
続いて、cookie1.txt
をコピーして新たにcookie2.txt
というファイルを作り、{"user_id":"1"}
をbase64エンコードした部分を、次のような値に変えてみます。
$ echo -n '{"user_id":"2"}' | base64
eyJ1c2VyX2lkIjoiMiJ9
改ざん後のファイルcookie2.txt
は、以下のように微妙に文字列が変わっています。
$ cat cookie2.txt | grep Http
#HttpOnly_127.0.0.1 FALSE / FALSE 0 mycookie eyJ1c2VyX2lkIjoiMiJ9.ZiyR0w.ceHY554g7z4YftOLmblQCx-DfiI
この状態で、以下のようにWebサーバにリクエストしてみます。
$ curl -b cookie2.txt http://127.0.0.1:5000/
No user_id key in session.
Session cookie created.
すると、{"user_id":"2"}
のセッションCookieは受け入れられずに、Webサーバ側で新しくセッションCookieが作り直されたのがわかります。予想通り、セッションCookieの改ざんを検知してくれました。
ちゃんと実装するのであれば、「署名の検証に失敗しました」旨がわかるようにするのが望ましいですが、今回は簡単な動作確認が目的ということで、お許しください。
ケース2 : Webサーバで、app.config['SECRET_KEY']
の値を変更してみる
続いては、Webサーバ側でセッションCookieの署名に作られている(と推測される)シークレットキーの値を、以下のように変えてみます。
app.config['SECRET_KEY'] = 'secret_key2'
クライアント側は、改ざんしていないcookie1.txt
でリクエストします。
$ curl -b cookie1.txt http://127.0.0.1:5000/
No user_id key in session.
Session cookie created.
セッションCookieが受け入れられなかったことを確認でき、こちらも予想通りの結果となりました。
参考情報
文中には掲載しませんでしたが、参考にさせていただいた情報です。