DIGGLE開発者ブログ

予実管理クラウドDIGGLEのプロダクトチームが、技術や製品開発について発信します

isolated-vmを使って安全なサンドボックスを実現しようとした思い出

お久しぶりです。DIGGLEのhirataです。

今回isolated-vmというものを使用する機会がありいろいろ学びがあったため、ニッチではありますがその内容を公開することで誰かのお役に立てたら嬉しいです。

isolated-vmとは何か?

Node.jsの中でユーザなど、外部の人が入力したJavaScriptコードを安全に実行するためのNode.js用のパッケージです。

https://github.com/laverdet/isolated-vm

隔離した環境でコードを実行できるので危険な可能性のあるJavaScriptコードもセキュアに実行することができます。

何をしようとしたのか

ユーザがJavaScriptコードでデータを操作できるようにしようということでどんなものか検証することになりました。

ざっくりとした使い方

以下ざっくりとした使い方です。パッと見は非常に簡単ですね。

  1. Isolateのインスタンスを作る。以下isolateとします。
  2. ivm.createContextSync()でコンテキストを作成。以下contextとします。
  3. context.globalがコンテキスト内のグローバル変数の参照になっているので、ivm上で実行するコードに必要なグローバル変数をcontext.global.setSync()で設定。
  4. context.evalSync()やScript.runSync()で好きなコードを実行

コードにするとこんな感じです。

const ivm = require('isolated-vm');
const isolate = new ivm.Isolate();
const context = isolate.createContextSync();
context.global.setSync('testVariable', 1234);
const result = context.evalSync('testVariable * 3');

非同期版(Syncのつかないメソッド)もありますが、今回は非同期にする必要がなかったのでわかりやすく同期版のメソッドを使っています。

さっそく序盤で苦労した話

序盤から結構色々なポイントでつまずいてしまいました。以下つまずいたところです。

使用例がほとんどない

Web上に情報がないのが一番大変でした。あまり一般的には使われていないようで、検索しても情報がほとんど出てきません。 まあコード実行を隔離するニーズってかなりニッチなので仕方ないですね。

サンプルコードもそれほどなくて、テストコードがあったのでそれを参考にしましたが、そもそもサンプルを意図したものではないためそこまで参考になりませんでした。 公式ページのなかでも

if this explanation doesn't make sense then you really should not be using this module. This is a low-level module which is just one piece of a very complicated problem.

とあり、この公式ページにある説明で理解できない人に対しては、元々丁寧に説明するつもりもないみたいです。使えるだけの知識がない人は使うなということですね。

やることが本質的に難易度が高い

isolated-vmで実現しようとしている内容は隔離された仮想の環境上でコードを安全に実行しようとするものです。

元々の実行環境とオブジェクトを共有できてしまうとそこが穴になってしまうので、基本的にisolated-vm上の環境に何かを渡すときにはコピーとなり、コピーできないものに関してはisolated-vm上のcontextでコードとして実行して別途生成する必要があります。

このことからisolated-vmを使う際にはisolated-vmに渡すためのコードを生成するコードを元々の実行環境側に実装しなくてはならない、いわゆるメタプログラミングが必須です。 メタプログラミングは分厚い抽象レイヤーであり、扱うための難易度が跳ね上がります。

デバッグが難しい

isolated-vmではconsoleは用意されておらず、気軽にデバッグ用にメッセージを出すことはできません。 元々の実行環境側から、以下のような感じで関数を渡すことでデバッグはできますが、isolated-vmの中の影響が外界に漏れてしまうので本番環境では気をつけて使ったほうがよさそうです。

  debugPrint = text => console.log(text);
  context.global.setSync('debugPrint', debugPrint);

もしくは例外はisolated-vmのcontextの外まで届くので、例外を上げて内容がわかるメッセージを投げるのも手です。

実装を進めていく上でわかったこととそれによる実装上の問題点

序盤のつまずきにめげずに実装を進めていったのですが、さらなる問題点が続々と出てきました。

コピー無しにオブジェクトは渡せない(つまりオブジェクトの共有はできない)

これはisolated-vmの目的を考えると当然ではあるのですが、元々の実行環境のオブジェクトを共有した状態では参照のみ渡すことはできないようです。 元々の実行環境からisolated-vmに参照(isolated-vmのReferenceクラスのインスタンス)を渡してもcontextの中で参照しようとするとエラーになりました。 データを共有できないため、isolated-vm側で必要なデータは元々の実行環境からコピーして独立して持つ必要があります。 つまりこのようなデータに関してはメモリを2倍必要としてしまう可能性があります。

モジュールのロードは手間がかかる

isolated-vmではModuleというクラスがあり、JavaScriptのモジュールを扱う機能を提供しているのですが、 定義したモジュールをcontextから直接参照する方法が無く、元々の実行環境を経由してReferenceを渡す必要がありました。 またモジュールは元々の実行環境側で文字列として読み込ませる必要が有ったり、依存関係を解決するための仕組みを実装する必要があったりしました。 例えばmodAとmodBに依存しているmodCを読み込んでcontext内で使おうとする場合、以下のようなコードを書く必要がありました。もしかしたら使い方が間違っている可能性もありますが、結構なコード量になります。

  const ivm = require('isolated-vm');
  const isolate = new ivm.Isolate();
  const context = isolate.createContextSync();
  ...
  // modAとmodBは定義済みのModuleインスタンスとします。
  const modules = {
    './ModA': modA,
    './ModB': modB,
  };
  const codePath = 'xxx/xxxx.js';
  const src = fs.readFileSync(codePath, { encoding: 'UTF-8' });
  const modC = isolate.compileModuleSync(
    `export default function IsolatedMod(){
        ${src}
   }; IsolatedMod.call(IsolatedMod);`
  );
  modC.instantiateSync(sandboxContext, specifier => {
    // 依存Moduleのインスタンスを返す。
    return modules[specifier];
  });
  modC.evaluateSync();
  context.setSync('modCRef', modC.global);
  context.evanSync('const modC = modCRef.deref(); ...');

isolated-vmのcontextに直接渡すことができるものはかなり限られている。

transferableのみがisolated-vmの壁を越えることができます。これがとても大きな制約となります。具体的にtransferableとは何かというと、公式ドキュメントに

Primitives (except for Symbol) are always transferable. This means if you invoke a function in a different isolate with a number or string as the argument, it will work fine. If you need to pass more complex information you will have to first make the data transferable with one of the methods here.

とあり、基本的にプリミティブはOKということです。またisolated-vmで定義されているクラスもすべてtransferableであると書かれており渡すことができるようです。 ですが、実際に動くコードがプリミティブのみなんてことはありません。必ず独自クラスが定義されていたりするものです。なのでいろいろ既存コードを変更する必要が出てきます。 以下実例です。

isolated-vmで作成したcontextに関数を渡したい場合

単純な関数はisolated-vmのCallbackとして渡されます。以下公式のドキュメントから。

Callbacks are created automatically when passing functions to most isolated-vm functions.

従ってisolated-vm側で渡した関数を実行すると、元のコンテキスト上で実行されて結果がisolated-vm側に渡されます。 そのため渡した関数の実行は常にコンテキストをまたぐ必要があり、プロセス間通信のようなことを行っているようで、その実行には時間がかかってしまうようです。 実際試しに行った実装では元々一瞬で終わっていた処理に数分かかるようになってしまいました。 後にこれがネックになり、既存部の実装を大きく変える必要が出てきます。

インスタンスを渡したい場合

isolated-vmで作成したcontextに渡す際には、オブジェクトはデフォルトではシャローコピーされます。ディープコピーはされないので、オブジェクトの中で他のオブジェクトを参照していると その参照しているオブジェクトはisolated-vmのcontextの中では参照できません。

オプションでディープコピーの指定も可能で、これを指定すると参照先もすべてまとめてコピーされるのでかなり便利です。 しかしながら、インスタンスを渡した場合にはまだ問題があります。 各オブジェクトのプロパティについてはコピーされるので単なるデータとしては使用可能ですが、クラスはコピーされないのでコピー先では元のインスタンスとしては動きません。

問題点を解決するためにどう実装したか

問題点は大きく以下のようになります。

  • 元々の実行環境からオブジェクトを共有できない
  • クラスは渡すことができない
  • モジュールのロードが面倒
  • 関数を渡すことは可能だが、実行に時間がかかる。

これに対してそれぞれどう対策したかを書いていきます。

元々の実行環境からオブジェクトを共有できない

これは解決することはできず、最後まで課題として残ってしまいました。 メモリをたくさん使ってしまうのであれば、ある程度の塊でデータを渡してその結果を受け取って、を繰り返すような処理をすることでその上限を抑えることは可能なのですが、かなり修正を加える必要がある上にたとえ対応を行ったとしても、仕様上の都合でどのような場合においてもメモリ使用量を抑制することができるかどうかはわかりませんでした。

クラスは渡すことができない

インスタンスを渡してもデータしかコピーされないので、必要なクラスをisolated-vmのcontextで別途定義しました。 元々の実行環境のソースをそのまま読み込んでisolated-vmのcontextに送り込んで実行するようにしています。 そして定義されたクラスのprototypeをObject.setPrototypeOf()を使ってコピーしたインスタンスのprototypeとして設定してやることで元々の実行環境のようにインスタンスとして機能するようになりました。

モジュールのロードが面倒

webpackで必要なモジュールを一つに固めてモジュールとしてisolated-vmのcontextにModuleとして読み込むようにしました。 これにより、直接必要なモジュールのみ記述すると、依存するモジュールやライブラリがあればwebpack側で自動的に解決してくれるようになりました。

これについては元々の実行環境側と遜色無く、とても快適です。

関数を渡すことは可能だが、実行に時間がかかる。

これを解決するには、何かあるたびに呼ばれていた関数を一回実行するだけで用を済ませられるようにする必要があり、なかなかに大変でした。 具体的に何をするかというと、複数のデータをまとめて実行する形にコードを変更する必要があります。 つまり変数に処理結果を集めてから渡すように元のコードを大きく変更しました。

そしてどうなった?

残念ながら、メモリ食いすぎで不採用になってしまいました……。

まず速度的な理由でisolated-vmのcontextにデータを渡す部分、またcontext内での処理結果を受け取る部分をまとめてデータを渡すようにする必要があり、一度に持たせなくてはならないデータ量が増大してしまいました。 さらに参照は渡せないので、それらのデータはコピーするしかなく、必要なデータをisolated-vm側と元々の実行環境の両方で持たなければなりませんでした。 結果として、最終的に許容できないほどのメモリ消費量になってしまいました。

というわけで、いろいろ苦労しましたが結果的に日の目を見ず、お蔵入りになってしまいましたとさ。苦労が必ず報われるとは限らないのです。

しかし今まで曖昧に覚えていたクラスやインスタンスの仕組みなどのJavaScriptの基礎部分を学ぶいい機会になったと思います。 苦い思い出は人生の礎になると信じて強く生きていきたいと思います。 そして色々試行錯誤しているときはとても楽しかったです。いろいろなことに挑戦できるのはエンジニア冥利につきますね。

We're hiring!

DIGGLEではともにプロダクトを開発してくれるエンジニアを大募集中です。

少しでも興味があれば、ぜひ下記採用サイトからエントリーください。
カジュアル面談も実施しているので、気軽なお気持ちでご応募いただければと思います!

herp.careers