ThreadLocal: 正しい言い回し、時代遅れ
Java EE Servlet コンテナ、1999年ごろ:リクエストごとに1つのThread。Thread はリクエストの開始から終了まで1つのリクエストを処理し、終了します。ThreadLocal は現在のThread にキーとして値を保持します。1-thread-per-request の場合、ThreadLocal に格納された値は正確に1つのリクエストに属します。言い回し: 正しいです。
Thread プールは契約を変えました。Thread がリクエストAを処理し、ThreadLocalにprincipal Aを格納し、リクエストAを終了し、プールに戻ります。Thread プールはThreadの状態をリセットしません。ThreadLocal.remove()でクリーンアップを行う必要がありますが、それを呼び出すには明示的な戒めが必要です。戒めが失敗した場合、リクエストBが同じThread上で実行され、ThreadLocalからprincipal Aを読み取ることができます。
5ステップの漏洩:
1. リクエストAが到着します。サーバーはThread-7を割り当てます。
2. Thread-7はリクエスト開始時にThreadLocal.set(principal_A)を設定します。
3. リクエストAが完了し、Thread-7はプールに戻ります。ThreadLocal.remove()が呼び出されません。
4. リクエストBが到着します。サーバーはThread-7(プールの再利用)を割り当てます。
5. Thread-7はThreadLocal.get()を読み取って、principal_Aを返します。リクエストBは正しいアイデンティティで実行されます。
なぜテストが見逃すのか
単体テストは分離で実行されます:Threadプールなし、再利用なし。統合テストは新しいThreadを使用し、またはテスト間で状態をリセットします。負荷テストは正しいユーザーで低並列性でクリーンアップを行います。欠陥は、Threadプールの再利用と重なるリクエストの条件下でしか現れないため、生産環境で通常のトラフィックの下で表示されることがありますが、それをチェックする任意のテスト構成では表示されません。
セキュリティの結果
ユーザーAのプリンシパルがユーザーBのリクエストに流出します。クラッシュではありません。例外ではありません。静かなセキュリティの境界違反:ユーザーBがユーザーAのアクションを実行し、ユーザーAのデータを読み取ったり、ユーザーBの権限をバイパスしたりします。システムはエラーを生成しません。ログはリクエストBが承認されたことを示しています。すべてが正しく見えます。
5ステップ
ThreadLocal漏洩の5ステップは、欠陥が発生する瞬間が特定されます。欠陥は、クリーンアップステップが欠如している瞬間で発生します。
スコープにアタッチされた値
ThreadLocalはスレッドに値をアタッチします。スレッドはリクエストを超えます。矛盾です。
スコープにアタッチされた値は、単位の仕事に関連付ける値をアタッチします。単位の仕事が終了すると、値も終了します。明示的なクリーンアップは不要です。remove()で忘れることもありません。
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // set at request start
// ... request handling ...
PRINCIPAL.remove(); // MUST be called; often forgotten
// ScopedValue (CORRECT carrier)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... request handling ...
// value automatically gone when run() returns
});
Go: context.Context
// context.Context carries values explicitly; scope = function call chain
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx passed explicitly; gone when function returns
Python asyncio: contextvars.ContextVar
# ContextVar scoped to each async task
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # set for this task only
# ... タスクの処理 ...
PRINCIPAL.reset(token) # または: タスクの終了
これらが共有する特性: ライフタイムはワーク単位と一致する。リクエストが終了する時(run()が戻り、関数が戻り、タスクが完了する時)、値も終了する。クリーンアップを忘れることがない。プールを汚すこともない。
特定 & 置き換え
Java EE アプリケーションは、リクエストの開始時にテナントIDをThreadLocalに格納します。負荷が高くなると、テナントAのIDがテナントBのリクエストに表示されます。テナントBのクエリは、テナントAのデータを返します。例外は投げられません。欠陥は、ただ生産環境での負荷テスト時にのみ表示されます。