GraphQL Tools: リクエストごとの接続パラメータが、後続のユーザーのWebSocket認証に引き継がれる

# `@graphql-tools/executor-legacy-ws`における認証状態の陳腐化: リクエストごとの`extensions.connectionParams`が、実行コンテキストの状態に保持され、後続の呼び出し元の`connection_init`で再利用される

## レポートのメタデータ

| 項目 | 値 |
| --- | --- |
| パッケージ | `@graphql-tools/executor-legacy-ws` (npm, `pkg:npm/%40graphql-tools/executor-legacy-ws`) |
| リポジトリ | https://github.com/ardatan/graphql-tools |
| コンポーネント | `buildWSLegacyExecutor` (`packages/executors/legacy-ws/src/index.ts`) |
| 脆弱性の種類 | 認証状態の陳腐化 / リクエスト間の認証情報の再利用 |
| 検証対象 | コミット `b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1`; npmリリース `1.1.33`がインストールされ、動作確認済み。検証中、最新リリース `1.1.35`でも同様の動作が確認され、`master`ブランチでも保持されたコンテキストが残っている。 |
| APIの範囲 | 公開されているドキュメント化されたAPI (`buildWSLegacyExecutor`、および返される実行コンテキストにおける`request.extensions.connectionParams`) |
| デフォルト設定で影響を受けるか | はい — ファクトリは、特定のユーザーや単一使用に制限がなく、汎用的で再利用可能な`Executor`を返し、サポートされている終了処理も保持されたパラメータをクリアしない。 |

バージョンに関する注意: 最初に記録された影響範囲は`<= 1.1.33`でした。検証の結果、この範囲はリスクを過小評価しており、同様の概念実証が`1.1.35`でも同じように動作します。

## 概要

`buildWSLegacyExecutor` は、エグゼキュータのライフサイクル全体で、スコープ内の単一のオブジェクト `executorConnectionParams` を保持します。 `request.extensions.connectionParams` を持つすべての実行において、そのキーが `Object.assign` を使用してこのオブジェクトにマージされ、新しい WebSocket が開かれるたびに、そのオブジェクトが `connection_init` ペイロードにマージされます。 一度追加されたキーは、決して削除されません。 `cleanupWebsocket` は、ソケットを終了し、ハンドルを null にしますが、`executorConnectionParams` はそのまま残ります。 また、特定のキーを省略した後続のリクエストは、条件付きの更新処理を完全にスキップします。

クライアント側の構成は、ユーザーごとに異なる認証情報を転送する共有エグゼキュータです。 1つの長期的なエグゼキュータが複数のユーザー(スキーマの結合、ゲートウェイ、またはメッシュなど、各 `ExecutionRequest` が独自の `extensions.connectionParams` を提供するパターン)を処理する場合、認証キーを省略したリクエストは、以前の呼び出し元の `Authorization` /テナント情報を含む `connection_init` とともに、下流サーバーに送信されます。 その結果、下流サーバーは、後続の呼び出し元を、以前のユーザーとして認証します。

同じファイル内の周辺状態の動作:

- WebSocket ハンドル (`websocket`) → `cleanupWebsocket` 内で、接続の終了時にリセットされます。[78-88行](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L78-L88)
- リクエストごとの `connectionParams` のマージ(存在する場合)→ ドキュメントに記載されているように適用されます。[93-101行](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L93-L101)
- `executorConnectionParams` スコープオブジェクト → **リセットされることはなく、ソケットの接続終了後も保持され、次の接続時に再送信されます。[32行](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L32) および [54行](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L54)**

## 影響

認証情報を提供しない(または、より低い権限の)クライアントからのリクエストの場合、そのダウンストリームのGraphQLサブスクリプションが、以前のユーザーが保持しているトークンやテナントの値を使用して実行される可能性があり、そのクライアント自身の`connection_init`では許可されないデータを受け取る可能性があります。認証情報自体は攻撃者に直接は公開されませんが(攻撃者がそれを知っているわけではなく、代わりにクローズジャのストレージを介して到達します)、それが表すダウンストリームの認証情報は公開されます。

この問題が修正されていない場合、匿名リクエストは、`connection_init`に基本オプションとリクエスト自身のパラメータのみを含む接続を開き、ダウンストリームサーバーはそれを匿名リクエストとして扱います。しかし、この問題が存在すると、匿名リクエストは被害者の認証情報を引き継いでしまいます。検証の結果、部分的な上書きマージも確認されました。例えば、攻撃者が`{tenant: "<攻撃者のテナント>"`のみを提供するリクエストは、被害者の`Authorization`と攻撃者のテナントの両方を保持し、つまり、攻撃者が選択したテナントが、他のユーザーのベアラートークンを使用して認証される状態になります。

評価に関する注意:**評価対象外。** この問題にはCVSSベクトルが付属しておらず、悪用可能性はライブラリ自体ではなく、利用するアプリケーションのデータフローに依存します。各リクエストごとに新しい実行コンテキストを構築するアプリケーションは影響を受けません。リポジトリ内の`UrlLoader`ラッパー([https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/loaders/url/src/index.ts#L172-L182](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/loaders/url/src/index.ts#L172-L182))は、各リクエストに対して新しい実行コンテキストを構築するため、この問題は発生しません。問題が発生するのは、エクスポートされたファクトリを直接使用し、実行コンテキストを共有するアプリケーションです。

## 前提条件

1. `buildWSLegacyExecutor`によって作成された1つの実行体インスタンスが、異なるユーザーまたは認証コンテキストに属する複数のリクエスト間で再利用されます。
2. アプリケーションは、明示的にサポートされている`extensions.connectionParams`フィールドを使用して、リクエストごとのダウンストリームのID情報を伝送します。このフィールドに関するドキュメントは、[91行](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L91)にあるソースコードのコメントのみに記載されており、そのコメントでは、`extensions`を通じて追加の接続パラメータを提供できると述べられていますが、後続のリクエストに値が保持されるという警告はありません。
3. 最初の操作のソケットが閉じられる前に、後続の操作が新しいソケットを開きます。これは通常の状態であり、例外ではありません。`COMPLETE`ハンドラは、すべての操作後に`cleanupWebsocket()`を呼び出します。
4. 攻撃者が持つべき能力:共有アプリケーションを介してリクエストを送信する能力。ゲートウェイの場合、リモートユーザーは認証の有無や、どのサブスクリプションを要求するかを制御でき、匿名リクエストでは、`connectionParams`キーが通常は存在しません。
5. 後続のリクエストでは、少なくとも1つの機密キーが省略されており、すべての以前のキーを上書きするわけではありません(`Object.assign`はマージしますが、置き換えません)。

これは、非標準の使用、monkey-patching、または異常な構成を必要としません。問題のある箇所は、パッケージの公開APIとしてドキュメント化されている`extensions.connectionParams`機能であり、ライブラリは、リクエストごとの認証情報の分離を、呼び出し元の責任としてドキュメント化していません。検証の結果、ライブラリ自身のドキュメントに記載されているクリーンアップ処理である`executor[Symbol.dispose]()`(`cleanupWebsocket`にバインド)も、状態をクリアしないことが確認されました。サポートされているAPIによっても、リセットされることはありません。

## データフロー

1. **情報源:** `ExecutionRequest` から、`extensions.connectionParams` が提供されます。これは通常、`Authorization` またはテナントトークンです ([`packages/executors/legacy-ws/src/index.ts:90-100`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L90-L100))。
2. **伝播:** `Object.assign` が、それらのキーをクロージャスコープ内の `executorConnectionParams` に格納します ([`index.ts:32`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L32), [`index.ts:97-100`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L97-L100))。
3. **伝播:** `cleanupWebsocket` はソケットを終了しますが、`executorConnectionParams` をリセットしません ([`index.ts:78-88`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L78-L88))。攻撃者の後続リクエストで `connectionParams` が省略されるため、条件付きの更新がスキップされ、古いキーが残ります ([`index.ts:93-101`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L93-L101))。
4. **影響点:** 新しいソケットの `onopen` イベントでは、保持された状態が `connection_init` ペイロードにマージされ、攻撃者の操作の前に送信されます: `payload = Object.assign(payload, executorConnectionParams); websocket!.send(...connection_init payload...)` ([`index.ts:44-65`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L44-L65), [`index.ts:120-129`](https://github.com/ardatan/graphql-tools/blob/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L120-L129))。
5. **到達可能性:** このパッケージは、`buildWSLegacyExecutor` を直接エクスポートします ([`index.ts:27`](https://github.com/ardatan/graphql-tools/blo

b/b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1/packages/executors/legacy-ws/src/index.ts#L27) にある箇所で、戻り値として得られる使い捨ての実行器と `request.extensions.connectionParams` がエントリーポイントとなります。

## 脆弱なコード

`packages/executors/legacy-ws/src/index.ts` の 32 行から 100 行 (コミットハッシュ `b5d1c36ebb8ed3868260ffee5bbcbb423ad5c1f1`):

```ts
let executorConnectionParams = {}; // クロージャの状態。実行器の有効期間中、この変数は保持されます。
...
payload = Object.assign(payload, executorConnectionParams); // 宛先:保持されたキーが `connection_init` に渡されます。
websocket!.send(JSON.stringify({ type: LEGACY_WS.CONNECTION_INIT, payload }));
...
if (request.extensions?.['connectionParams'] && typeof request.extensions?.['connectionParams'] === 'object') {
executorConnectionParams = Object.assign(executorConnectionParams, request.extensions['connectionParams']);
}
// else ブランチはありません。`connectionParams` が省略されたリクエストの場合、前のリクエストのキーがそのまま残り、
// `cleanupWebsocket` (32 行から 100 行) は `websocket` のみをリセットしますが、`executorConnectionParams` はリセットしません。
```

## 再現方法

**実際のパッケージに対して動的に検証**しました。静的なレビューだけでは確認できません。バージョン `1.1.33` を npm からインストールし、ローカルの `ws` サーバーでレガシーの `graphql-ws` プロトコルを実行し、すべての物理的な接続の `connection_init` ペイロードを記録しました。

検証の実行中に、1つの共有された実行器インスタンスを使用しました。

- リクエスト1(被害側)から、`extensions.connectionParams` が `{Authorization: "Bearer VICTIM-ALICE-SECRET-TOKEN", tenant: "alice-tenant"}` として送信されました。その後、サブスクリプションが完了し、実行エンジンから `connection_terminate` が送信され、ソケットが閉じられました。
- リクエスト2では、`connectionParams` が**一切**指定されていませんでしたが、サーバーは被害側の `connection_init` ペイロードと完全に一致する、新しい接続を検知しました。この一致には、被害側のベアラー トークンも含まれていました。
- 一部の情報の上書き: 後続の呼び出し元が `{tenant: "mallory-tenant"}` のみを指定した場合、`Object.assign` は上書きするのではなく、マージを行うため、被害側の `Authorization` が攻撃側のテナント情報とともに保持されました。
- リクエストの間に `executor[Symbol.dispose]()` を呼び出しても、状態がクリアされませんでした。
- 負のテスト: インストールされている `1.1.33` のバージョンを修正し、`cleanupWebsocket` の中に `executorConnectionParams = {}` を追加したところ、2番目の接続の `connection_init` ペイロードが `{}` になりました。これにより、情報漏洩の原因が、リセットの欠如であることが明確になりました。
- 同じ概念に基づくテストは、`1.1.35` でも同様の結果を示し、`master` ブランチにも、リセットされていない状態が残っています。`master` ブランチにおける該当する行番号は、上記で引用した行番号とわずかに異なります(クロージャの宣言:L38、条件付きの `Object.assign`:L100-105、`cleanupWebsocket`:L84、`onopen` のマージ:L60-63)。

手順:

1. `connection_init` ペイロードを記録し、`Authorization` の値に基づいて通知を許可する、従来の GraphQL WS サーバーを実行します。
2. 1つの実行エンジンを使用して、被害側のリクエストを送信します。`extensions.connectionParams.Authorization = 'Bearer victim-token'` を指定します。サブスクリプションを終了/解除し、クリーンアップによってソケットが閉じられるようにします。
3. 同じ実行エンジンを使用して、`extensions.connectionParams` を指定しない、匿名リクエストを送信します。
4. 2番目の `connection_init` には依然として `Bearer victim-token` が含まれており、匿名サブスクリプションが被害側によって許可された結果を受け取っていることを確認します。

```ts
import WebSocket from 'isomorphic-ws';
import { parse } from 'graphql';
import { buildWSLegacyExecutor } from '@graphql-tools/executor-legacy-ws';
```

```
const exec = buildWSLegacyExecutor('ws://localhost:8081/graphql', WebSocket);

const v = exec({
document: parse('subscription { privateEvents { id } }'),
extensions: { connectionParams: { Authorization: 'Bearer victim-token' } },
});
await v.next();
await v.return?.(); // イテレータのクリーンアップにより、最初のソケットが閉じられます。executorConnectionParams はクリアされません。

const a = exec({ document: parse('subscription { privateEvents { id } }') });
await a.next(); // サーバーは、依然として Authorization: Bearer victim-token を含んだ、2番目の connection_init ペイロードを記録します。
```

- **期待される動作:** 2番目の `connection_init` ペイロードには、基本オプションと、そのリクエスト自身のパラメータのみが含まれるべきであり、被害者の情報が引き継がれるべきではありません。
- **実際の動作:** 2番目の `connection_init` は、被害者の `Authorization`/テナント情報が引き継がれており、その結果、後続の呼び出し元が被害者として認証されてしまいます。

## 提案される対策

操作レベルの `extensions.connectionParams` を、それが属する操作と接続の範囲を超えて保持しないでください。各 `connection_init` ペイロードを、基本オプションと現在のリクエストのパラメータのみから作成し、クリーンアップ時に接続固有の状態をクリアしてください。

```ts
// 1. 接続が切断されたときに、保持されている状態をクリアします。
function cleanupWebsocket() {
// ... 既存の connection_terminate / terminate / websocket = null ...
executorConnectionParams = {};
}
```

より完全な代替案は、共有クロージャの状態への累積を完全に停止することです。各リクエストごとにペイロードを生成し、例えば `const payload = { ...options.connectionParams, ...(request.extensions?.['connectionParams'] ?? {}) }` のようにし、それを executor クロージャから読み取るのではなく、その接続の `onopen` ハンドラに渡します。

備考:
```

- `executor[Symbol.dispose]()` は `cleanupWebsocket` に関連付けられており、リセットの修正を行うことで、ドキュメントに記載されている通り、呼び出し元が期待するような動作になるように改善されます。
- `Object.assign` は、置き換えるのではなく、マージを行います。そのため、後続のリクエストで一部のパラメータが提供された場合でも、省略されたキーは引き継がれます。オブジェクトを「存在する」場合にのみ置き換える修正では、この状態が中途半端になる可能性があります。
- このリポジトリの `UrlLoader` の部分は、各リクエストごとに新しい executor を構築しており、すでに安全です。そのため、変更は必要ありません。問題となるのは、エクスポートされた `buildWSLegacyExecutor` を直接利用するユーザーです。
- また、1つの executor で異なる認証コンテキストを組み合わせるのがサポートされるかどうかを明示的に定義し、サポートされない場合は拒否することが重要です。現在、このファクトリは、特定のIDに関する制限のない一般的な `Executor` を返します。

提案される回帰テストは、パッケージの既存の legacy-ws executor テストと並行して実行します(正確な仕様ファイルは、利用可能な情報では**検証されていません**)。1つの executor を録画サーバーに対して実行し、リクエスト1で `extensions.connectionParams = { Authorization: 'Bearer victim-token' }` を送信し、完了後に、リクエスト2で `connectionParams` を指定せずに送信し、2番目の `connection_init` ペイロードに `Authorization` キーが含まれていないことを確認します。さらに、2番目のケースとして、リクエスト2で `{ tenant: 'other' }` のみを提供し、ペイロードが `{ tenant: 'other' }` と基本オプションの組み合わせであることを確認します。

## 脆弱性情報

現時点では、この問題に関連する CVE、GHSA、アドバイザリURL、アップストリームの issue、または修正コミットはありません。また、アップグレードできる修正バージョンも存在しません。

## 修正方法

修正は `@graphql-tools/executor-legacy-ws@1.1.36` で行われました。これは npm で公開されています。各実行は、独自のソケットを開きます。`connection_init` は、executor の基本 `connectionParams` と、そのリクエストの `extensions.connectionParams` のみから構築されます。バージョン `>=1.1.36` にアップグレードしてください。

影響を受けるパッケージ:
- npm (JavaScript) @graphql-tools/executor-legacy-ws: 影響を受けるバージョンは <= 1.1.35、修正されたバージョンは 1.1.36