Vary ヘッダーの役割と Flask における検証
CVE-2026-27205 において Flask の Vary ヘッダーの不備が報告されていた (修正済み) ので, Vary ヘッダーの学習用の覚え書き
Vary Header とは?
- HTTP ヘッダーの一つであり, キャッシュに関連するヘッダー
- Vary で指定された要素について, 一致しないレスポンスは別種のレスポンスとして取り扱うようになる
例 (モバイルサイトの対応)
例として
Vary: User-Agentが設定されていた場合, キャッシュサーバーは User-Agent ヘッダーの情報ごとにキャッシュするかしないかを判断するようになるこれが設定されていない (+他のところでもモバイル・PCの区別がされてない) 場合, モバイルからアクセスがあった場合にそのレスポンスをそのままキャッシュし, その後 PC からアクセスされた場合にキャッシュされたモバイル用のサイトを返してしまうようなことが生じ得る
なお, ミドルウェア次第ではキャッシュのポリシーが独自に設定されている場合もあるらしく, Vary ヘッダーが必須とは限らないらしい
Vary ヘッダーに関する実験
- 以下では Vary ヘッダーの役割を確認するためテスト環境を構築した (コードは github においてある)
- Flask で以下のように User-Agent の内容および Hello ヘッダーの内容を含めたレスポンステキストを返す単純なアプリケーションを作成した
from flask import Flask, make_response, request app = Flask(__name__) @app.get("/") def index(): user_agent = request.headers.get("User-Agent", "unknown") resp_txt = f"Hello from Flask. User-Agent: {user_agent}\n" user_hello = request.headers.get("Hello", "see you!") resp_txt += user_hello + "\n" response = make_response(resp_txt) response.headers["Cache-Control"] = "public, max-age=60" response.headers["Vary"] = "User-Agent" return response
レスポンスには
Vary:User-Agentを含めたミドルウェアでは nginx を用いて, キャッシュがヒットしたかどうかのステータスを返すようにした
curl によるキャッシュの検証
- 以下のように
User-Agent:curlと指定したうえで "Hello!" を表示するアクセスを送信
curl -v -A "curl" -H "Hello:Hello!" http://localhost:3210/
- 結果は以下のようになり, Vary ヘッダーが確認できる (初回のアクセスのため当然キャッシュはヒットしない)
* Host localhost:3210 was resolved. * IPv6: ::1 * IPv4: 127.0.0.1 * Trying [::1]:3210... * Connected to localhost (::1) port 3210 > GET / HTTP/1.1 > Host: localhost:3210 > User-Agent: curl > Accept: */* > Hello:Hello! > < HTTP/1.1 200 OK < Server: nginx/1.30.0 < Date: Thu, 07 May 2026 13:16:26 GMT < Content-Type: text/html; charset=utf-8 < Content-Length: 42 < Connection: keep-alive < Cache-Control: public, max-age=60 < Vary: User-Agent < X-Cache-Status: MISS < Hello from Flask. User-Agent: curl Hello! * Connection #0 to host localhost left intact
- 次に, 表示内容が本来変化する
Hello:Hi!に指定した状態でリクエストを送る
curl -v -A "curl" -H "Hello:Hi!" http://localhost:3210/
- 本来は "Hi!" が表示されてほしいが,
Vary:User-Agentのため User-Agent が変化しないアクセスでは内容が同じであると判断されキャッシュされた "Hello!" が表示される
< X-Cache-Status: HIT < Hello from Flask. User-Agent: curl Hello! * Connection #0 to host localhost left intact
- また,
User-Agentを変えるとキャッシュはされなかった
CVE-2026-27205 について
- CVE-2026-27205 は Flask における Vary ヘッダーの不備に関する脆弱性で,
Cookieについて操作を行った際に本来付与されるはずのVary:Cookieが付与されないようなパターンが報告された Vary:Cookieが付与されないと前項のように予期せぬ形でキャッシュされたレスポンスが渡されてしまう可能性がある
Docker を用いた検証
- Flask で単純なログインフォームを持つアプリケーションを作成した (Docker などのコードは github に置いた)
- ログイン画面では正しいパスワードを打つとセッション変数 (Cookie 保存) に
session["authenticated"]=Trueを格納しアカウントページへリダイレクトするようにした - アカウント画面では, 以下のように
in演算子を用いてセッション Cookie にログイン情報があるかないかを調べるようにしている
if "authenticated" in session: resp_txt = "Hello Pumpkin!\n" else: resp_txt = "Please Login\n" resp_txt += "Session accessed:" + str(session.accessed)
CVE-2026-27205 では
in演算子を用いてセッションを参照した際に,session.accessedが True にならず, レスポンスがセッションに関係ないと判断されてVary:Cookieがレスポンスにつかないということが報告された以下のように python で正しいパスワード ("Labu") で二回アクセスし, 誤ったパスワード ("Pumpkin") で一回アクセスするコードを作成し, 脆弱性の報告された Flask 3.1.2 と修正された Flask 3.1.3 で実行した
import requests BASE_URL = "http://localhost:3210" def login(passwd): # Session keeps cookies received during redirects and reuses them for later requests. client = requests.Session() # requests follows redirects by default, but keep it explicit for this verification. resp = client.post( f"{BASE_URL}/", data={"password": passwd}, allow_redirects=True, timeout=10, ) if "Please Login" in resp.text: print("Login Miss!, headers:", resp.headers) elif "Hello Pumpkin!" in resp.text: print("Login Success!, headers:", resp.headers) # Print only the session access line from the response body. for line in resp.text.splitlines(): if "Session accessed:" in line: print(line) if __name__ == "__main__": login(passwd="Labu") login(passwd="Labu") login(passwd="Pumpkin")
- Flask 3.1.3 では以下のような結果が返ってきた
Login Success!, headers: {'Server': 'nginx/1.30.0', 'Date': 'Sun, 17 May 2026 12:56:10 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': '211', 'Connection': 'keep-alive', 'Vary': 'Cookie', 'X-Cache-Status': 'MISS'} Session accessed:True Login Success!, headers: {'Server': 'nginx/1.30.0', 'Date': 'Sun, 17 May 2026 12:56:10 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': '211', 'Connection': 'keep-alive', 'Vary': 'Cookie', 'X-Cache-Status': 'HIT'} Session accessed:True Login Miss!, headers: {'Server': 'nginx/1.30.0', 'Date': 'Sun, 17 May 2026 12:56:10 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': '209', 'Connection': 'keep-alive', 'Vary': 'Cookie', 'X-Cache-Status': 'MISS'} Session accessed:True
想定された通り, 正しいパスワードでは二回ともログイン成功し, 間違ったパスワードではログインが失敗した
次に Flask 3.1.2 で試すと以下の結果となった
Login Success!, headers: {'Server': 'nginx/1.30.0', 'Date': 'Sun, 17 May 2026 12:53:51 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': '212', 'Connection': 'keep-alive', 'X-Cache-Status': 'EXPIRED'} Session accessed:False Login Success!, headers: {'Server': 'nginx/1.30.0', 'Date': 'Sun, 17 May 2026 12:53:51 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': '212', 'Connection': 'keep-alive', 'X-Cache-Status': 'HIT'} Session accessed:False Login Success!, headers: {'Server': 'nginx/1.30.0', 'Date': 'Sun, 17 May 2026 12:53:51 GMT', 'Content-Type': 'text/html; charset=utf-8', 'Content-Length': '212', 'Connection': 'keep-alive', 'X-Cache-Status': 'HIT'} Session accessed:False
- 結果として, 間違ったパスワードに対してもキャッシュされたレスポンス ("Login Success!") が返された
- また, ヘッダーも確認すると
Varyヘッダーがついていないことが確認できる