WebAssemblyは、サンドボックス化された実行環境として設計されている。線形メモリの外には触れられず、ファイルもネットワークも見えない。安全ではある。ただ、そのままでは計算しかできない。設定ファイルを読むことも、時刻を取ることもできない。
この「何もできない」状態から、必要な分だけ外の世界とつなぐための仕様がWASI(WebAssembly System Interface)だ。2019年に最初の案が公開され、いまも改訂が続いている。そしてWASIの設計は、単なるシステムコールの寄せ集めではない。権限の渡し方そのものに踏み込んでいる。
WASMとWASIは、層が違う#
まず位置関係を整理しておく。WebAssemblyの仕様そのものには、外の世界に触れる手段が一切定義されていない。数値を計算し、線形メモリを読み書きし、ホストが渡した関数を呼ぶ——それだけだ。2019年12月にW3Cの勧告となったWebAssembly 1.0の仕様書を最後まで読んでも、ファイルを開く命令も通信する命令も出てこない。
だからブラウザーでWASMを使うときは、JavaScript側が必要な関数を用意してモジュールに渡す。「ホストが渡した関数」の中身をJavaScriptで書くことになる。
ブラウザーの外でWASMを動かすなら、この「ホストが渡す関数」に共通の取り決めが要る。ランタイムごとにファイルの読み方が違えば、同じバイナリがどこでも動くという利点が失われるからだ。その取り決めがWASIだ。WASMが実行の仕様で、WASIがその外側との境界の仕様、という層になっている。
ambient authorityという前提#
通常のOSでプロセスを起動すると、そのプロセスはユーザーの権限を丸ごと引き継ぐ。ホームディレクトリの中身は読めるし、/etcも見えるし、外部への通信もできる。プログラムがopen("/etc/passwd")と書けば、それが通る。
この、プログラムが名前を書くだけで到達できてしまう権限をambient authority(周囲権限)と呼ぶ。プログラムに与えたのではない。環境に最初から漂っている権限だ。
何が問題か。プログラムが実際に何にアクセスするのかが、コードを全部読むまで分からない。依存ライブラリの奥深くでfs.readFileSyncが呼ばれていても、外からは見えない。「このプログラムはどのファイルを読むか」に答えるには、静的解析か、実行して観察するしかない。
Capability-based security — 渡したものしか使えない#
これに対する考え方がcapability-based security(ケーパビリティベースのセキュリティ)だ。
原則は単純だ。リソースへのアクセスは、明示的に渡されたハンドルを通してのみ行える。プログラムは名前を書いて何かに到達することができない。呼び出し側から渡された「開いた状態のもの」だけを使える。
ファイルディスクリプタが分かりやすい例になる。すでに開かれたfdを渡されたプログラムは、そのfdが指すものにしかアクセスできない。パス名から新しくファイルを開くことはできない。渡していない権限は、行使する手段がない。
WASIはこの原則を採用している。WASMモジュールは、ホストから渡されたcapabilityの範囲でのみ外の世界に触れる。
preopen — 開いたディレクトリしか見えない#
WASIの実装で、この原則が最もはっきり見えるのがpreopenだ。
ホストは、WASMモジュールを起動する前に「このディレクトリを使ってよい」と指定する。wasmtimeなら--dirフラグで渡す。
wasmtime --dir=./data app.wasm
モジュールから見えるのは、渡された./dataの中だけだ。../で上に抜けようとしても、/etc/passwdを絶対パスで開こうとしても通らない。パスの解決がホスト側でpreopenされたディレクトリを起点に行われるため、その外は名前として存在しないからだ。
重要なのは、これが実行時の設定で決まることだ。モジュール側のコードは変えなくていい。渡すディレクトリを変えれば権限が変わる。「このプログラムは何を読むか」の答えが、コードではなく起動コマンドに書いてある。監査する側にとって、これは大きな違いだ。
ネットワークも同じ考え方で、ソケットを開く権限はホストが明示的に渡す。何も渡さなければ、モジュールの中に通信のコードがあっても、それは動かない。
出所の分からないコードを動かせる#
この性質が効くのは、自分で書いていないコードを動かすときだ。
プラグイン機構を考えてみる。利用者が書いたスクリプトを自分のサーバーで実行したい。従来なら、コンテナで隔離するか、言語レベルのサンドボックスを作るか、そもそも諦めるかだった。コンテナは起動が重い。言語レベルのサンドボックスは抜け道が絶えない。
WASIなら、preopenを渡さずに起動すればいい。そのモジュールはファイルもネットワークも触れない。計算はできるが、外に何も出せない。安全性がホスト側の設定で保証されるので、モジュールの中身を信頼する必要がない。
EnvoyのWASMフィルタや、各種のプラグインシステムがWASMを採用しているのは、この性質のためだ。配布されたバイナリを、中身を検証せずに実行できる。
Preview 1とPreview 2#
WASIには大きな段階が二つある。
長く使われてきたのがPreview 1(wasi_snapshot_preview1)だ。POSIXに近い関数の集合として定義されていて、ファイル、時刻、乱数、環境変数、標準入出力といった基本的なものが揃っている。多くのツールチェーンがいまも出力先として想定している。
その後に来たのがPreview 2で、2024年に0.2.0として固まった。こちらは作り直しに近い。Component Modelという仕組みの上に再構築され、インターフェースをWIT(WASM Interface Type)という言語で記述し、モジュール同士が型のある境界でつながるようになった。wasi:filesystem wasi:sockets wasi:httpのように、機能ごとに分かれたインターフェースとして定義されている。
実務上の意味は、モジュールが必要とする権限がインターフェースとして明示されることだ。wasi:socketsをimportしていないモジュールは、通信できないと型のレベルで分かる。中身を読まなくても、必要な権限の一覧が取れる。
まだ移行期にある。ツールチェーンによって対応状況が違うので、使う前に対象のランタイムがどちらを想定しているかは確認したほうがいい。
限界#
万能ではない。注意する点が五つある。
ホスト側の設定は運用の約束になる。 preopenに/を渡してしまえば、capabilityの利点は消える。仕組みが保証するのは「渡したものしか使えない」までで、「何を渡すか」は人が決める。
計算資源の制限は別問題だ。 無限ループやメモリの使いすぎは、capabilityでは防げない。ランタイム側の実行時間制限や、メモリ上限の設定が別に要る。
サイドチャネルは範囲外だ。 タイミングを使った情報の推測のような攻撃は、この設計が対象にしていない。
移植は無料ではない。 既存のコードをWASIに載せるとき、open()でパスを直接指定している箇所は書き換えが要る。preopenされたディレクトリを起点にした相対パスに直す、という作業が発生する。ambient authorityを前提に書かれたコードほど、この手間は大きい。
エコシステムの成熟度に差がある。 RustやC/C++ はターゲットとして安定しているが、言語によっては対応が発展途上だ。特にPreview 2への対応状況はまちまちで、採用前に確認が要る。
まとめ#
WASIが持ち込んだのは、システムコールの標準化そのものよりも、権限をコードの外に出したことの方が大きい。
プログラムが名前を書いて到達するのではなく、呼び出し側が渡したものだけを使う。その結果、「このプログラムは何にアクセスするか」という問いに、コードを読まずに答えられるようになる。起動時の設定を見ればいい。
出所の分からないコードを走らせる場面が増えるほど、この性質の価値は上がっていく。