FrankenPHP: ワーカモードにおけるPHPセッションIDの漏洩(共有モジュールを使用した場合)

## 概要
並行処理環境下でのワーカモードにおいて、FrankenPHPは、PHPの`session`拡張がFrankenPHPのバイナリに静的にリンクされているのではなく、共有モジュールとしてロードされている場合に、あるリクエストのPHPセッションIDを別のリクエストに引き継ぐ可能性があります。 `Cookie: session=A`というリクエストにおいて、`session_id()`関数が、別の同時実行クライアントのIDである`B`を返すか、または同じワーカスレッドによって処理された以前のリクエストの古いIDを返す可能性があります。

## 影響
- **セキュリティ:** ワーカモードにおける、ユーザー間のセッション情報の漏洩/セッションハイジャック。CVE-2026-24894と同様の影響。
- **機能:** ユーザーが断続的にログアウトする。ユーザーのCookieに含まれるセッション情報が無視され、別のユーザーのセッション情報を受け取ったり、セッション情報が空になったりする。

## 影響を受ける/影響を受けない
セッション情報の漏洩は、FrankenPHPがコンパイルされるPHPのビルドにおいて、`session`拡張がどのようにリンクされているかによって決まり、FrankenPHPのリリースバージョンによるものではありません。

- **影響を受ける:** `session`拡張がコンパイル時にFrankenPHPから認識されない場合、つまり共有モジュール/アウトオブツリーモジュールとしてロードされている場合(iniファイルで`extension=session`と設定されている場合)。これには、RPMパッケージ、Debianパッケージ、および`session`を`conf.d`ディレクトリ下の共有拡張として提供するその他の`php-zts`ディストリビューションが含まれます。
- **影響を受けない:** `session`が、FrankenPHPがリンクするPHPに静的にコンパイルされている場合。例えば、公式の静的バイナリやDockerイメージなど。これらの環境では、以下のリセットコードがコンパイルされており、各リクエストごとにIDがクリアされます。

C言語で記述されたセッションのリセットコードは、1.11.xから1.12.xのバージョン間で変更されていません。したがって、これはリリース間のコードのバグではありません。同じホストで「1.12.2では問題ないが、1.12.3では漏洩が発生する」という現象が見られる場合、それは異なるバージョンのパッケージがインストールされていることを意味し、ソースコードの変更によるものではありません。永続的なzvalヘルパー関数(#2366)は、`FRANKENPHP_TEST`オプションが有効な場合にのみコンパイルされ、今回の問題とは無関係です。

## 根本原因
FrankenPHPは、`frankenphp_reset_session_state()`関数で、リクエストごとのセッション状態をリセットします。これにより、`PS(id)`が解放され、次の`session_start()`関数がリクエストのCookieを再度読み込みます。この関数全体は`#ifdef HAVE_PHP_SESSION`という条件付きコンパイルで囲まれており、このマクロは、FrankenPHPのコンパイル時にセッション拡張のヘッダーが存在する場合にのみ定義されます(つまり、PHPのビルドにセッションが静的に組み込まれている場合)。`session`が共有モジュールである場合、`HAVE_PHP_SESSION`は未定義となり、リセット処理はコンパイルから除外され、`PS(id)`はリクエストの境界を越えて保持されます。

PHPの`session_start()`関数は、`PS(id)`が`NULL`の場合にのみ、リクエストのCookieを読み込みます(`ext/session/session.c`の`try_find_session_id_in_cookies()`関数内: `if (!PS(use_cookies) || PS(id)) return;`)。古い`PS(id)`が存在する場合、Cookieは無視され、ワーカープロセスは前のリクエストのIDを再利用するため、次のクライアントにそのIDが公開される可能性があります。

## 再現方法
最小限のワーカースクリプト (`index.php`):
```php
<?php
$handler = static function () {
session_start();
$sent = $_COOKIE['session'] ?? '(none)';
$id = session_id();
header('Content-Type: text/plain');
echo ($sent === '(none)' || $sent === $id) ? "OK sent=$sent id=$id\n"
: "LEAK sent=$sent id=$id\n";
session_write_close();
};
while (\frankenphp_handle_request($handler)) {}
```
ワーカーモードで、`num 2`(2つ以上のワーカー)、`session.use_strict_mode=1`を設定し、`session`が共有モジュールとして組み込まれたPHPのビルドで実行します。事前に有効なセッション `A` と `B` を作成し、Cookieを交互に切り替えて同時リクエストを送信します。
```sh
seq 600 | xargs -P32 -I{} sh -c '
c=$([ $(( {} % 2 )) -eq 0 ] && echo A || echo B);
curl -s -H "Cookie: session=$c" http://127.0.0.1:8080/' | grep -c LEAK
```
影響を受けるビルドでは、応答の大部分が `LEAK` となります(`session_id()` が送信されたCookieと異なり、`A` と `B` が時々同じIDに解決される場合)。静的にリンクされたビルドでは、カウントは常に `0` になります。

## 修正点
`HAVE_PHP_SESSION` が未定義の場合、FrankenPHP は、各ワーカーリクエストにおいて、モジュールレジストリを通じて `session` モジュールをリセットします。このリセット処理は、`RSHUTDOWN` で `PS(id)` を解放し、`RINIT` でグローバル変数を再初期化することで行われ、拡張がどのようにリンクされているかにかかわらず、リクエストごとのクリーンアップが常に実行されます。静的にリンクされたビルドは変更されておらず、`frankenphp_reset_session_state()` を引き続き使用します。

PHP 8.6-dev ZTS、2つのワーカー、`session.use_strict_mode=1`、600件の交互に送信されるクッキーを使用した同時リクエストで検証した結果、修正前は 600件中 519件がメモリリークを起こしていましたが、修正後は 600件中 0件になりました。静的にリンクされたビルドは、修正後も 600件中 0件のままです。

## 対策
- `session` モジュールが静的にコンパイルされた FrankenPHP のビルドを使用する(公式の静的バイナリまたは Docker イメージ)。
- または、ワーカーループ内で、`session_write_close()` を呼び出した後、`session_id('')` を呼び出すことで、次の `session_start()` がリクエストのクッキーを再読み込みするようにする(`session.use_strict_mode=1` の場合)。

影響を受けるパッケージ:
- go github.com/dunglas/frankenphp: 影響を受けるバージョンは 1.12.4 以下、1.12.5 で修正済み。