第三者のJavaScriptをセキュアに実行するには?― exaBase Studio のプラグイン機能
メリークリスマス🎄 Studio Application 開発グループの吉田です。
エクサウィザーズのAdvent Calendar 25日目、最終日です!
exaBase Studio に Figma みたいなプラグイン機能を入れたい
これはけっこう前からずっと頭の片隅にあったアイデアでした。

exaBase Studio(エクサベース スタジオ)はアプリケーション開発基盤ですが、Figma のようなキャンバス画面を持っています。キャンバスを読み書きできるプラグインをユーザー自身が自由に開発できれば、Studioの機能を拡張することができます。
exaBase Studio とは
exaBase Studio は、エクサウィザーズが提供するクラウドネイティブな AI サービス開発環境です。GUI でシステム設計ができ、ワンクリックで Kubernetes ベースの本番環境を自動構築できます。LLM Chat UI や RAG などのテンプレートも用意されており、小規模に試しながら段階的に改善していける「インクリメンタルな AI ソフトウェア開発」を実現します。
プラグインのアイデア自体はいくらでも出てきまして、ぱっと思いつくだけでもこんな感じです。
- キャンバスのデータを GitHub にエクスポート / インポートして、「Canvas as Code」なワークフローを実現するプラグイン
- キャンバス上のノードやエッジを解析して、設計がベストプラクティスに沿っているかチェックするプラグイン
- キャンバスのデータから自動的にドキュメントを生成するプラグイン
- キャンバスのデータを LLM に渡して、対話しながらキャンバスをどんどん更新していくプラグイン
…などなど。想像し始めるとキリがありません。
最大の課題:セキュリティ
プラグイン機能を実装するうえで、もっとも大事にしなければならないのが セキュリティ です。
プラグインは、ユーザー自身や第三者が書いたコードをそのまま実行します。つまり、悪意のあるコードが紛れ込む可能性を常に考えないといけません。
もしプラグインコードが Studio App のグローバルオブジェクトや IndexedDB、ネットワークリクエストなんかに普通にアクセスできてしまうと、ユーザーのデータが漏れたり改ざんされたりするリスクがあります。
そのため、プラグインコードは 「強力にサンドボックス化された環境」 で動かす必要があります。
とはいえ私はセキュリティの専門家というわけではありません。最初から「この技術を使えばいけるかな」という確信があったわけでもなく、まずやったのは地道な調査でした。
- サンドボックス環境を作る一般的な方法を調べる
- すでにプラグイン機能を持っている有名なアプリケーションが、どうやって実現しているのかを調査する
ここから、試行錯誤の旅が始まります。
方法1: iframe サンドボックス
まず最初に検討したのは、もっとも一般的な iframe を使う方法 です。
iframe はブラウザ標準の機能で、sandbox 属性を使うことで、細かく権限を制御できます。
さらに、プラグインによっては UI を持つものもあるので、実際には「コード用 iframe」と「UI 用 iframe」の二段構えになります。
構成イメージはこんな感じです。ホストアプリと各 iframe は postMessage によるメッセージだけでやり取りし、それぞれオリジンを分けておくことを意識しています。
ホストアプリとプラグインコードは postMessage を使って通信します。
postMessage とは
ブラウザには、タブや iframe など「別のウィンドウ(Window オブジェクト)」とメッセージを送り合うための仕組みとして window.postMessage() が用意されています。
- 送り手:
otherWindow.postMessage(message, targetOrigin)でメッセージを送る - 受け手:
window.addEventListener("message", (event) => { ... })でメッセージを受け取る -
targetOriginを"https://example.com"のようにきちんと指定しておくことで、意図したオリジン以外にメッセージが届かないようにできる
postMessage 自体は同期的に呼び出されますが、相手側で処理して返事を返すまでには時間がかかるので、「メッセージを投げておき、結果はあとから別メッセージで返ってくる」という非同期な通信モデルになります。
ホストアプリ側では、postMessage をラップして Promise を返すユーティリティを用意しておきます。getCanvas であれば、だいたい次のようなイメージです(簡略化)。
// Host App 側の擬似実装(簡略化)
// iframe に「canvas を返して」と依頼し、レスポンスを Promise で待つ
function createPluginApi(pluginIframe: HTMLIFrameElement) {
let requestId = 0;
const pending: Record<number, { resolve: (v: unknown) => void }> = {};
window.addEventListener("message", (event) => {
if (event.origin !== new URL(pluginIframe.src).origin) return;
const { id, type, payload } = event.data ?? {};
if (type === "getCanvas:response" && pending[id]) {
pending[id].resolve(payload.canvas);
delete pending[id];
}
});
return {
getCanvas(): Promise<unknown> {
const id = ++requestId;
const promise = new Promise<unknown>((resolve) => {
pending[id] = { resolve };
});
pluginIframe.contentWindow?.postMessage(
{ id, type: "getCanvas:request" },
new URL(pluginIframe.src).origin,
);
return promise;
},
};
}
プラグインコードの雰囲気はこんな感じです。
// プラグインコードの例(iframe サンドボックス版)
// 非同期 IIFE で囲む必要がある
(async () => {
// 読み取り(非同期)
const canvas = await studio.getCanvas();
console.log("Nodes count:", canvas.nodes.length);
// 書き込み(非同期)
await studio.addNode({
id: "new-node-1",
type: "sticky",
position: { x: 100, y: 100 },
data: { text: "Hello from plugin!" }
});
})();
ここでのポイントは、
- プラグインコードは 非同期 IIFE(即時実行関数)として書く必要がある
- すべてのプラグイン API が
asyncになる(postMessageの呼び出し自体はすぐ返りますが、結果は別のmessageイベントで届くため、Promise で待つしかない)
というところです。
これは 開発者体験としてはあまり嬉しくありません。
単にキャンバスのデータを触りたいだけなのに、毎回 async/await を書くのは面倒ですし、コードも冗長になります。すぐに直感的にこれはダメだと思いました。
でも、「開発スケジュールもそれほどないのでまずはこの形式でリリースするか...」と、この時点では考えていました。
方法2: Web Worker
次に試したのが Web Worker です。
(詳しい方なら最初から Web Worker は候補に入れないかもしれませんが、私はそこまで詳しくなかったので無駄な調査でしたがご理解ください)
Web Worker とは
ブラウザのメインスレッドとは別のスレッドで JavaScript を実行できる仕組みです。
-
new Worker("worker.js")で別スレッドを起動する - Worker 内では
onmessage/postMessageでメインスレッドとメッセージをやり取りする - DOM にはアクセスできない一方で、
fetchやIndexedDBなど一部の Web API は利用できる
といった性質があり、重い計算処理を UI スレッドから切り離す用途でよく使われます。
Web Worker はメインスレッドとは別スレッドでコードを実行できるので、UI スレッドをブロックしないという大きなメリットがあります。さらに、DOM にはアクセスできないので、セキュリティ的にも良さそうに見えました。
しかし、調査を進めていくうちに、Web Worker は完全なサンドボックスにはならない ことがわかってきました。
- IndexedDB にアクセスできる
→ ホストアプリと同じオリジンの IndexedDB に保存されているデータを読まれる可能性 -
fetchが普通に使える
→ ユーザーの認証情報を外部に送信されてもおかしくない -
importScripts()で外部スクリプトを読み込み可能
→ 後から悪意のあるコードを注入できる - グローバルスコープ(
self)に API がたくさん生えている
→ 完全にコントロールするのが難しい
iframe の sandbox のように「この権限はOK、これはダメ」という細かい制御ができないので、「完全に安全なサンドボックス」と言い切るには追加の工夫が必要 になります。
そして、結局 postMessage でホストと通信しなければいけない点も、iframe と同じです。
方法3: SES (Secure ECMAScript)
もう少し別のアプローチがないかと思い、オープンソースのデザインツール Penpot を調査してみました。
Penpot も Figma のようなプラグイン機能を持っていて、コードベースを追いかけていくと、SES(Secure ECMAScript) という仕組みを使っていることがわかりました。
SES とは
SES についてざっくりいうと、
- JavaScript の世界を「安全なサブセット」に絞り込んで
- その中に「隔離された実行環境(Compartment)」を作る
というものです。
コアになるのは次の 2 つです。
-
lockdown()
グローバルオブジェクト(Object/Array/Function/Proxyなど)を凍結して、プロトタイプの書き換えなどを防ぐ。 -
Compartment
いわゆる「サンドボックス」。必要な API だけをホワイトリストで渡してコードを実行できます。
SES をメインスレッド上で使うことで、プラグインコードにも 同期 API をそのまま提供できるようになります。postMessage 不要!(SES 自体は「メインスレッド専用」というわけではなく、隔離された評価環境を提供する仕組みです)
メインスレッドで動かすということにはデメリットもあり、UI スレッドがブロックされるリスクがあります。例えば、無限ループに陥ったプラグインコードがホストアプリをフリーズさせてしまう可能性があります。
しかし、プラグインAPIはユーザーが明示的に呼び出すものであり、言い換えればユーザーの代わりにキャンバスを操作するものですので、同期操作であることは自然ともいえます。
それに、Figma や Penpot でも採用されているアプローチですので、フリーズのリスクはあるにせよ、それほど心配していませんでした。
SES を試してみたが…
結論からいうと、exaBase Studio には使用できませんでした。
lockdown() を呼び出すと、ホストアプリ側のコードにも影響が波及してしまい、普通に動いていた部分がエラーを吐き始めました。
exaBase Studio は内部で Proxy をかなり活用していて、lockdown() によってグローバルオブジェクトやそのプロトタイプが凍結され、それを前提にしていない Proxy ベースの実装と衝突し、既存のコードが正しく動かなくなってしまったのです。
Proxy とは
JavaScript のオブジェクトに対する操作(プロパティの読み書き、in 演算子、new など)を横取りして、自分のロジックを差し込める仕組みです。
const target = {};
const proxy = new Proxy(target, {
get(obj, key) {
console.log("get", String(key));
return obj[key];
},
});
proxy.foo; // => get "foo" がログに出る
「既存コードを SES 対応に書き換える」という選択肢もゼロではないですが、現実的ではありません。
結果として、SES は「アイデアとしてはすごく良いけど、今の exaBase Studio には入れられない」という判断になりました。
方法4: QuickJS(最終的な採用)
最終的にたどり着いたのが、QuickJS を使う方法です。
これは Figma でも使われていると言われているアプローチで、「JS エンジンごとサンドボックスにしてしまおう」という発想です。
QuickJS とは
QuickJS は小型で高速な JavaScript エンジンです。C で実装されていて、WebAssembly(WASM)にコンパイルすることで、ブラウザ上でも実行できます。
特徴をざっと挙げるとこんな感じです。
-
ホスト JS から隔離された実行環境
QuickJS は WASM 上の独立したメモリ空間で動作し、ホスト側の JavaScript のグローバルオブジェクトや変数には直接アクセスできない -
ECMAScript 2020 準拠
モダンな JavaScript 構文がひと通り使える -
サイズが小さい
WASM ファイルはだいたい 500KB 前後 -
同期実行ができる
メインスレッド上で同期的に動かせる
イメージとしては、だいたい次のような構成です。
という構成になっています。
最初は QuickJS を採用することに懸念がありました。
バンドルサイズが増えることや、QuickJS 本体のアップデートに継続的に追従していく必要があることなどです。
ただ、それを上回るメリットがありました。
-
同期 API を提供できる
SES と同じくメインスレッドで動いているので、studio.getCanvas()のような同期 API が実装できる。 -
既存コードに影響しない
SES のlockdown()と違って、ホスト側のグローバルオブジェクトを書き換えない。 -
ホスト JS から強く隔離されている
WASM の独立したメモリ空間で動くので、ホスト側の JavaScript に直接触ることはできない。
実際に組み込んで動かしてみたところ、既存コードには影響がなく、期待通りに動いてくれました。
プラグイン側のコードも、かなり素直な形になります。
// QuickJS 版のプラグインコード(同期API)
// 読み取り(同期)
const canvas = studio.getCanvas();
console.log("Nodes count:", canvas.nodes.length);
// 書き込み(同期)
studio.addNode({
id: "new-node-1",
type: "sticky",
position: { x: 100, y: 100 },
data: { text: "Hello from plugin!" }
});
この「同期で書ける」というだけでも、プラグイン開発者のストレスはかなり減るはずです。
車輪の再発明:標準 API の実装
QuickJS には、実は一つ大きなクセがあります。
それは、console.log や setTimeout などの「当たり前にあるはずの API」が最初は存在しない という点です。
最初これを知ったときは「えっ、そこから実装するの?」という正直な戸惑いがありました。
ただ、理由を知ると納得できます。QuickJS は「純粋な ECMAScript エンジン」として設計されていて、
- ECMAScript 仕様に書かれているものだけを提供する
- それ以外(
console/setTimeout/fetchなど)は「ホスト環境」が責任を持って提供する
という思想になっています。
(これはブラウザや Node.js でも同じで、setTimeout や console は ECMAScript 仕様外の「ホスト API」です)
このおかげで、必要な API だけをホワイトリストで渡せる ので、セキュリティ的にはむしろ有利です。
たとえば、fetch を渡さなければ、QuickJS サンドボックス内のプラグインコードからはネットワークアクセスは一切できません。
とはいえ、console.log もない世界でプラグインを書くのはさすがにつらいので、「これはもう腹を括って実装しよう」と決めました。
// console の実装例:
export function registerConsole(context: QuickJSContext): void {
const consoleHandle = context.newObject();
const methods = ["log", "warn", "error", "info", "debug"] as const;
for (const method of methods) {
const fn = context.newFunction(method, (...args) => {
// QuickJS の値をホスト環境の値に変換
const values = args.map((arg) => context.dump(arg));
// ホストの console に出力(プレフィックス付き)
console[method]("[Plugin]", ...values);
});
context.setProp(consoleHandle, method, fn);
fn.dispose(); // メモリリーク防止
}
context.setProp(context.global, "console", consoleHandle);
consoleHandle.dispose();
}
こうした細かい「車輪の再発明」は正直それなりに大変ですが、そのぶん 何をプラグインに許可し、何を禁止するか をコードレベルでコントロールできるので、結果的にはやってよかったなと感じています。
ネットワークアクセスの設計
現時点では、サンドボックスでは fetch を実行できません。
つまり、プラグインのメインコード側からは、ネットワークに直接アクセスする手段がそもそも存在しません。
では、「外部 API と連携するプラグイン」はどうやって作るのか?
答えは、UI iframe を経由する という設計です。
UI iframe はブラウザの通常環境で動いているので、fetch を含むブラウザ API がそのまま使えます。
メイン側(QuickJS)と UI 側(iframe)のコードを分担すると、だいたいこんなイメージになります。
// main.js (QuickJS サンドボックス内)
studio.ui.open("./ui.html");
studio.ui.onmessage = (message) => {
if (message.type === "apiData") {
console.log("Received data from UI iframe:", message.data);
}
};
<!-- ui.html (iframe 内) -->
<!DOCTYPE html>
<html>
<body>
<button id="fetchBtn">Fetch Data</button>
<script>
document.getElementById("fetchBtn").addEventListener("click", async () => {
// iframe 内では fetch が使える
const response = await fetch("https://api.example.com/data");
const data = await response.json();
// プラグインコードにデータを送信
parent.postMessage({
pluginMessage: { type: "apiData", data }
}, "*"); // 実際には適切なオリジンを指定するべきです
});
</script>
</body>
</html>
この構成にしておくと、
- ネットワークアクセスできるのは UI iframe だけ
- メインのプラグインコードは、UI から渡されたデータをもとに Canvas を更新するだけ
- 通信は基本的に、ユーザーの明示的な操作(ボタンを押すなど)がトリガーになる
という形になり、「勝手にどこかへデータを送り続ける」ようなプラグインを作りづらくなります。
しかし、将来的には fetch API をサンドボックスに提供したいと思っています。
慎重に fetch を実装し、プラグイン開発者の利便性を高めていきたいと考えています。
まとめ
ここまで、いろいろな方法を試しては断念しつつ、最終的に QuickJS に落ち着くまでの流れをざっと書いてきました。
検討した方法をあらためて整理すると、こうなります。
| 方法 | 隔離レベル | API | 既存コードへの影響 | 採用 |
|---|---|---|---|---|
| iframe | ○ 強い | 非同期 | なし | × |
| Web Worker | △ 中程度 | 非同期 | なし | × |
| SES | ○ 強い | 同期 | あり(Proxy 凍結の影響) | × |
| QuickJS | ◎ 非常に強い | 同期 | なし | ✓ |
QuickJS を採用したことで、現時点で得られているメリットはだいたい次のとおりです。
-
セキュリティ
WASM サンドボックスにより、ホスト環境への直接アクセスを原則として遮断できる。 -
開発者体験
同期 API が使えるので、プラグインのコードがシンプルで読みやすい。 -
互換性
SES のlockdown()のようにホスト側のグローバルを書き換えないため、ホストアプリ側の既存コードの実行に影響を与えません。
今後は、
- プラグイン API をもっと充実させる
- ドキュメントやサンプルを整備して、プラグインを書き始めやすくする
- 実際のユーザーや社内メンバーからのフィードバックを反映していく
といったところに力を入れていく予定です。
Discussion