Node.js の vm モジュールでサンドボックスを作る場合の注意点

こちら↓

[JavaScript: プラグインシステムとプロトタイプ汚染攻撃 - Qiita](https://qiita.com/suin/items/9c80f299057a338f77be)

のコメント欄で、「Node.js でサンドボックスを作るなら `vm` モジュールおすすめだぜ！」
と言うような主旨のコメントをしたのですが、
色々と注意が要る機能でもあるので、補足がてらにちょっとまとめてみます。

## `vm` モジュールを利用したサンドボックス化

Node.js の [vm モジュール](https://nodejs.org/api/vm.html) を使うと、
新規コンテキスト下で任意のスクリプトを実行することができます。

~~~js
const vm = require('vm');

// 新規コンテキストを作成
const context = vm.createContext({
  // コンテキスト内のグローバル変数・関数
  x: 10
});

// 作成したコンテキスト下で実行
const result = vm.runInContext("x + 2", context);
console.log(result);
// => 12
~~~

ぱっと見、引数を渡せる `eval` みたいなものですが、
新規コンテキスト下では、`require` や `process` 等、
純粋な JavaScript にはない Node.js の機能は、明示的に与えない限り使用できません。
`console` は存在しますが、出力を行いません。

~~~js
const vm = require('vm');

const context = vm.createContext();

vm.runInContext("const fs = require('fs');", context);
// => ReferenceError: require is not defined

vm.runInContext("process.exit();", context);
// => ReferenceError: process is not defined

vm.runInContext("console.log('hey!');", context);
// => なにも表示されない
~~~

また `Object` や `Array` などの組み込みオブジェクト/コンストラクタも、
コンテキストの内と外では **別なもの** として扱われます。

~~~js
const vm = require('vm');
const context = vm.createContext();
const innerObject = vm.runInContext("Object", context);
console.log(innerObject === Object);
// => false
// 外側の Object と内側の Object は異なる実体
~~~

このため、信用できない外部コードなどを実行する際に、
一種のサンドボックスとして利用することができます。

~~~js
const vm = require('vm');
const context = vm.createContext();

const userCode = `
// プロトタイプ汚染しているつもり
Object.prototype.inject = function() {
};
`;

vm.runInContext(userCode, context);

// 外側には影響を与えない
console.log(Object.prototype.inject);
// => undefined
~~~

## 注意点

コンテキスト内部では、与えられたものを介してのみ外界にアクセスできます。

これで安全だ！

と言いたいところなのですが、裏を返せばアクセス可能なものを渡してしまえば色々出来てしまうので注意が必要です。

特に漏れやすいのが、ビルトインオブジェクトの prototype。

例えば、以下のようなケース。

~~~js
const vm = require('vm');

const context = vm.createContext({
  myConsole: {
    log(...args) {
       console.log('sandbox:', ...args);
    }
  }
});

const userCode = `
myConsole.log('hoge');
`;

vm.runInContext(userCode, context);
// => sandbox: hoge
~~~

実は外側の `Object.prototype`, `Function.prototype` にアクセスできてしまいます。

~~~js
const vm = require('vm');
const context = vm.createContext({
  myConsole: {
    log(...args) {
       console.log('sandbox:', ...args);
    }
  }
});

const userCode = `
// 外側の Object.prototype
const outerObj = Object.getPrototypeOf(myConsole);

// 外側の Function.prototype
const outerFun = Object.getPrototypeOf(myConsole.log);

outerObj.foo = 1;
outerFun.bar = 2;
`;

vm.runInContext(userCode, context);

console.log(Object.prototype.foo); // 1
console.log(Function.prototype.bar); // 2
~~~

対策をしないと、コンテキスト内部から、外側の `prototype` 汚染が可能になってしまいます。

他にも、外側からプリミティブでない値を渡す場合にも同じ危険性があります。

~~~js
const vm = require('vm');

const values = [1, 2, 3];

const context = vm.createContext({
  values: values // 外側で作られた配列を渡している
});

const userCode = `
// 外側の Array.prototype にアクセスできてしまう
const arrProto = Object.getPrototypeOf(values);
arrProto.hoge = 3;
`;

vm.runInContext(userCode, context);
console.log(Array.ptototype.hoge); // 3
~~~

また、上記例だと、"外側の `Array`" と "内側の `Array`" が異なる実体であるために、想定外の結果を生むことがあります。

~~~js
const vm = require('vm');

const values = [1, 2, 3];

const context = vm.createContext({
  values: values // 外側で作られた配列を渡している
});

const result = vm.runInContext("values instanceof Array", context);
console.log(result);
// => false
// 外側で作られた配列は、内側コンテキストの Array のインスタンスでないため。
~~~

## 対策

コンテキスト内で使うオブジェクトは外から直接与えずに、内側で作ります。

例えば以下のような感じ。

~~~js
const vm = require('vm');

// 空のコンテキスト作成
const context = vm.createContext();

// 外側のオブジェクトに直接触らないように、
// コンテキスト内で定義する。
vm.runInContext(`(outer) => {
  globalThis.myConsole = {
    log(...args) {
      outer.console.log(...args);
    }
  };
  globalThis.values = Array.from(outer.values);
}`, context)({
   console,
   values: [0, 1, 2]
});

// 外部に影響を与えられない。
const userCode = `
  Object.getPrototypeOf(myConsole);     // 内側の Object.prototype
  Object.getPrototypeOf(myConsole.log); // 内側の Function.prototype
  Object.getPrototypeOf(values);        // 内側の Array.prototype
  myConsole.log(values); // => [0, 1, 2]
`;

vm.runInContext(userCode, context);
~~~

**追記**

外部関数が例外を投げる可能性がある場合は、それらも漏らさないようにご注意を。

~~~js
vm.runInContext(`(outer) => {
  globalThis.myFunc = (arg) => {
    try {
      outer.myFunc(arg);
    } catch(e) {
      outer.reportError(e);
      throw new Error("internal error");
    }
  };
}`, context)({
   myFunc,
   reportError
});
~~~

## まとめ

`vm` モジュールを使ってサンドボックスを作る場合、
「外側で作られたオブジェクトを直接触らせない」ように注意しましょう。


## おまけ

別コンテキストに分けることで、コード実行にタイムアウトを設定することができるようになります。

これを利用して、無限ループや高負荷対策も可能です。

~~~js
const vm = require('vm');
const context = vm.createContext();

const userCode = 'while(true);';

try {
    vm.runInContext(userCode, context, { timeout: 1000 });
} catch (e) {
    console.log(e);
    // => Error: Script execution timed out after 1000ms
}
~~~

ただし、タイムアウトが発生した場合、スクリプトは中途半端なところでエラーで中断することになります。

副作用を持つコードを実行する場合は、タイムアウト後に再開するような使い方は避けたほうが無難です。

## 補遺

Node.js をクラッシュさせるコード、というのはいくつか知られていて、
そういったものは別コンテキストに分けたところでやはり防げなかったりします。
ぐぬぬ。