NodeJS 速習チュートリアル

Node.js VM モジュール

1. VM モジュールの紹介

VM(Virtual Machine)モジュールは、JavaScript コードをコンパイルし、隔離されたコンテキスト(Context)内で実行することを可能にします。

これは以下のユースケースで非常に有用です:

  • 信頼できないコードをサンドボックス内で安全に実行する
  • JavaScript コードを動的に評価(Evaluate)する
  • プラグインや拡張システムを作成する
  • カスタムスクリプト環境を構築する
  • コードを分離してテストする

       警告: VM モジュールはメインの JavaScript 環境からの隔離機能を提供しますが、完全に安全なサンドボックスではありません。信頼できないコードを実行するための唯一のセキュリティメカニズムとして使用すべきではありません。

2. VM モジュールのインポート

VM モジュールを使用するには、Node.js アプリケーションで以下のようにインポートします:

const vm = require('vm');

3. 主要な概念

VM モジュールには、いくつかの重要なコンポーネントがあります:

コンポーネント説明
Script異なるコンテキストで複数回実行できる、コンパイル済みの JavaScript コード。
Contextスクリプトが実行される隔離されたグローバルオブジェクト。サンドボックス環境に相当。
ContextifiedObjectVM コンテキストに関連付けられ、そのグローバルオブジェクトとして機能するオブジェクト。

4. 基本的な使い方:コンテキスト内での実行

VM モジュールの最もシンプルな使い方は、特定のコンテキスト内でコードを実行することです:

const vm = require('vm');

// コンテキストオブジェクトを作成
const context = { x: 2 };

// オブジェクトを「コンテキスト化」する
vm.createContext(context);

// コンテキスト内でスクリプトをコンパイルして実行
vm.runInContext('x = x * 2; y = 10;', context);

// 変更されたコンテキストを確認
console.log(context); // 出力: { x: 4, y: 10 }

この例のポイント:

  1. 変数 x を持つコンテキストオブジェクトを作成します。
  2. vm.createContext() を使用してオブジェクトをコンテキスト化します。
  3. このコンテキスト内で、x を変更し y を作成する JavaScript を実行します。
  4. 変更内容は元のコンテキストオブジェクトに反映されます。

5. VM モジュールのメソッド

5.1 Script メソッド

メソッド説明
vm.Script(code[, options])コンパイル済みコードを表す新しい Script オブジェクトを作成します。
script.runInContext(contextObject[, options])指定されたコンテキスト内でコンパイル済みコードを実行します。
script.runInNewContext([contextObject][, options])新しいコンテキスト内でコンパイル済みコードを実行します。
script.runInThisContext([options])現在のコンテキスト内でコンパイル済みコードを実行します。

5.2 コンテキストメソッド

メソッド説明
vm.createContext([contextObject][, options])スクリプト実行に使用できる新しいコンテキストを作成します。
vm.isContext(object)オブジェクトがコンテキスト化されているか確認します。
vm.runInContext(code, contextObject[, options])指定されたコンテキスト内でコードをコンパイルし実行します。
vm.runInNewContext(code[, contextObject][, options])新しいコンテキスト内でコードをコンパイルし実行します。
vm.runInThisContext(code[, options])現在のコンテキスト内でコードをコンパイルし実行します。

6. スクリプトの作成とコンパイル

同じコードを複数回実行する場合、パフォーマンスを向上させるために Script クラスを使用して事前コンパイルすることができます:

const vm = require('vm');

// スクリプトを一度だけコンパイル
const script = new vm.Script('x += 40; let z = 30;');

// 複数のコンテキストを作成
const context1 = { x: 10 };
const context2 = { x: 20 };

// オブジェクトをコンテキスト化
vm.createContext(context1);
vm.createContext(context2);

// 同じスクリプトを異なるコンテキストで実行
script.runInContext(context1);
script.runInContext(context2);

console.log(context1); // 出力: { x: 50, z: 30 }
console.log(context2); // 出力: { x: 60, z: 30 }

       注意: 解析とコンパイルの手順が一度で済むため、同じコードを何度も実行する必要がある場合は、スクリプトを個別にコンパイルする方が効率的です。

7. コード実行の異なる方法

7.1 runInContext

事前に作成されたコンテキスト内でコードを実行します:

const vm = require('vm');

const context = { value: 10 };
vm.createContext(context);

// 直接実行
vm.runInContext('value += 5', context);
console.log(context.value); // 15

// コンパイルしてから実行
const script = new vm.Script('value *= 2');
script.runInContext(context);
console.log(context.value); // 30

7.2 runInNewContext

新しいコンテキストを作成し、その中でコードを実行します:

const vm = require('vm');

// 事前に createContext を呼ぶ必要はありません
const context = { value: 10 };
vm.runInNewContext('value += 5; result = value * 2;', context);

console.log(context); // { value: 15, result: 30 }

7.3 runInThisContext

現在の V8 コンテキスト内でコードを実行します(eval に似ていますが、より安全です):

const vm = require('vm');

// 現在のスコープで変数を定義
const locallet = 20;
let result;

// これは locallet にアクセスできません
vm.runInThisContext('result = (typeof locallet !== "undefined" ? locallet : "未定義")');
console.log(result); // '未定義'

// ただし、グローバル変数にはアクセス可能です
global.globallet = 30;
vm.runInThisContext('result = globallet');
console.log(result); // 30

// 比較用:eval はローカル変数にアクセスできます
eval('result = locallet');
console.log(result); // 20

       注意:runInThisContexteval と似ていますが、呼び出し元のスコープにあるローカル変数にアクセスできない点が異なります。これにより、コードインジェクションがローカル変数に影響を与えるリスクを軽減でき、多少安全になります。

8. タイムアウトオプションの活用

無限ループや長時間実行されるスクリプトを防ぐために、実行タイムアウトを設定できます:

const vm = require('vm');

const context = { result: 0 };
vm.createContext(context);

try {
  // 1000ms (1秒) 後にタイムアウトするよう設定
  vm.runInContext(`
    let counter = 0;
    while (true) {
      counter++;
      result = counter;
    }
  `, context, { timeout: 1000 });
} catch (err) {
  console.error(`実行タイムアウト: ${err.message}`);
  console.log(`タイムアウト前の結果: counter は ${context.result} に達しました`);
}

       警告: タイムアウトオプションは、指定された時間に「正確に」停止することを保証するものではありません。実際のタイムアウト時間は多少前後する可能性があります。

9. Node.js コアモジュールへのアクセス制御

デフォルトでは、VM コンテキストで実行されるコードは Node.js のコアモジュールにアクセスできません。利用可能なモジュールを制限することができます:

const vm = require('vm');
const fs = require('fs');

// コアモジュールへのアクセスを制限したサンドボックスを作成
const sandbox = {
  // console の一部の機能のみ許可
  console: {
    log: console.log,
    error: console.error
  },
  
  // fs モジュールへの制限付きアクセスを提供
  fs: {
    readFileSync: fs.readFileSync
  },
  
  // カスタムユーティリティ
  util: {
    add: (a, b) => a + b,
    multiply: (a, b) => a * b
  },
  
  // process や child_process などにはアクセス不可
};

vm.createContext(sandbox);

// 制限されたアクセス権でコードを実行
try {
  vm.runInContext(`
    console.log('サンドボックス内で実行中');
    console.log('2 + 3 =', util.add(2, 3));
    
    // 許可されたファイル読み込みを試行
    try {
      const content = fs.readFileSync('example.txt', 'utf8');
      console.log('ファイルの内容:', content);
    } catch (err) {
      console.error('ファイル読み込みエラー:', err.message);
    }
    
    // process へのアクセスを試行(失敗するはず)
    try {
      console.log('プロセス情報:', process.version);
    } catch (err) {
      console.error('process にアクセスできません:', err.message);
    }
  `, sandbox);
} catch (err) {
  console.error('サンドボックスの実行に失敗しました:', err);
}

       警告: 特定のモジュールへのアクセスを制限することはできますが、このアプローチは完全に安全ではありません。悪意のある攻撃者は、サンドボックスを脱出(エスケープ)する方法を見つける可能性があります。真に安全なサンドボックス化には、追加の隔離技術や専門のライブラリを検討してください。

10. シンプルなテンプレートエンジンの構築

VM モジュールを使用して、シンプルなテンプレートエンジンを作成できます:

const vm = require('vm');

function renderTemplate(template, data) {
  // テンプレート関数を作成 - {{ variable }} を値に置換
  const templateScript = `
    function template(data) {
      let output = \`${template.replace(/\{\{\s*(\w+)\s*\}\}/g, '${data.$1}')}\`;
      return output;
    }
    template(data);
  `;
  
  // データを含むコンテキストを作成
  const context = { data };
  vm.createContext(context);
  
  // テンプレート関数を実行
  return vm.runInContext(templateScript, context);
}

// 使用例
const template = `
<!DOCTYPE html>
<html>
<head>
  <title>{{ title }}</title>
</head>
<body>
  <h1>{{ title }}</h1>
  <p>ようこそ、{{ name }} さん!</p>
  <p>今日は {{ date }} です</p>
</body>
</html>
`;

const data = {
  title: 'マイテンプレートページ',
  name: 'ユーザー',
  date: new Date().toLocaleDateString()
};

const rendered = renderTemplate(template, data);
console.log(rendered);

       注意: この例はシンプルな使用法を示していますが、Handlebars や EJS などの本番用テンプレートエンジンの方が堅牢で安全です。ユーザーデータが適切にエスケープされていない場合、この例はインジェクション攻撃に対して脆弱です。

11. プラグインシステムの作成

VM モジュールは、プラグインを隔離して読み込み・実行するシステムの作成に役立ちます:

const vm = require('vm');
const fs = require('fs');
const path = require('path');

class PluginSystem {
  constructor() {
    this.plugins = new Map();
    this.api = {
      version: '1.0.0',
      registerHook: this.registerHook.bind(this),
      utils: {
        add: (a, b) => a + b,
        multiply: (a, b) => a * b,
        formatDate: (date) => new Date(date).toLocaleDateString()
      }
    };
    
    this.hooks = {
      init: [],
      process: [],
      shutdown: []
    };
  }
  
  // プラグインフックを登録
  registerHook(hookName, callback) {
    if (this.hooks[hookName]) {
      this.hooks[hookName].push(callback);
      console.log(`${hookName} フックを登録しました`);
    } else {
      console.error(`無効なフック名: ${hookName}`);
    }
  }
  
  // ファイルからプラグインを読み込む
  loadPlugin(pluginName, pluginCode) {
    try {
      console.log(`プラグインを読み込み中: ${pluginName}`);
      
      // このプラグイン専用のサンドボックスを作成
      const sandbox = {
        console: {
          log: (msg) => console.log(`[${pluginName}] ${msg}`),
          error: (msg) => console.error(`[${pluginName}] ${msg}`)
        },
        setTimeout,
        clearTimeout,
        api: this.api
      };
      
      // コンテキストを作成してプラグインコードを実行
      const context = vm.createContext(sandbox);
      vm.runInContext(pluginCode, context);
      
      // 読み込まれたプラグインを保存
      this.plugins.set(pluginName, {
        name: pluginName,
        sandbox
      });
      
      console.log(`プラグインの読み込みに成功しました: ${pluginName}`);
    } catch (err) {
      console.error(`プラグイン ${pluginName} の読み込みエラー:`, err.message);
    }
  }
  
  // 特定のタイプのフックをすべて実行
  async runHooks(hookName, data) {
    console.log(`${hookName} フックを実行中...`);
    
    for (const hook of this.hooks[hookName]) {
      try {
        const result = await hook(data);
        console.log(`フック実行結果:`, result);
      } catch (err) {
        console.error(`${hookName} フック内でエラーが発生しました:`, err.message);
      }
    }
  }
  
  // プラグインシステムを実行
  async run(data) {
    await this.runHooks('init', data);
    await this.runHooks('process', data);
    await this.runHooks('shutdown', data);
  }
}

// プラグインコードの例(通常は別ファイル)
const examplePlugin = `
// 初期化フックを登録
api.registerHook('init', async (data) => {
  console.log('プラグインをデータで初期化中:', data);
  return '初期化完了';
});

// 処理フックを登録
api.registerHook('process', async (data) => {
  console.log('データを処理中');
  return {
    processed: true,
    sum: api.utils.add(data.x, data.y),
    product: api.utils.multiply(data.x, data.y),
    date: api.utils.formatDate(new Date())
  };
});

// シャットダウンフックを登録
api.registerHook('shutdown', async () => {
  console.log('プラグインを終了中');
  return '終了完了';
});

console.log('プラグインが API バージョン', api.version, 'で読み込まれました');
`;

// システムの作成と実行
(async () => {
  const system = new PluginSystem();
  system.loadPlugin('example', examplePlugin);
  await system.run({ x: 5, y: 10 });
})();

12. ベストプラクティスとセキュリティ上の注意

12.1 セキュリティ・ベストプラクティス

  • VM モジュールだけに頼らない: 信頼できないコードを扱う場合は、追加のセキュリティ対策を併用してください。
  • リソースを制限する: 実行されるコードに対してタイムアウトやメモリ制限を設定してください。
  • アクセスの制御: サンドボックスには必要最低限の機能のみを提供してください。
  • 入力の検証: VM で処理する前に、すべての入力を慎重に検証してください。
  • プロセスの隔離: 最高のセキュリティを求めるなら、信頼できないコードは別プロセスやコンテナで実行してください。

12.2 パフォーマンス・ベストプラクティス

  • スクリプトを一度コンパイルする: 複数回実行されるコードには new vm.Script() を使用します。
  • コンテキストを再利用する: 新しいコンテキストの作成はコストがかかるため、可能な限り再利用してください。
  • コンテキストのサイズを制限する: パフォーマンス向上のため、コンテキストは小さく保ちます。
  • 大量のデータに注意する: コンテキスト間での巨大なデータ構造の受け渡しは非効率になる場合があります。

13. VM モジュール vs eval()

VM モジュールは eval() を使用する場合と比較して、いくつかの利点があります:

特徴VM モジュールeval()
ローカル変数へのアクセス不可(runInThisContext を除く)可能
隔離レベル良好(独立したコンテキスト)なし(同一コンテキスト)
セキュリティ良好(制御されたコンテキスト)脆弱(すべてにアクセス可能)
繰り返し実行の性能良好(事前コンパイル可能)低(実行のたびにコンパイル)
実行制御多い(タイムアウト等)少ない

14. VM モジュールの制限事項

  • 不完全なサンドボックス: VM コンテキストは、別プロセスほどの完全な隔離を提供しません。
  • CPU・メモリ制限がない: リソースの使用量を直接制限することはできません(タイムアウトのみ可能)。
  • プロトタイプ汚染のリスク: VM コンテキスト内のコードが、依然として JavaScript のプロトタイプを変更できる可能性があります。
  • 同期実行: コードの実行はイベントループをブロックします(ワーカースレッドで実行しない限り)。
  • デバッグの難しさ: VM コンテキスト内で実行されるコードのデバッグは困難な場合があります。

       警告: ミッションクリティカルなセキュリティアプリケーションでは、child_process モジュールを使用した別プロセス、コンテナ、または vm2 のような専用のライブラリなど、より堅牢なサンドボックスソリューションを検討してください。

15. まとめ

Node.js の VM モジュールは、隔離された V8 コンテキストで JavaScript コードを実行する手段を提供します。以下の用途に役立ちます:

  • 一定の隔離レベルを保ちながらコードを動的に実行する
  • 安全に拡張可能なプラグインシステムを作成する
  • テンプレートエンジンやスクリプト環境を構築する
  • 制御されたコンテキストでコードをテストする

信頼できないコードを実行するための完全なセキュリティソリューションではありませんが、VM モジュールは eval() よりも優れた隔離機能を提供し、Node.js アプリケーション内での JavaScript 評価に非常に価値のあるツールです。

16. 高度なコンテキスト管理

カスタムグローバル変数やモジュールを使用した複雑な VM コンテキストの管理方法を学びましょう。

16.1 カスタムグローバル変数を持つコンテキストの作成

const vm = require('vm');
const util = require('util');

// 特定のグローバル変数を持つカスタムコンテキストを作成
const context = {
  console: {
    log: (...args) => {
      // カスタム console.log 実装
      process.stdout.write('カスタムログ: ' + util.format(...args) + '\n');
    },
    error: console.error,
    warn: console.warn,
    info: console.info
  },
  // カスタムユーティリティの追加
  utils: {
    formatDate: () => new Date().toISOString(),
    generateId: () => Math.random().toString(36).substr(2, 9)
  },
  // 安全な require 関数の追加
  require: (moduleName) => {
    const allowedModules = ['path', 'url', 'util'];
    if (!allowedModules.includes(moduleName)) {
      throw new Error(`モジュール '${moduleName}' は許可されていません`);
    }
    return require(moduleName);
  }
};

vm.createContext(context);

const code = `
  console.log('現在時刻:', utils.formatDate());
  console.log('生成された ID:', utils.generateId());
  
  try {
    const fs = require('fs'); // これはエラーを投げる
  } catch (err) {
    console.error('セキュリティエラー:', err.message);
  }
  
  const path = require('path'); // 許可されているので動作する
  console.log('ディレクトリ名:', path.dirname('/path/to/file.txt'));
`;

try {
  vm.runInContext(code, context, { filename: 'custom-context.js' });
} catch (err) {
  console.error('スクリプトの実行に失敗しました:', err);
}

16.2 VM 内でのモジュールシステムの実装

VM コンテキスト内にシンプルなモジュールシステムを構築する方法です:

const vm = require('vm');
const fs = require('fs');
const path = require('path');

class VMModuleSystem {
  constructor(basePath = '.') {
    this.basePath = path.resolve(basePath);
    this.cache = new Map();
    this.context = vm.createContext({
      module: { exports: {} },
      exports: {},
      console: console,
      require: this.require.bind(this),
      __dirname: this.basePath,
      __filename: path.join(this.basePath, 'main.js')
    });
  }
  
  require(modulePath) {
    // コアモジュールの処理
    if (require.resolve.paths(modulePath) === null) {
      return require(modulePath);
    }
    
    const resolvedPath = this.resolveModule(modulePath);
    
    // キャッシュを確認
    if (this.cache.has(resolvedPath)) {
      return this.cache.get(resolvedPath).exports;
    }
    
    const module = { exports: {} };
    this.cache.set(resolvedPath, module);
    
    try {
      const code = fs.readFileSync(resolvedPath, 'utf8');
      // コードを関数ラッパーで包む
      const wrapper = `(function(module, exports, require, __dirname, __filename) {${code}\n})`;
      
      const script = new vm.Script(wrapper, {
        filename: resolvedPath,
        displayErrors: true
      });
      
      const localRequire = (path) => this.require(path);
      
      script.runInNewContext({
        module: module,
        exports: module.exports,
        require: localRequire,
        __dirname: path.dirname(resolvedPath),
        __filename: resolvedPath
      });
      
      return module.exports;
    } catch (err) {
      this.cache.delete(resolvedPath);
      throw err;
    }
  }

  // モジュールパスの解決
  resolveModule(request, parentPath) {
    // 簡易的なパス解決ロジック(実際にはより詳細なロジックが必要)
    const resolved = path.resolve(path.dirname(parentPath || this.basePath), request);
    if (fs.existsSync(resolved + '.js')) return resolved + '.js';
    return request;
  }
  
  runFile(filePath) {
    const absolutePath = path.resolve(this.basePath, filePath);
    return this.require(absolutePath);
  }
}

17. セキュリティのベストプラクティス(詳細)

VM モジュールを使用する際、セキュリティは最優先事項です。

const vm = require('vm');

// 安全性の高い評価関数
function safeEval(code, timeout = 1000) {
  // 必要なグローバル変数のみを含むコンテキストを作成
  const context = {
    console: {
      log: console.log,
      error: console.error
    },
    Math: Object.create(null),
    JSON: {
      parse: JSON.parse,
      stringify: JSON.stringify
    }
  };
  
  // Math メソッドを安全にコピー
  Object.getOwnPropertyNames(Math)
    .filter(prop => typeof Math[prop] === 'function')
    .forEach(prop => { context.Math[prop] = Math[prop]; });
  
  // プロトタイプアクセスを制限したサンドボックスを作成
  const sandbox = vm.createContext(context, {
    name: 'sandbox',
    codeGeneration: { strings: false, wasm: false } // 動的なコード生成を禁止
  });
  
  try {
    const script = new vm.Script(`(function() { "use strict"; ${code} })();`, {
      timeout: timeout,
      microtaskMode: 'afterEvaluate'
    });
    return script.runInContext(sandbox, { timeout });
  } catch (err) {
    throw new Error('スクリプトの実行に失敗しました');
  }
}

18. パフォーマンスの最適化(詳細)

18.1 事前コンパイルとキャッシュの活用

const vm = require('vm');

// 1. 一度コンパイルして、何度も実行する
const expensiveScript = new vm.Script(`
  function calculate(n) {
    let result = 0;
    for (let i = 0; i < n; i++) result += Math.sqrt(i);
    return result;
  }
  calculate; // 関数参照を返す
`);

const context = { Math };
vm.createContext(context);
const calculate = expensiveScript.runInContext(context);

// 再コンパイルなしで関数を呼び出し
console.log(calculate(1000));

// 2. コンテキストを再利用してオーバーヘッドを削減
const sharedContext = {
  console: { log: console.log },
  utils: { formatDate: d => d.toISOString() }
};
vm.createContext(sharedContext);

function runWithSharedContext(code) {
  const script = new vm.Script(code);
  return script.runInContext(sharedContext);
}

パフォーマンス向上のヒント:

  • 事前コンパイル: 再コンパイルのオーバーヘッドを避ける。
  • コンテキスト再利用: 毎回作成せず、同一コンテキストを使い回す。
  • サイズの最小化: コンテキストに含めるグローバル変数を最小限にする。
  • ワーカースレッドの活用: CPU 負荷の高い処理を別スレッドに移す。