vm2: ホストから返されるPromiseの拒否(reject)は、vm2の未処理の拒否に対する保護機能を回避し、ホストプロセスを終了させる可能性がある。

## 概要

vm2の最新バージョン(`v3.11.5`、コミット `7a1f5100b96f48d34e0fe104ab37c0acc5944f92`)でも、サンドボックス内のコードが、拒否されたホストPromiseを返すホスト側の関数を呼び出し、その返り値を無視すると、ホストのNode.jsプロセスを終了させることができる。

これは、`GHSA-hw58-p9xv-2mjh`における未処理の拒否に対する保護機能の不完全な修正版である。`localPromise`コンストラクタは、サンドボックス内で作成された未処理の拒否を捕捉し、処理するようになったが、ブリッジを介して返されるホスト側のPromiseは、ブリッジの境界で「処理済み」としてマークされない。サンドボックスが`.catch()`または`.then(..., onRejected)`を付与しない場合、Nodeのデフォルトの未処理の拒否の動作により、ホストプロセスが終了する。

## 技術的な詳細

`lib/setup-sandbox.js`は、サンドボックス内で作成されたPromiseを保護するために、実行関数をラップし、無害な「スワローテール」を付与する:

```js
apply(globalPromisePrototypeThen, this, [undefined, localPromiseSwallow]);
```

これは、サンドボックス内で作成された`localPromise`インスタンスにのみ適用される。

ホストから返されるPromiseは、ブリッジの適用パスを介して膜を越えて渡される。サンドボックス内のコードが、後でホスト側のPromiseに対して`.then`、`.catch`、または`.finally`を呼び出す場合、ブリッジはコールバックをラップする:

```js
bridge.setHostPromiseSanitizers(e => handleException(from(e)), from);
```

しかし、サンドボックス内のコードが返されたホスト側のPromiseを無視する場合、ホスト側の拒否ハンドラはアタッチされない。元のホスト側のPromiseは未処理のままとなり、Nodeのデフォルトの未処理の拒否の動作により、ホストプロセスが終了する。

`NodeVM`は、特別な`events`という組み込みモジュールのラッパーを通じて、この基本的な動作の例を提供している。`lib/builtin.js`は、ホスト側の`EventEmitter.once`をサンドボックスに渡す:

```js
once: EventEmitter.once,
```

その後、`lib/events.js`で再エクスポートされる:

```js
if (host.once) module.exports.once = host.once;
```

`events.once(ee, 'message')` を呼び出し、その後 `ee` に対して `error` を発生させると、そのラッパーを通じてホストの Promise が拒否されます。サンドボックスのコードによって無視されると、ホストプロセスが終了します。

## 影響

`vm2` サンドボックス内でコードを実行できる攻撃者が、埋め込み側がホストの Promise を返す API を公開している場合、または `NodeVM` が `events` の組み込み機能を使用することを許可している場合に、ホストの Node.js プロセスを終了させることができます。

Web サービス、キュー、ノートブックワーカー、プラグインホスト、およびマルチテナントコード実行システムでは、単一の小さなリクエストがワーカープロセスを終了させる可能性があります。再起動ポリシーは、ペイロードが再起動ごとに再実行される可能性があるため、この問題を完全に軽減できません。

## 影響を受けるパッケージ/バージョン

Node.js `v25.8.0` で影響が確認されました。

- `v3.10.0`
- `v3.10.1`
- `v3.10.2`
- `v3.10.3`
- `v3.10.4`
- `v3.10.5`
- `v3.11.0`
- `v3.11.1`
- `v3.11.2`
- `v3.11.3`
- `v3.11.4`
- `v3.11.5` / 現在のヘッド `7a1f5100b96f48d34e0fe104ab37c0acc5944f92`

最終的な PoC は、Node.js `v16.20.2`、`v18.20.8`、`v20.20.2`、`v22.22.3`、`v24.16.0`、および `v25.9.0` で、`npx node@<major>` を使用して再現されました。ローカルワークステーションのデフォルトの Node.js `v25.8.0` でも再現されます。

## 必要な設定

一般的な VM PoC には、埋め込み側が公開する、拒否されたホストの Promise を返すことができるホスト関数が必要です。

```js
const vm = new VM({
sandbox: {
hostReject: () => Promise.reject(new Error('host-boom')),
},
});
```

`NodeVM` のバリアントでは、組み込みの許可リストに `events` が必要です。

```js
new NodeVM({ require: { external: false, builtin: ['events'] } });
```

`events.once()` は、`vm2` の特別な `events` 組み込みラッパーによって公開されます。Node の公式 API ドキュメントでは、`events.once()` が、監視対象のエミッタが別のイベントを待機中に `error` を発生させた場合に拒否される Promise を返すものとして記述されています。

- サンドボックス内のコードが、返されるホストPromiseに`.catch(() => {})`を付加する場合、プロセスは正常に動作します。
- サンドボックス内のコードが、サンドボックス内で作成されたが無視されたrejected Promiseの場合、現在のバージョンではプロセスが正常に動作します。これは、`GHSA-hw58`のローカルPromiseのセキュリティ対策が有効になっていることを確認しています。
- サンドボックス内のコードが、`events.once()` Promiseに`.catch(() => {})`を付加する場合、`NodeVM`プロセスは正常に動作します。
- `NodeVM`が`events`という組み込み関数を許可していない場合、`events.once()`の機能は実行されず、プロセスは正常に動作します。

## 脆弱性情報開示に関する方針

vm2のセキュリティポリシーでは、報告者は公開された問題の報告を避け、このレポートのような非公開のチャネルを通じて報告するように求められています。このレポートは、その非公開チャネル向けに作成されており、再現手順、影響を受けるバージョン、環境/構成の詳細、および影響範囲が含まれています。影響を受ける範囲は、サポートされている`3.x`のバージョンに含まれます。

## ローカルでの検証

`oss-zero-day-harness`ディレクトリから以下のコマンドを実行します。

```fish
node submission-bundle/vm2-pov-test-host-promise-return-unhandled-rejection-dos/pov-host-promise-return-unhandled-rejection-dos.js
```

この検証用プログラムは、各ケースを子プロセスで実行します。脆弱性が確認された場合、肯定的なケースでは`status: 1`、コントロールケースでは`status: 0`となります。

最小限の脆弱性のあるケースの例:

```js
const { VM } = require('vm2');

const vm = new VM({
sandbox: {
hostReject: () => Promise.reject(new Error('host-boom')),
},
});

vm.run('hostReject(); 1');
setTimeout(() => console.log('survived'), 150);
```

現在のバージョンで確認された結果:

```text
Error: host-boom
at hostReject (...)
```

プロセスは、`survived`と表示される前に終了します。

最小限の`NodeVM`組み込み関数を使用した例:

```js
const { NodeVM } = require('vm2');

const vm = new NodeVM({
require: { external: false, builtin: ['events'] },
});

vm.run(`
const events = require('events');
const ee = new events.EventEmitter();
events.once(ee, 'message');
ee.emit('error', new Error('event-boom'));
module.exports = 'returned';
`, 'events-pov.js');

setTimeout(() => console.log('survived'), 150);
```

現在のヘッドで確認された内容:

```text
node:internal/process/promises:332
triggerUncaughtException(err, true /* fromPromise */);
Error: event-boom
```

プロセスはステータス `1` で終了します。

## 対策

アプリケーションは、READMEで推奨されているように、関連する非同期エラーに関する注意点に対処するために、プロセスレベルの `unhandledRejection` ハンドラをインストールすることで、影響を軽減できます。これはアプリケーション側の回避策であり、ライブラリレベルの修正ではありません。そのようなハンドラがない場合、現在のNodeのデフォルトの `--unhandled-rejections=throw` の動作により、エラーが未処理の例外として発生し、プロセスが終了します。

## 推奨される修正方法

ホスト関数が、サンドボックスコードに渡すホスト側のPromiseを返す場合、サンドボックスでラップする前に、元のホスト側のPromiseに、安全なホスト側のエラーハンドラをアタッチします。これにより、元のホスト側のPromiseが処理されたことを示すことになりますが、サンドボックスコードに返される値は変更されず、サンドボックスコードが後で`.catch()`または`.then(..., onRejected)`をアタッチする場合でも、エラーが隠されることはありません。

回帰テストでは、以下の内容を網羅する必要があります。

- `VM`で、`hostReject: () => Promise.reject(new Error(...))` を設定し、`.catch()`なしで `hostReject()` を呼び出した場合、プロセスは終了してはいけません。
- 同じ呼び出しでサンドボックスに`.catch()`を設定した場合でも、サニタイズされたエラーがサンドボックスのコールバックに渡される必要があります。
- `NodeVM`で、`require.builtin: ['events']` を設定し、`events.once(ee, 'message')` を呼び出した後、`.catch()`なしで `error` を発生させた場合、プロセスは終了してはいけません。
- 同じ `events.once()` の呼び出しでサンドボックスに`.catch()`を設定した場合でも、サニタイズされたエラーがサンドボックスのコールバックに渡され続ける必要があります。
- サンドボックスで作成された、エラーが発生したPromiseは、既存のローカルPromiseの保護メカニズムによって引き続き処理される必要があります。

## これが意図された動作ではない理由

vm2は、すでにこのエラーの種類をセキュリティに関わるものとして扱っています。`GHSA-hw58-p9xv-2mjh`は、サンドボックス内で発生した未処理の拒否によりホストプロセスが終了するという問題に対して、重大度「高」と判定されました。現在の`setup-sandbox.js`では、ローカルのPromiseに関連するエラー処理が実施されているため、ホストの`unhandledRejection`イベントが発生しないことが明示的に記述されています。

このレポートは、ホストから返されるPromiseについても、同様の可用性に関する問題が依然として存在することを示しています。

- サンドボックスは、`child_process`、ファイルシステム、ネットワーク、`nesting`、または危険な組み込み機能は必要ありません。
- `VM`のバリアントは、非信頼コードに対して非同期ホストヘルパーを公開するという、一般的な組み込みパターンのみが必要です。
- `NodeVM`のバリアントは、ドキュメントに記載されている組み込み機能の許可リストのみが必要です。
- 単一のサンドボックス呼び出しが、すべてのユーザーにサービスを提供するホストプロセス全体を終了させます。

これは、`timeout`がCPUループを停止できないという、ドキュメントに記載されている注意点とは異なります。また、READMEに記載されている現在の非同期関数/`await using`に関する注意点とも異なります。このレポートでは、サンドボックスの`async`構文、非同期関数、使い捨てスタック、またはV8のスタックフォーマット動作は必要ありません。問題は、vm2自身のブリッジが、ホスト側のPromiseを処理済みとしてマークせずに、サンドボックスに返している点です。一方、サンドボックス内で作成されたPromiseの処理パスでは、拒否が処理済みとしてマークされます。

これは、ホスト側のPromiseのコールバックのサニタイズに関する問題とは異なります。その場合、サンドボックスコードはホスト側のPromiseにコールバックをアタッチし、その後、不適切にサニタイズされた値を送信または返します。しかし、ここではサンドボックス側のPromiseのコールバックは全く必要なく、元のホスト側のPromiseが単にブリッジを越えた後に孤立してしまいます。

影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響を受けるバージョンは3.10.0以上、3.11.7以下、3.11.8で修正済み。