自作ブラウザの
Omoikane
でiframeを扱っていると、何度もRealmの話に戻ってくる。最初は「子フレーム用に別のJavaScript実行環境を作る」くらいの認識だった。しかし、別の環境を作るだけでは終わらなかった。親から子のArrayを参照するとき、iframeが別のページへ移動するとき、子で予約したタイマーが後から動くとき。それぞれ、どのRealmの値を、どのDocumentのために使っているのかを決める必要があった。
以前の記事ではPointer Lockの入力配送中に見つかったRealmの不具合に触れた。今回はその周辺も含めて、そもそもRealmが何を分けているのかを整理しておく。
「Realm」という言葉
英語のrealmには、もともと王国や君主の支配する領土という意味がある。
The American Heritage Dictionaryのrealm
は、中英語のrealmeから古フランス語を経て、ラテン語のregimen(統治)へ遡ると説明している。古フランス語のreial(王の)の影響も挙げている。現在は王国に限らず、知識や活動の「領域」を表す言葉としても使われる。
この意味を知ると、JavaScriptのRealmを「それぞれにグローバル環境と組み込みオブジェクトを持つ領域」と捉えやすい。ただし「王国」の比喩を、そのままセキュリティ上の国境だと思うと間違える。同一オリジンのiframe同士は別Realmでも相互に値を参照できるし、アクセスの可否はRealmという名前ではなく、別の規則で決まる。
ECMAScriptの仕様に、この単語を選んだ理由まで書かれているわけではない。語の由来から仕様策定者の命名意図を断定するのではなく、以下では「領域」という日常的な意味を手掛かりに、仕様上どこに境界があるのかを見る。
Realmとは何か
JavaScriptを使っているとwindowやglobalThisはよく見るが、Realmという名前の値を直接触る機会はあまりない。これはページからnew Realm()のように作るための通常のAPIではなく、ECMAScript仕様が「このコードはどの世界の組み込みオブジェクトとグローバル環境を使うか」を説明するための概念だからだ。
仕様では、実行されるJavaScriptのコードはあらかじめどれかのRealmに関連付けられる。Realmには、そのRealmの組み込みオブジェクト、そのRealmのグローバルオブジェクトとグローバル環境、その環境で読み込まれたコードに関する状態などがある。仕様上はRealm Recordという形で表現される。漠然と「JavaScriptの実行環境」と言うと広すぎるが、少なくともArrayやObjectをどの集合から取るか、グローバルな名前をどの環境で解決するかを決める単位だと考えると、挙動が追いやすい。
flowchart LR
subgraph parent["親ページ"]
PA["Realm A<br/>グローバル環境 A<br/>Array A / Date A"]
end
subgraph child["子iframe"]
CB["Realm B<br/>グローバル環境 B<br/>Array B / Date B"]
end
PA -.->|同一オリジンなら値を参照できる| CB
両方ともJavaScriptのArrayではあるが、Array AとArray Bは同じ関数オブジェクトではない。そのprototypeも別のオブジェクトになる。Realmを増やすとは、単に変数を格納する辞書を一つ増やすことではなく、こうした組み込みオブジェクトのまとまりも作ることになる。
ここで「ではJavaScriptの値はRealmの外へ出せないのか」と考えたくなるが、そうではない。同じオリジンのiframeなら、親から子のオブジェクトを普通に参照できる。別のRealmにある値を受け取ることと、その値が親Realm生まれになることは違う。オブジェクトの出自は変わらないまま、参照だけが境界を越える。
グローバルオブジェクトとRealmは同じものではない
Realmはグローバルオブジェクトを含むが、グローバルオブジェクトそのものではない。globalThisも、Realmを指す変数ではない。たとえばブラウザでは、HTML仕様が新しいRealmを作る際に新しいWindowをグローバルオブジェクトとし、globalThisに相当するglobal thisの束縛にはそのbrowsing contextのWindowProxyを使う。
HTML仕様のWindowとWindowProxyの生成
を見ると、両者が別のものとして指定されている。
flowchart LR
C["コードから見える<br/>window / globalThis"] --> P["WindowProxy<br/>browsing contextの窓口"]
subgraph R["Realm Record"]
E["グローバル環境<br/>名前解決"]
W["グローバルオブジェクト<br/>Window"]
I["組み込みオブジェクト<br/>Array / Object / ..."]
end
P -->|現在のWindowへ委譲| W
普段window === globalThisと書けるので、裏で別のオブジェクトが関わることは意識しない。しかし、iframeがナビゲーションして別のDocumentを読み込む場合には、この二つを分けて考える必要がある。参照していたWindowProxyは同じbrowsing contextの窓口として残り、その裏にあるWindowとRealmは入れ替わる。Realmをwindowという一個の値と同一視すると、この説明ができなくなる。
グローバル環境とグローバルオブジェクトも完全な同義語ではない。グローバル環境は、コード中の名前を解決するための環境だ。トップレベルのletとvarの扱いが異なることからも、グローバルの名前が全部単純なオブジェクトのプロパティになるわけではないと分かる。Realmは、そうした名前解決の土台と組み込みオブジェクトの両方を持っている。
スコープを作ることとRealmを作ることも違う
関数を呼べばローカル変数のスコープができる。ブロックを書けばletやconstの有効範囲ができる。しかし、関数を一回呼ぶたびに新しいArrayコンストラクタが作られるわけではない。同じページのスクリプトに関数をいくつ書いても、通常は同じRealmの組み込みオブジェクトを使っている。
function first() {
const local = 1;
return Array;
}
function second() {
const local = 2;
return Array;
}
console.log(first() === second()); // true
二つのlocalは別のスコープにあるが、返されるArrayは同じだ。「変数が見える範囲」と「組み込みオブジェクトやグローバル環境の所属先」は別の軸になる。
RealmはスレッドやOSプロセスとも同義ではない。親と子のiframeが同期的に互いの関数を呼べる場合、実行はRealmの間を行き来する。一方で、Realmを作ったから別のスレッドで並列に実行される、という約束はない。Omoikaneでも一つのBoaのContextの中に複数の子Realmを持たせている。Rust側のContextを作り直すことと、BoaのRealmを追加することは別の操作だ。
実行中は「現在のRealm」が変わる
Realmはコードの所属先であり、実行コンテキストは「いまどのコードを実行していて、どこへ戻るか」などを追う仕組みだ。 ECMAScript仕様の実行コンテキスト では、実行コンテキストがRealmの情報を持ち、実行コンテキストスタックの一番上にあるものが現在実行中とされる。
親のスクリプトから子iframeで定義した関数を呼び、その関数が親の関数をまた呼ぶことを考える。
親の関数を実行中 -> 現在のRealmは親
子の関数を呼ぶ -> 子の関数を実行する間は子
親の関数を呼び返す -> 呼び返された親の関数を実行する間は親
戻る -> 子の関数へ戻る
戻る -> 最初の親の関数へ戻る
「最初に呼び出したページのRealmを、処理の最後まで使い続ける」わけではない。逆に、現在処理しているDOMノードが子Documentにあるから、そこで呼んだすべての関数が子Realmで動くわけでもない。どの関数のコードを実行するかによって、現在のRealmは入れ替わる。この点が、後で触れるnative continuationの不具合につながった。
ECMAScriptの関数オブジェクトは作られたRealmと結び付いている。親が作ったコールバックを子へ渡しても、子に渡した瞬間に関数が子Realmのものへ変換されるわけではない。呼び出し元、関数の所属、受け取ったオブジェクトの所属が別々になり得るので、「いまのRealm」の一語で全部を決めようとすると破綻する。
同じオリジンでも、同じRealmではない
ECMAScript仕様のRealm
は、Realmを組み込みオブジェクトの集合、グローバル環境、その環境に属するコードなどを含むものとして定義している。ここでいう組み込みオブジェクトには、ArrayやDateのようなコンストラクタも含まれる。
例えば同じオリジンのページにiframeを一つ作る。同一オリジンなので、親のJavaScriptから子のcontentWindowへアクセスできる。それでも、親と子のArrayは一つのオブジェクトを共有しているわけではない。
const frame = document.createElement("iframe");
document.body.appendChild(frame);
const child = frame.contentWindow;
const value = new child.Array(1, 2);
console.log(child.Array === Array); // false
console.log(value instanceof child.Array); // true
console.log(value instanceof Array); // false
「アクセスしてよいか」と「同じ実行環境か」は別の問いだ。オリジンはアクセス制御に関わる。一方、Realmはコードが使うグローバル環境や組み込みオブジェクトの同一性に関わる。これを混ぜて考えると、同一オリジンだから親のArrayを子へ渡してもよい、という実装になりやすい。
Omoikaneでもここはテストにしている。
iframeの組み込みオブジェクトを確認するテスト
では、子のDateやArrayが親のものとは異なることに加え、親からchild.Function(...)で作った関数が子のグローバルを見ることも確かめている。子のグローバルに値を書き込んでも、親のグローバルに同じ名前の値が増えてはいけない。
instanceofがfalseでも配列は配列
上の例ではvalue instanceof Arrayがfalseになる。これは子から受け取った配列が壊れているからではない。通常のinstanceofは、右辺のコンストラクタと、そのprototypeを使って判定する。親のArray.prototypeと子のArray.prototypeは別物なので、親のArrayを右辺に置くと一致しない。
console.log(Object.getPrototypeOf(value) === child.Array.prototype); // true
console.log(Object.getPrototypeOf(value) === Array.prototype); // false
console.log(Array.isArray(value)); // true
Array.isArray()は配列かどうかを判定するので、Realmの異なる配列でもtrueになる。つまり、instanceof Arrayを「JavaScriptの配列である」という意味で使うコードは、iframeから値を受け取ると誤判定し得る。アプリケーションの入力検証でも起きるが、ブラウザを実装する側ではさらに深刻だ。DOM APIが配列を返すとき、誤ったRealmのコンストラクタで生成すると、呼び出し側から見えるオブジェクトの同一性が仕様と違ってしまう。
配列だけではない。子のDateから作ったオブジェクトは、親のDateとは違うconstructorとprototypeを持つ。子のPromiseも親のPromiseと同一の関数オブジェクトではない。typeofだけ見れば同じように見える値でも、prototypeや組み込み関数の同一性を調べると所属先が現れる。仕様が組み込みオブジェクトをRealmごとに作るのは、こういう観測可能な差があるからだ。
この例のArrayやDateはECMAScript側の組み込みオブジェクトだが、documentやDOMノードはブラウザが提供するオブジェクトだ。両方を「子フレームのもの」にするには、言語エンジンのRealmを分ける処理と、ブラウザ側で子Documentへ結び付ける処理の両方が要る。Boaへ子Realmを作ってもらっただけでは、子のdocumentが自動で正しく用意されるわけではない。
オリジンはアクセス権、Realmは所属先
ここは混同しやすかった。Realmが別なら親から子へアクセスできない、というわけではない。同一オリジンのiframeは親とは別Realmを持つが、親はcontentWindow.documentなどへアクセスできる。逆に、クロスオリジンのiframeにも子側でコードを実行する環境はあるが、親から子のグローバルへ自由にアクセスできるわけではない。
同一オリジンの親と子
Realm: 別
DOMへのアクセス: 条件を満たせば可能
クロスオリジンの親と子
Realm: 別
DOMへのアクセス: 制限される
オリジンは一般にscheme・host・portで決まる。同じドメイン名が見えても、スキームやポートが違えば同一オリジンとは限らない。さらにiframeのsandboxの指定によって、URLから想像したオリジンとは異なる扱いにもなる。Realmを一つ作ったという事実だけを、アクセスを許可する根拠にしてはいけない。
Omoikaneの子Realm作成処理も、まず対象Documentの実効オリジンと、それを埋め込むDocumentのオリジンを照合する。sandboxで同一オリジンが許可されているかも見る。data:のようなopaque originを持つ子を、見た目のURLだけから親と同一オリジン扱いしない。この判定結果を使ってparentやtopへ何を渡すかを決め、親のcontentWindow側でもアクセスを制限する。
とはいえ、これをもってOmoikaneがすべてのクロスオリジンの振る舞いを完全に実装した、とは言わない。ここで重要なのは責務の分け方だ。Realmは「どのグローバル環境・組み込みオブジェクトに属するか」を表し、オリジンとsandboxは「境界を越えて何を見せるか」を決める。両方が必要で、片方がもう片方を代用することはできない。
Documentごとに実行環境を用意する
OmoikaneのJavaScriptエンジンはBoaで、子iframeにはContext::create_realm()でRealmを作り、対象のスクリプトを実行するときにenter_realm()で切り替える。iframeのDocument IDをRealmへ結び付け、DOMのバインディングもその環境に登録する。既に同じDocumentのRealmがあれば再利用する。
子iframeのRealmを作る処理
で気を付けているのは、どの段階でRealmを作るかという点だ。単にiframeが存在するだけで何もかも初期化するのではなく、子スクリプトの実行や同一オリジンからのcontentWindowアクセスなど、必要な経路で作る。以前はloadイベントのためだけにスクリプトのないiframeへも子Realmを作る経路があり、そこは不要な初期化を避けるように直した。
Realmを切り替えれば終わり、でもない。子のdocument、parent、top、frameElementをどう見せるかはホストであるブラウザ側の仕事になる。入れ子のiframeなら、子のparentは最上位ページではなく一段上のフレームを指す。子のスクリプトが読むURLや、適用するCSPも、その子Documentに結び付けなければならない。
なお「子Realmを持つ」ことだけでクロスオリジンの安全性が成立するわけではない。親から子へ、子から親へどのプロパティを見せるかは、別途オリジンとsandboxの条件で制御する。 子RealmとWindowProxyの分離を扱ったIssue #494 は、まさにこの二つを一緒に扱った作業だった。
ここで子Document IDをRealmへ結び付けている理由も見えてくる。Realmが別であることだけでは、そのRealmが「現在もiframeに表示されているDocumentのものか」は分からない。ナビゲーションしたあとも、親が古い関数や値を変数に保持していれば、それらへの参照は残り得る。古い値を使えることと、古いDocumentを現役として扱うことを分けるために、OmoikaneはDocumentの同一性と有効性を別に追っている。
また、子のscriptを実行するときは一時的に子Realmへ入り、終了したら元のRealmへ戻る。ここで復帰を忘れると、次に親のコードを実行したときに子のdocumentや組み込みオブジェクトを見てしまう。逆に、早く戻しすぎれば、まだ子で動くべき処理が親へ迷い込む。enter_realm()は便利だが、その呼び出し一つだけで境界管理が終わるわけではない。
contentWindowはページそのものではない
iframeのcontentWindowを変数に保存してから、そのiframeを別のURLへ移動させても、参照の同一性は維持される。一方、移動後に見えているDocumentや組み込みコンストラクタは新しいものになる。
const windowRef = frame.contentWindow;
const oldDate = windowRef.Date;
frame.srcdoc = "<p>new document</p>";
// ナビゲーション完了後:
console.log(windowRef === frame.contentWindow); // true
console.log(windowRef.Date === oldDate); // false
contentWindowを「現在のページのグローバルオブジェクト」そのものとして固定してしまうと、この振る舞いは作れない。
HTML仕様のWindowProxy
では、browsing contextに対応するWindowProxyが、ナビゲーションに応じて背後のWindowを切り替える。
flowchart LR
P["contentWindow<br/>WindowProxy X<br/>参照は同じ"]
A["Window A<br/>Realm A / Document A"]
B["Window B<br/>Realm B / Document B"]
P -->|ナビゲーション前| A
P -->|クロスDocumentのナビゲーション後| B
WindowProxy Xの参照は同じで、参照先のWindowが変わる。これに対して、URLのフラグメントだけ変わるような同一Document内の移動まで、毎回新しいRealmを作る話ではない。ここで言う入れ替えは、別のDocumentを読み込むナビゲーションの話だ。
OmoikaneはcontentWindowを安定したfacadeとして保持し、現在有効なDocumentとRealmに基づいて参照先を更新する。
contentWindowのfacade実装
では、同一オリジンなら子のグローバルへ操作を委譲し、クロスオリジンなら許されたプロパティ以外の参照を拒否する。iframeをDOMから外した場合は古いfacadeを閉じ、再接続後には新しいbrowsing contextとして扱う。
テストでは、移動前のDateから作った値は保持できるが、移動後にwindowRef.Dateを読むと新しいコンストラクタが返ることを確認している。古い値を残すことと、古いページを今も生きているように見せることは違う。現状のOmoikaneのfacadeは必要な範囲を実装したもので、HTML仕様のWindowProxyを完全に再現したとまでは言わない。
この違いは「ナビゲーションしたら全部のJavaScriptオブジェクトを消す」というモデルとも合わない。親が古いDateや古いDocument由来のオブジェクトを保持しているなら、GCから到達できる限り値として残り得る。ただし、その値を使って古いbrowsing contextへ自由に操作を続けられるかは別問題だ。古いRealmの値が残ること、新しいWindowがアクティブになること、古いタスクを止めることは、それぞれ独立に扱わなければならない。
コールバックでは「呼び出し元」だけを見ても足りない
iframeをまたいでイベントを配送すると、関数がどちらのRealmで作られたかによって、参照するグローバル環境が変わる。Omoikaneでは、子Documentに届いた入力イベントから、親Realmで作られたリスナーを呼ぶ経路があった。普通のJavaScript関数呼び出しでは動いていたのに、Rust側のnative continuationを挟むと、親のリスナーが参照するグローバル変数を見失い、ReferenceErrorになった。
原因は「イベントが子に届いたから親の変数は読めない」ことではなかった。BoaがJavaScriptコールバックのフレームを積んだあと、ネイティブ呼び出しの後始末で現在のRealmを無条件に呼び出し元へ戻していた。これから実行するコールバックのRealmまで上書きしてしまう。
子Documentでイベントを配送
-> 親Realmのリスナーを呼ぶフレームを準備
-> native callの後始末でRealmを切り替える
-> リスナーが違うRealmで走り、親の名前を解決できない
native continuationのRealm不具合 #768 では、コールバックが走るRealmを維持し、そのコールバックから戻った後の復帰先を別に保存するように直した。入れ子の呼び出し、例外、中断と再開、GCを挟む場合もテストした。Realmは「今どのiframeを処理しているか」を示す単純なフラグではなく、実際に走る関数と実行コンテキストの関係で決まることがよく分かる例だった。
新しい値をどのRealmで作るのか
関数を正しいRealmで実行できたとしても、次には「その関数が返す新しいオブジェクトをどこで作るか」という問いが残る。親が子のDOMオブジェクトに対してメソッドを呼ぶ場合、現在走っている関数のRealm、呼び出しを始めた親のRealm、操作対象の子オブジェクトのRealmは一致するとは限らない。
HTML仕様はこの違いを、current realmや、特定のオブジェクトのrelevant realmという言葉で説明している。current realmは現在実行中のコードに対応する。一方、relevant realmはそのプラットフォームオブジェクトが属するRealmを指す。 HTML仕様のRealmとsettings object にも、呼び出しているコード側と操作対象のオブジェクト側でRealmが異なる例が載っている。
たとえば子に属するオブジェクトのAPIが、子に結び付いた状態としてPromiseを作って返す仕様なら、親からそのAPIを呼んだからといって、親のPromiseで作ればよいとは限らない。どちらのRealmのPromiseになるべきかは、そのAPIのアルゴリズムが決める。単純に「いま動いているRealmを使う」と決め打ちすると、返り値がどのPromiseのinstanceなのかまで変わってしまう。
これはRealmを意識するAPI実装で悩ましい点だ。メソッドを呼んだ場所、メソッド自身の所属、thisとして渡されたオブジェクトの所属、引数に入ったオブジェクトの所属が異なる可能性がある。毎回すべてを比較するという話ではなく、仕様がどの値を基準にすると決めているかを確認しないと、たまたま親子が同じRealmのときだけ正しく動く実装になる。
非同期処理には所有者と世代が要る
子iframeでsetTimeoutを呼んだ直後にナビゲーションしたら、そのタイマーは新しいDocumentのものにはならない。requestAnimationFrameも同じだ。コールバックの関数だけ保存しておくと、後で実行する時点には、その関数を登録したDocumentがもう存在しないかもしれない。
Omoikaneでは、子Realmで予約された処理にRealmとDocument IDを結び付ける。実行前にそのDocumentがまだ有効か確認し、古い世代の処理は走らせない。Promiseの後続処理から新しくタイマーを登録する場合もあるため、単に登録時の一時的なホスト状態を見るだけでは足りず、実行中のRealmが持つ所有Documentを参照する。
タイマーとRealmの結び付け
と
古い子Realmのタイマーを確認するテスト
では、iframeを別のDocumentへ移動した後、古いsetTimeoutやrequestAnimationFrameが新しいDocumentを書き換えないことを確かめている。Realmの分離は値の同一性だけでなく、非同期処理の寿命にも関わる。
Realmのテストは一つの観点では足りない
Realmの実装を確かめるのに「子のスクリプトでdocumentが読めた」だけでは、ほとんど足りない。Omoikaneのテストも、少なくとも別々の性質を見るようにしている。
まず値の所属を見る。子のArrayやDateが親と異なり、子から作った値が子のprototypeにつながるか。次にアクセス境界を見る。親から同一オリジンの子へは必要な操作ができ、クロスオリジンやsandboxで閉じるべき経路は閉じるか。さらに時間軸を見る。ナビゲーションでWindowProxyの同一性を保ちながら新しいRealmに切り替わるか。最後に非同期の処理を見る。古いDocumentのために予約されたタイマーが、新しいDocumentへ侵入しないか。
これらは独立して壊れる。例えば親から子のdocumentにアクセスできても、子のDateが親のDateと同じならRealmの分離としては不十分だ。逆に、コンストラクタの同一性が正しくても、古いiframeのタイマーが新しいページを変更するなら、ライフサイクルの境界が壊れている。前述のnative continuationの件では、通常の関数呼び出しが成功しても、ネイティブコードを挟むだけで別の問題が現れた。
一つのiframeに関するテストという名前でも、何を守るテストなのかを分けておくと原因を追いやすい。Realmは単一のフラグで表せないので、確認も単一のアサーションでは済まない。
まとめ
Omoikaneでiframeを実装してみると、Realmを作る処理そのものより、境界をまたぐときの扱いのほうが難しかった。同一オリジンでもArrayは別物。contentWindowの参照は残っても背後のDocumentは入れ替わる。親の関数を子のイベントから呼んでも、その関数の実行環境まで子にしてよいわけではない。タイマーは、予約したDocumentが消えた後に動かしてはいけない。
「iframeごとにグローバル変数を分ける」という説明だけでは、この辺りが抜け落ちる。自分にとってRealmは、コードと組み込みオブジェクトの所属先を決め、その所属をナビゲーションや非同期処理の間も取り違えないための境界として見えるようになった。