Python : FlaskのsessionのKeyValueが、セッションCookieにどのように格納されているのかを調べてみた

# はじめに

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名になるようです。

```python:session-simple.py
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のようです。

:::note warn
上のケースでは、Base64エンコードされた文字列の長さがたまたま4の倍数だったためにうまくデコードできましたが、そうでない場合は文字列全体の長さが4の倍数になるように"="でパディングする必要があります。bashコマンドで"="でパディングしてデコードしたい場合は、[こちら](https://qiita.com/testnin2/items/a0b58370a8590b0cac32)の記事が参考になると思います。
:::

:::note info
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つ目の文字列から時刻を復元できるか確認してみます。

```sh:decode-timestamp.sh
#!/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つ目のデータは署名

最後の３つ目。
[FlaskのSessions](https://flask.palletsprojects.com/en/3.0.x/api/#sessions)に記載された説明を見てみると、「**Flaskは署名つきCookieを使っている**」と記載があります。また、[secret_key](https://flask.palletsprojects.com/en/3.0.x/api/#flask.Flask.secret_key)の部分で、「**暗号化コンポーネントはsecret_keyを使ってcookie等に署名できる**」と書かれています。

ということは、以下の最後の``ayizbt...F0dCSo``の部分
```
eyJ1c2VyX2lkIjoiMSJ9.ZixDMg.ayizbtdJueWh69UFDrwviF0dCSo
```
は、**``session``のKeyValueとタイムスタンプを、``app.config['SECRET_KEY']`` によって署名したデータ**である可能性が高そうです。

さらに調べていくと、署名には [itsdangerous](https://itsdangerous.palletsprojects.com/en/2.2.x/)というモジュールが使われていることがわかりました。実際にペイロードとタイムスタンプを署名してみて、本当にFlaskが発行するセッションCookieの署名と一致するかどうかを確認したいところですが、時間がかかりそうなのと記事が無駄に長くなりそうだという理由で一旦割愛します。

24/05/26 追記
[Flaskのsessionのソース](https://github.com/pallets/flask/blob/main/src/flask/sessions.py#L313)で、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は次のような内容になっています。

```cookie1.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の改ざんを検知してくれました。

:::note info
ちゃんと実装するのであれば、「署名の検証に失敗しました」旨がわかるようにするのが望ましいですが、今回は簡単な動作確認が目的ということで、お許しください。
:::

## ケース2 : Webサーバで、``app.config['SECRET_KEY']``の値を変更してみる

続いては、Webサーバ側でセッションCookieの署名に作られている（と推測される）シークレットキーの値を、以下のように変えてみます。

```python:session-simple.pyから抜粋
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が受け入れられなかったことを確認でき、こちらも予想通りの結果となりました。

# 参考情報

文中には掲載しませんでしたが、参考にさせていただいた情報です。

https://qiita.com/juno_rmks/items/a707228a0682f529298d

https://blowup-bbs.com/python-flask-session-disassemble/

https://qiita.com/aachyee/items/23412da88ff86e45187c

https://qiita.com/daisukeokaoss/items/673e79699cf49c4fd7e5