GraphQL Tools: `mergeIncrementalResult`におけるプロトタイプ汚染の脆弱性(非プリミティブパスセグメントを介して)

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

| フィールド | 値 |
|---|---|
| パッケージ | `@graphql-tools/utils` (npm, `pkg:github/ardatan/graphql-tools`) |
| リポジトリ | https://github.com/ardatan/graphql-tools |
| コンポーネント | `mergeIncrementalResult` / `setObjectKeyPath` (`packages/utils/src/mergeIncrementalResult.ts`) |
| 脆弱性の種類 | プロトタイプ汚染 (未検証のオブジェクトパスのトラバーサルを介して) |
| 検証対象 | ソースコード: `134d67ae36a559651f43ec7fe4a9da76f4a00c2b`; `master` HEAD: `ccca0ff0dc8ea20726fd116b2f19a38709c30e62`; 公開されているnpmのバージョン: 10.10.0, 10.10.1, 11.0.0, 12.0.0, 12.0.1 |
| 影響を受けるバージョン | `@graphql-tools/utils` 10.10.1以降、およびスキャン対象の12.0.0のソースコード。`fbb58b51c4993ba9680ce6c2ef7203ab3a142713` ("remove dset dependency")によって導入された。12.0.1でも再現される。10.10.0では再現されない。 |
| APIの公開範囲 | パブリック、ドキュメント化されたエクスポート (`packages/utils/src/index.ts:55`) |
| デフォルト設定で影響を受けるか | はい — オプションは一切関係ありません。`setObjectKeyPath`における禁止キーのチェックが唯一の防御策であり、デフォルトで有効になっています。 |

## 概要

`mergeIncrementalResult`は、`['data', ...incrementalResult.path]`を辿り、解決された場所に値を代入することで、GraphQLの応答の一部を累積された実行結果にマージします。`setObjectKeyPath`は、`'__proto__'`, `'constructor'`、および `'prototype'`に対する厳密な文字列比較によって、危険なセグメントを拒否しようと試みます(50-52行および60-62行)。しかし、パスセグメントがプリミティブな文字列または数値であるかどうかを検証することはありません。JSONパスのセグメントである`["__proto__"]`は配列オブジェクトであるため、`["__proto__"] !== '__proto__'`となり、ガードが通過します。次の行では、`current[key]`が実行されます。この`ToPropertyKey`変換により、配列が文字列キー`"__proto__"`に変換され、`Object.prototype`が解決されます。その後、一見無害なセグメントが直接`Object.prototype`に割り当てられます。

アプリケーション内で、`incrementalResult`は、別のGraphQLサーバーやトランスポートからのマルチパートまたは段階的な配信応答、または上流の`@defer`/`@stream`から解析されたデータがマージされた部分です。`ExecutionResult`というTypeScriptの型注釈は、実行時のチェックではないため、悪意のある、または不正アクセスを受けた上流サーバーは、`path`の値を自由に選択でき、その結果、ゲートウェイまたはクライアントプロセス内のグローバルプロトタイプキーと値を操作できます。

比較のために、同じヘルパー関数を通るパスの例を以下に示します。

- 単純な文字列セグメント `"__proto__"` -> 厳密な等価性によってブロックされます(50~52行、テストにおけるコントロールケースで確認済み)
- 配列セグメント `["__proto__"]` -> **危険:50~52行のガードを通過し、53~57行で`__proto__`キーに変換されます**
- 10.10.0以前のバージョンでは、`dset`が使用されていたため、この脆弱性は利用できませんでした(攻撃ペイロードのうち5件中0件が成功しました)

## 影響

悪意のある段階的なGraphQL応答は、消費するNode.jsプロセス内の`Object.prototype`に、攻撃者が選択した値を設定した任意のプロパティを追加できます。すべての通常のオブジェクトがこれから継承するため、認証や設定のチェック、およびプロセス内の他の無関係なコードの動作を変更する可能性があります。あるPoCでは、`Object.prototype.isAdmin = true`と`Object.prototype.ownerEmail`を設定し、その後、新しく作成された`{}`オブジェクトに対して`opts.isAdmin === true`というチェックを行うと、`true`が返されました。

安全な動作からのずれ:このトラバーサルの脆弱性がない場合、悪意のある上流応答は、呼び出し元に返されるGraphQLデータにのみ影響を与えることができます。これは、範囲が限定された、同一のバンド内での影響です。一方、`path: [["__proto__"], "isAdmin"]`の場合、書き込みは`Object.prototype`に到達し、応答オブジェクト全体を回避し、後で作成されるオブジェクトや、応答に触れないコードに影響を与えます。

評価に関する注意:**このレポートには、意図的にCVSSベクトルは記載されていません。** この脆弱性に対するスコアは算出されておらず、脆弱性の悪用可能性は、利用するアプリケーションのデータフローに依存します。具体的には、制御できない上位のサーバーから受信した増分結果を統合するかどうか、また、継承されたデフォルト設定にどの程度依存するかによって異なります。影響を受けない利用者は、`@graphql-tools/utils`のバージョン10.10.0以前のもの(`dset`ベースの実装では、テストされたすべてのペイロードがブロックされました)、`Object.prototype`を固定しているプロセス、および、ヘルパー関数を呼び出す前に、増分される`path`セグメントをプリミティブ型に検証する利用者のことです。また、`graphql-tools`自体の本番環境コードでは、`mergeIncrementalResult`関数を直接呼び出していません。これは、増分された転送結果を組み立てる利用者のために公開されています。

## 前提条件

1. 利用者は、別のGraphQLサーバーまたはその他の信頼できない転送から受信した増分ペイロードに対して、公開されている`mergeIncrementalResult`ヘルパー関数を呼び出します。特別なオプションは必要ありません。
2. 呼び出しの前に、増分ペイロードが、文字列/数値のプリミティブ型の`path`セグメントのみを含むように、実行時に検証されていません。これはデフォルトの設定です。`packages/utils/src/index.ts:55`では、ヘルパー関数が直接エクスポートされており、実行時の検証は行われず、警告も記載されていません。また、この動作は、呼び出し元の責任として文書化されていません。
3. 攻撃者は、不正な増分`path`値を返すことができます。つまり、上位のサブグラフ/転送を制御している、または、そのセキュリティが侵害されている、または、中間者攻撃(MITM)を実行できる状態です。
4. プロセスが`Object.prototype`を固定していない状態である必要があります。

これは、特殊な記述方法、コードの改ざん、または通常とは異なる設定を必要としません。ドキュメントに記載されている、想定される正常な動作経路が影響を受けます。コードに存在する唯一の防御策は、50行目と60行目に記述されている、厳密な等価性による禁止キーチェックです。このチェック自体が、開発者がこれらのパスセグメントを信頼できない入力として扱っていることを示しています。信頼に関する警告がドキュメントに記載されているかどうかは確認できませんでした(ドキュメントのレビューは、`packages/utils/src/index.ts:55`にあるエクスポートされたシンボルに限定されます。より広範囲なドキュメントの監査は**実施されていません**)。

## データフロー

1. **原因:** 攻撃者が制御する `incrementalResult.path` が、パブリックなマージヘルパーに渡されたオブジェクトから取得され、キーパスに展開されます: `const path = ['data', ...(incrementalResult.path ?? [])];` ([`packages/utils/src/mergeIncrementalResult.ts:12`](https://github.com/ardatan/graphql-tools/blob/134d67ae36a559651f43ec7fe4a9da76f4a00c2b/packages/utils/src/mergeIncrementalResult.ts#L12)).
2. **伝播:** `setObjectKeyPath` は、検証されていないセグメントを禁止文字列と比較する際に `===` のみを使用します。プリミティブでないセグメントは、どの禁止文字列とも一致しないため、変更されずに通過します ([`packages/utils/src/mergeIncrementalResult.ts:48-52`](https://github.com/ardatan/graphql-tools/blob/134d67ae36a559651f43ec7fe4a9da76f4a00c2b/packages/utils/src/mergeIncrementalResult.ts#L48-L52)).
3. **影響点:** `current[key]` は `ToPropertyKey` の型変換を行い、配列セグメント `["__proto__"]` をキー `"__proto__"` に変換し、`Object.prototype` を解決します。その後、次のセグメントがその解決されたオブジェクトに割り当てられます: `current[finalKey] = existingValue != null ? mergeDeep([existingValue, value]) : value` ([`packages/utils/src/mergeIncrementalResult.ts:53-57`](https://github.com/ardatan/graphql-tools/blob/134d67ae36a559651f43ec7fe4a9da76f4a00c2b/packages/utils/src/mergeIncrementalResult.ts#L53-L57), 影響点: [`:59-64`](https://github.com/ardatan/graphql-tools/blob/134d67ae36a559651f43ec7fe4a9da76f4a00c2b/packages/utils/src/mergeIncrementalResult.ts#L59-L64)).
4. **到達可能性:** エントリーポイントは、パブリックAPI である `mergeIncrementalResult({ incrementalResult, executionResult })` であり、これは [`packages/utils/src/index.ts:55`](https://github.com/ardatan/graphql-tools/blob/134d67ae36a559651f43ec7fe4a9da76f4a00c2b/packages/utils/src/index.ts#L55) で直接エクスポートされ、インクリメンタルな転送結果を組み立てるユーザーに提供されます。テストの結果、`data`、`items` (`@stream`) および再帰的な `incremental[]` のすべてのパスが影響を受けていることが確認されました。

## 脆弱なコード

`packages/utils/src/mergeIncrementalResult.ts` の 12 行目から 64 行目まで、`134d67ae36a559651f43ec7fe4a9da76f4a00c2b` のコミットにおける変更箇所(一部省略。`...` は、その間の行を省略していることを示します)。

```ts
const path = ['data', ...(incrementalResult.path ?? [])];
...
const key = keyPath[i];
if (key === '__proto__' || key === 'constructor' || key === 'prototype') return;
if (current[key] == null) current[key] = ...;
current = current[key];
...
current[finalKey] = existingValue != null ? mergeDeep([existingValue, value]) : value;
```

- フィルタリング処理 (`key === '__proto__' || ...`, 50 行目から 52 行目) は、意図された防御策ですが、これは文字列型のデータのみに対して安全です。
- `current = current[key]` (53 行目から 57 行目) は、プリミティブ型でないデータが、許可されていないキーに変換される場所です。これが、レスポンスオブジェクトから抜け出す原因となります。
- `current[finalKey] = ...` (59 行目から 64 行目) は、最終的な書き込み先であり、`Object.prototype` に到達します。
- 60 行目から 62 行目にある、最終セグメントのチェックも、同様の厳密な等価性比較を行っており、同じ脆弱性を持っています。

## 再現

**検証済み — 検査だけでなく、動的に再現されました。** 3 つの独立した再現テストが実施されました。

1. **ソースコードレベル。** `packages/utils/src/{mergeIncrementalResult,mergeDeep,helpers}.ts` ファイルは、`.js` の相対パスインポート指定を `.ts` に変更する以外は、`master` ブランチから変更せずにコピーされ、`node --experimental-strip-types --test` コマンドで実行されました。5件のテストのうち、5件すべてが成功しました。コントロールテストでは、単純な文字列 `"__proto__"` がブロックされることが確認されました。一方、ペイロード `{"path":[["__proto__"],"polluted"],"data":{"pwned":true}}` は、`Object.prototype.polluted = {pwned: true}` を設定し、`executionResult` を `{"data":{}}` のままにします。`data`、`items` (`@stream`)、および再帰的な `incremental[]` の各部分が影響を受けています。
2. **公開された成果物。** 同じ攻撃を、実際の npm のディストリビューションビルドに対して実行しました。10.10.0 = 攻撃が5件中0件成功(`dset` が使用されており、これがブロックしています)。10.10.1、11.0.0、12.0.0、および 12.0.1 = 攻撃が5件中5件すべて成功しました。この差異は、影響を受ける範囲と、`fbb58b51c4993ba9680ce6c2ef7203ab3a142713` がこの問題を導入したことを確認しています。このコミットは、`master` ブランチでそのファイルに触れた最後のコミットであり、HEAD (`ccca0ff0dc8ea20726fd116b2f19a38709c30e62`) では修正されていません。
3. **ネットワーク経由での実際の呼び出し元。** ローカルの HTTP サーバーが、仕様に準拠した `multipart/mixed` の `@defer` レスポンスを返し、その遅延パッチには `path: [["__proto__"],"isAdmin"]` が含まれていました。クライアントは、チャンクを `JSON.parse()` で解析し、公開されている `mergeIncrementalResult()` を呼び出しました。その後、`Object.getOwnPropertyNames(Object.prototype)` には `isAdmin` と `ownerEmail` が含まれ、`({}).isAdmin === true` となり、新しい `{}` に対して、コンシューマー向けのチェック `opts.isAdmin === true` を実行すると `true` が返されます。

最小限のスタンドアロン再現コード:

```ts
import { mergeIncrementalResult } from '@graphql-tools/utils';

const executionResult = { data: {} };
const incrementalResult = JSON.parse('{"path":[["__proto__"],"polluted"],"data":"yes"}');
mergeIncrementalResult({ incrementalResult, executionResult });
console.log(({} as any).polluted); // 期待される脆弱な出力: yes
delete (Object.prototype as any).polluted;
```

手順:上記のコードをNode.js環境で実行し、コンパイル済みの`@graphql-tools/utils`パッケージを使用します。その後、マージ処理後に新たに作成されたオブジェクトを詳細に確認してください。

- **期待される結果:** ヘルパー関数が不正なパスを検出し、`({} as any).polluted`が`undefined`のままになり、`Object.prototype`に新しいプロパティが追加されない。
- **実際の結果:** 書き込みが`Object.prototype`に到達し、新しいオブジェクトにおいて`({} as any).polluted`が `"yes"` となり、`executionResult`は`{"data":{}}`のままになっている。

## 提案される改善策

プロパティへのアクセスに使用されるすべてのパスセグメントの型を検証し、プリミティブな文字列/数値キーのみがブラケットアクセスに到達するようにします。そして、検証後にのみ禁止された名前との比較を行います。

```ts
function isSafeKey(key: unknown): key is string | number {
if (typeof key !== 'string' && typeof key !== 'number') return false;
return key !== '__proto__' && key !== 'constructor' && key !== 'prototype';
}

// パスセグメントを辿る
const key = keyPath[i];
if (!isSafeKey(key)) return;
if (!Object.prototype.hasOwnProperty.call(current, key) || current[key] == null) {
current[key] = ...;
}
current = current[key];

// 最終セグメント
if (!isSafeKey(finalKey)) return;
current[finalKey] = existingValue != null ? mergeDeep([existingValue, value]) : value;
```

さらに強力な方法として、プロジェクトのスタイルに合えば、`Object.create(null)`で中間コンテナを構築し、パス全体を通して自身のプロパティへのアクセス (`Object.prototype.hasOwnProperty.call`) を使用します。これにより、継承されたプロパティが、将来のチェックを逃した場合でも、解決されたり書き込まれたりするのを防ぐことができます。

備考:

- 最終セグメントのチェック(60-62行目)は、ウォークチェック(50-52行目)と同様の処理が必要です。片方だけを修正すると、もう片方も到達可能になってしまいます。
- この箇所に到達する3つの分岐(`data`、`items`(`@stream`)、および再帰的な`incremental[]`の処理)は、すべて同じヘルパーによって影響を受けており、`setObjectKeyPath`での検証によってまとめて修正されています。
- このヘルパー以外の箇所で変更が必要なものはないようです。10.10.0以前のバージョンでは、`dset`に委譲していたため、テストに使用したペイロードでは脆弱性を突くことができませんでした。したがって、この問題は、`fbb58b51c4993ba9680ce6c2ef7203ab3a142713`に追加されたカスタムのトラバーサル処理に限定されます。
- 10.xおよび11.xのバージョンに対する修正の適用方法(修正の適用か、バックポートか)は、メンテナが決定します。現時点では、この問題に関する修正、注意喚起、またはアップストリームの問題は存在しません。

提案される回帰テストは、既存の`mergeIncrementalResult`のテストに追加し、正確な仕様ファイルパスは**不明**です(このレビューでは特定されていません)。テストでは、`{"path":[["__proto__"],"polluted"],"data":"yes"}`をマージすると、`({} as any).polluted`が`undefined`になり、`Object.getOwnPropertyNames(Object.prototype)`が変更されないことを検証します。また、コントロールとして、単純な文字列`"__proto__"`を含むセグメントが依然として拒否されることを確認し、オブジェクト値を持つセグメント(例:`{"toString":() => "__proto__"}`のような強制変換)および`items`/`incremental[]`の分岐に対する同様のテストも追加します。

## 修正

`@graphql-tools/utils@12.0.2`で修正されました。これはnpmで公開されています。`setObjectKeyPath`は、すべてのセグメントがプリミティブな文字列または数値である場合にのみパスを許可し、依然として`__proto__`、`constructor`、および`prototype`をブロックします。バージョン12.0.2以降にアップグレードしてください。

影響を受けるパッケージ:
- npm (JavaScript) @graphql-tools/utils: 影響範囲は10.10.1以上、12.0.1以下。12.0.2で修正済み。