Omoikaneの開発中に、ページのJavaScriptから内部の__omoikane_*関数が見えていることにたまたま気づいた。そこからコードを調べ、別のDocumentやNodeのIDを渡すと、同一オリジンポリシーを通らずに別オリジンのDOMやCookie、ストレージへ到達し得る経路を静的解析で確認した。修正前の読み書きを実際に再現するPoCは作っていない。修正後に越境アクセスを拒否することは回帰テストで確認した。

前の記事ではiframeとRealmの関係を書いた。Realmを分ける話を進めていたが、今回見つかった問題は、Realmの外側にあるRustのホスト関数との境界だった。修正はこのコミットに入り、v0.4.0に含まれている。

問題になった経路

OmoikaneはJavaScriptの実行にBoaを使い、DOMなどのブラウザ機能をRust側で扱っている。JavaScriptのdocumentやNodeにはラッパーがあり、例えばinnerHTMLの取得は、ラッパーからRustの関数へNode IDを渡し、Rust側のNodeをシリアライズして返す。CookieやlocalStorageでも、対象のDocumentを特定してからRust側の状態を読む。

これらの内部関数は、ページに公開するためのAPIではない。DOMのJavaScript実装とRustの実装をつなぐ部品だ。しかし修正前は、各RealmのglobalThisへホスト関数を登録していた。DOMの初期化スクリプトがそれを取り込み、一部の名前は初期化後にグローバルから削除していたが、登録した関数をすべて消していたわけではない。ページのスクリプトから直接参照できる内部関数が残っていた。

問題は関数が見えることだけではなかった。ホスト関数の中には、渡されたDocument IDやNode IDをRust側の共有状態から探し、そのまま処理するものがあった。呼び出したスクリプトのDocumentと、指定された対象のDocumentが同じオリジンかどうかを、ネイティブ側で調べていなかった。

通常のDOM操作なら、異なるオリジンのiframeに対してcontentDocumentを取得できないなど、JavaScript側の境界を通る。しかし内部関数を直接呼べるなら、その境界を通らずにRust側の状態へ届く。DocumentやNodeのIDは内部で使う識別子であって、アクセス権の証明ではない。IDを引数として受け取る入口があるなら、IDがどこから来たかを信用してはいけない。

コード上で確認した影響範囲は、同じOmoikaneのランタイム内にある別オリジンのDocumentに対するDOM、ページのJavaScriptから読めるCookie、localStorageなどだ。HttpOnlyのCookieをページのJavaScriptから読めるという話ではない。別のサイトへネットワーク越しに直接アクセスする話でもない。問題の位置は、複数のDocumentを扱うOmoikane内部のオリジン境界にある。

なぜRealmを分けても防げなかったのか

iframeのDocumentごとにRealmを作ると、それぞれにグローバル環境とArrayなどの組み込みオブジェクトができる。親ページのglobalThisと子ページのglobalThisは同じオブジェクトではない。この分離は、子で定義した変数がそのまま親のグローバル変数にならないことや、どちらのRealmでJavaScriptの値を作ったかを扱うために必要だ。

一方、DOMの実体はJavaScriptのグローバルオブジェクトの中だけにあるわけではない。OmoikaneのRust側にあるHostStateは、親と子のDocument、そこに属するNode、オリジンなどの情報をまとめて管理する。各Realmに置いたDOMラッパーは、その共有された状態へ到達するためにホスト関数を呼ぶ。つまり、Realmは分かれていても、Rust側のNode IDが指す先はRealmごとに独立した別の保管場所ではない。

親をオリジンA、子iframeをオリジンBとすると、通常は親からiframe.contentDocumentを取得しようとしたところで境界に当たる。WindowProxyやDOMラッパーがBのDocumentをそのまま渡さない。しかし、AのRealmのグローバルにも内部ホスト関数が見えていて、その関数が受け取ったIDを共有HostStateから解決するなら、contentDocumentを通る必要がない。関数が「Aから呼ばれた」「対象のIDはBのDocumentに属する」という二つを照合しなければ、JS側で設けた境界の内側へ直接入れてしまう。

修正前の二つの経路を図にすると、次のようになる。点線は、ページから内部ホスト関数を直接呼べた経路だ。

flowchart TB
    subgraph A["Realm A / 親ページ・オリジンA"]
        P["親ページのJS"]
        H["内部ホスト関数<br/>globalThisから参照可能"]
    end
    subgraph B["Realm B / 子ページ・オリジンB"]
        Q["子ページのJS"]
    end
    subgraph R["Rust / 共有HostState"]
        D["Document B / Node B"]
    end
    P -->|通常のDOM操作| W["WindowProxy / DOMラッパー"]
    W --> C{"AとBは同一オリジンか"}
    C -->|いいえ| X["アクセス拒否"]
    P -.->|修正前: 直接呼べた| H
    H -.->|IDで対象を取得| D
    Q -->|自分のDOMラッパー経由| D

二つのRealmを用意しても、右側のRustの状態がRealmごとに別々になるわけではない。点線の経路には、通常のDOM操作で通るWindowProxyの判定がない。修正では点線の入口をページから見えなくし、IDを受け取るRust側でも呼び出し元と対象のオリジンを調べるようにした。

BoaがRealmを作った時点で、OmoikaneのRust関数へ渡る整数の意味や、その整数が指すNodeのアクセス権まで自動で判定してくれるわけではない。ホスト関数から見ると、引数はまずJavaScriptの値であり、Rust側でIDとして解釈して初めて対象が分かる。IDが有効なNodeを指していることと、現在の呼び出し元がそのNodeを触ってよいことも別の条件だ。ここを分けずに「IDが引けたから処理する」とすると、Realmをいくつ用意しても同じ問題が残る。

また、Realmの違いとオリジンの違いは一対一ではない。同一オリジンの親子iframeは別Realmでも互いのDocumentを参照できる場合がある。反対に、同じBoaのランタイムとHostStateを使っていても、異なるオリジンなら読めないデータがある。Realmを単位に丸ごと許可・拒否するのではなく、呼び出し元RealmからDocumentを特定し、対象NodeまたはDocumentの所属先とオリジンを比較する必要があった。

修正前は「ページから見える内部関数」と「IDを受け取った後にオリジンを調べない処理」が重なっていた。前者を隠せば通常のページからその入口へ進めなくなるが、後者を放置すれば別の内部経路ができたときに再発する。逆にRust側だけを守っても、ページに不要な内部関数を公開し続けることになる。この二つを別々に直した理由は、Realmの分離だけではどちらの責務も果たせないからだ。

ホスト関数をページのグローバルに置かない

最初の修正は、内部関数の渡し方を変えることだった。旧実装のregister_host_bindingsでは、Boaのregister_global_callableやregister_global_propertyを使って、DOMの初期化に必要な関数やDocument IDをグローバルへ登録していた。これでは「初期化が終わったら削除する」という別の処理に公開範囲の管理を任せることになる。関数を追加した場所と削除する場所が離れているので、片方だけ増やすと内部名がページに残る。

修正後はBootstrapBindingsに関数と値をまとめ、Omoikane自身のDOMブートストラップモジュールだけへ渡す。モジュールローダーは、そのモジュールレコードを内部用として登録しておき、対応するimport.metaの初期化時に限ってバインディングを渡す。ページが読み込む普通のJavaScriptモジュールには渡さない。

ブートストラップ側は、受け取った値をモジュール内のローカルな名前に展開して使う。globalThisのプロパティと、モジュールの字句スコープにある名前は違う。ページが同じ名前のグローバルプロパティを作っても、内部コードが使う束縛は置き換わらない。値を受け取るために使った一時的なimport.metaのプロパティも、取り出した直後に削除する。

既存のDOMブートストラップにはglobalThis.__omoikane_*を参照する箇所が多数ある。今回の実装では、登録済みの内部名について、その参照をモジュール内の名前へ置き換えたソースを評価する。旧来の「グローバルから削除する」文も、そもそもグローバルに置かないので、そのまま実行する必要がなくなった。新しい保存場所へ一度に移すための処理だ。

ページのグローバルへ置かなくなった関数は、初期化の途中でGCに回収されないようにする必要がある。HostStateはブートストラップ用のオブジェクトを一時的に保持し、トレース対象に含めている。モジュールのload、link、evaluateが終わると、その一時的な登録を片付ける。初期化時には、内部関数名がページのグローバルにown propertyとして残っていないことも検査する。

ページのスクリプトから内部関数を参照できなくするのは重要だが、それだけで完了にはしなかった。今後別の経路から関数へ到達できたとしても、渡されたIDだけで別オリジンのデータを返さないようにする必要がある。

Rust側でもオリジンを確認する

もう一つの修正は、DocumentやNodeのIDを受け取るネイティブ側の入口にオリジン検証を入れることだ。ensure_same_origin_documentは呼び出し元Documentと対象Documentのセキュリティオリジンを比較する。ensure_same_origin_nodeは対象Nodeが属するDocumentを求め、同じ判定へ進む。異なるオリジンや、判定に必要な情報が欠けた場合はSecurityErrorにする。

呼び出し元を決めるところには少し注意が要る。ネイティブ関数が実行された瞬間のcontext.realm()だけを見ると、別Realmで作られた関数を呼んだ場合などに、操作を始めたDocumentを取り違える可能性がある。実装ではBoaのcaller_realm()、実行中のスクリプトまたはモジュールのRealm、現在のRealmという順に見て、そのRealmに記録されたDocument IDを使う。明示的な呼び出し元RealmにDocument IDがない場合は、都合のよいIDを別の場所から補わず拒否する。

対象Documentのオリジンが取得できない場合も許可しない。ここはOptionをそのまま比較するだけでは危ない。両方の記録が欠けていると、RustではNone == Noneが真になるからだ。修正コードは両側が実際にオリジンを持つ場合だけ比較する。iframeやpopupにオリジン情報が欠けた状態を作り、Documentが見えてしまわないこともテストしている。

Cookieのgetterとsetterは、Document IDを受け取った直後にこの判定を通る。ストレージも、共通の引数処理でDocumentを確認してから保存先を求める。innerHTMLなどNode IDを受け取るDOM操作では、対象Nodeの所属Documentを確認してから、Rust側のNodeを読んだり書いたりする。フォーム送信やdocument.write()のように値を返さない操作でも、別オリジンのDocumentを変えられるなら同じ問題になるため、対象のIDを確認する。

ここで使うセキュリティオリジンは、Cookieやストレージの保存先を決める情報と完全に同じものではない。OmoikaneではDocumentSecurityOriginが通常のタプル型オリジンと不透明なオリジンの識別子を表す。StorageOriginはスキーム、ホスト、ポートを持ち、保存先の管理に使う。まず触ってよいDocumentかを判定し、その後でCookieやストレージの処理へ進む。URL文字列だけで一括して判断する形にはしていない。

detached nodeと古いDocument

Nodeの所属先を調べるとき、親をたどってDocumentまで行けるとは限らない。document.createElement()で作った直後のElementは、まだツリーに接続されていない。それでも生成元のDocumentはある。lifetime ownerとして所有元を記録する仕組みは既にあったが、detached nodeを最初にラップする時点で記録が欠け得た。今回の修正ではその記録を補強し、ツリーにいないNodeのオリジン判定にも使えるようにした。

Nodeを保持する処理や、所有者を付け替える処理も見直した。retain_node_nativeは保持するNodeのオリジンを確認し、set_owner_nativeは対象Nodeと新しい所有元Documentの両方を確認する。DOMのgetterだけを守っていても、参照や所有関係を変更する内部関数が別オリジンのIDを受け付ければ、まだ経路が残るからだ。この部分は最初の修正に続いて追加した。

iframeがナビゲーションした後にも、古いDocument由来の関数やDOMラッパーをJavaScriptが保持していることがある。新しいページへ移った瞬間に、古いRealmへの参照がすべて消えるわけではない。修正後はRealmに作成時のオリジンを記録し、古いDocumentの通常の記録が片付いた後でも、そのRealmからの呼び出し元を判定できるようにした。保持された古いDocumentのオリジンも別途残し、対応する参照がなくなったときに整理する。

このあたりは単に「古いDocumentなら全部拒否する」では済まない。同一オリジンの古いラッパーを使える場合がある一方で、古い記録がなくなったことを理由に別オリジンへのアクセスを許してはいけない。NodeやRealmの寿命に合わせて、判定に必要な出自を残す必要があった。

内部処理も通り道を変える

今回、デバッグで特に時間がかかったのは、オリジン検査を入れた後の失敗だった。別オリジンへの不正な操作が止まるのは期待どおりだが、DOMの初期化や子iframe自身の操作まで止まればブラウザとして動かない。テストが落ちたとき、検査を緩めれば通るのかではなく、その操作が誰のDocumentに対して行われているのかを一つずつ確認した。

最初はホスト関数をglobalThisから外した段階で、ページのDOM初期化と子iframeの初期化が失敗した。初期の検証では、内部関数がページから見えないことを確かめるテストも、DOMを作る途中のJavaScriptエラーで止まっている。ブートストラップ自身も、以前はグローバルにある関数を使っていたからだ。単純に名前を消すだけでは、内部コードがRustの関数へ届かなくなる。

そこで渡し先をブートストラップモジュールのimport.metaに限定した。ここでも、モジュールが値を取り出すまで関数を保持できているか、親だけでなく子iframeのモジュールにも正しく渡るか、初期化後に一時的な登録が残らないかを確認した。内部関数を公開しないことと、初期化に必要な間だけ生かすことを両立させる必要があった。

次にNodeの出自を確認し始めると、detached nodeで誤判定が出た。document.createElement()で作ったばかりのNodeには親がないので、親をたどって所属Documentを探すだけでは足りない。デバッグ中には、子ページが自分で作ったNodeを操作しているのに、別オリジンのNodeとしてSecurityErrorになった。呼び出し元は子ページなのに、対象Nodeの所有元が親Documentとして扱われていた。既存のlifetime ownerの記録を、detached nodeを最初にラップする時点から使えるように補強したのはこのためだ。

iframeが別のページへ移った後も似た問題がある。古いDocumentのラッパーやRealmへの参照を保持している間に、通常のDocument記録だけが片付くと、その呼び出し元のオリジンを判定できなくなる。情報がないまま許可するのは危険だが、一律に拒否すると、同一オリジンで許される操作まで壊す。古いRealmとDocumentの出自を必要な間は保持し、現在のDocumentとは別に判定するようにした。

さらに、ホスト関数に検査を通すと、Omoikane内部のiframe処理にも影響が出た。履歴やreload、フォーム送信では、親側の内部処理が別オリジンの子Documentを一度ラップして、そのラッパーからURLや状態を読んでいた。以前は動いていた経路だが、ここに検査を追加すると、子ページのreload中のwrapNodeがSecurityErrorになった。履歴の更新結果が空になったり、フォーム送信のテストがタイムアウトしたりもした。

この失敗を「内部処理だから例外」として許すと、IDを渡せば子Documentのラッパーを得られる経路が残ってしまう。修正では、子Documentへアクセスできる場合だけラップし、できない場合はナビゲーションに必要なURLや状態をDocumentラッパーを介さず渡すようにした。フォーム送信、可視状態の変更、iframeから離れる処理も同じ観点で見直した。ページからの越境アクセスを止めるだけでなく、ブラウザ内部の正当な越境処理からも、不要なDocumentラッパーへの依存を外した。

postMessageも、最初の判定は厳しすぎた。不透明オリジンのiframeでは、通常のスキーム・ホスト・ポートの組で比較できない。しかし、そのiframeが既定のtargetOriginで自分自身に送ったメッセージまで届かなくなった。修正後は、既定値の"/"で同じDocumentへ送る場合と、異なるDocumentへ送る場合を分けている。後者は送信元と対象のセキュリティオリジンが両方あるときだけ比較する。"*"や明示的なオリジン文字列も、それぞれの条件で判定する。

送信元オリジン自体は、JavaScript側が組み立てた文字列を信用せず、Rust側にある送信元Documentの記録から求める。別オリジンへメッセージを送れることはpostMessageの機能なので、送信を全部拒否するのではなく、送信元の偽装を防ぎつつ、自己宛てを含む正当な配送を残す形になった。

検証したこと

追加したテストでは、ページのスクリプトと異なるオリジンのiframeから、代表的な内部ホスト関数が見えないことを確認した。ページのモジュールがimport.metaからブートストラップ専用のバインディングを取れないことも見ている。その一方、通常のDOM操作や自分のCookie、ストレージは使える。内部のDocument IDと同名のグローバルプロパティをページが作っても、ブートストラップが扱うDocumentは入れ替わらない。

別のテストでは、ラッパーに異なるオリジンのNodeやDocumentのIDが渡されたとき、DOMの読み取りやツリーの探索、Cookieとストレージの操作、Documentへの書き込みがSecurityErrorになることを確かめた。detached nodeや、Nodeの保持・所有者の変更も対象にした。これはページ向けの使い方の例ではなく、IDを受け取るネイティブ境界が呼び出し元を信用しすぎていないかを見るテストだ。

同時に、同一オリジンのiframeは通常どおり参照でき、異なるオリジンへ移ればcontentDocumentが見えなくなることも確認している。WindowProxyという窓口は残り、許されたpostMessageやナビゲーションを使える。境界を守るために全部を拒否するのではなく、使える操作と使えない操作を分ける。

修正時の検証記録では、通常のcargo test --locked -- --test-threads=4とignored testを含む実行で、それぞれ61グループが通った。前者はライブラリテスト2682件、後者は2710件で、どちらもdoc-testは11件。JIT differential testは3件、Web API surface testは3件通過した。固定したWPTのスモークテストは293件成功し、退行は0件だった。印刷ページのWPT reftest、cargo build --locked、Rustのフォーマット検査も通している。これらはGitHub Actionsの結果ではなく、修正時に手元で実行した結果だ。

テストで確認した経路についての回帰を防ぐことと、将来増えるすべてのホスト関数が自動的に安全になることは違う。内部関数が新しくIDを受け取るなら、ページのグローバルに露出しないことに加えて、そのIDが属するDocumentと呼び出し元のオリジンを確かめる必要がある。

おわりに

今回の問題は、iframeのWindowProxyだけを見ていても分からなかった。ページに見えていた内部ホスト関数と、IDを受け取るRust側の入口を合わせて見ると、別オリジンのDocumentへ進める経路があった。

修正では内部関数をブートストラップだけへ渡し、Rust側でも呼び出し元と対象のオリジンを照合した。さらに、detached node、保持された古いDocument、内部のiframeナビゲーションやフォーム送信など、同じ境界に触れる処理を洗い直した。Realmを分けるだけでなく、共有状態に届く最後の入口で対象の出自を確かめる必要がある、というのが今回の実装で残った教訓である。