During the development of Omoikane, I happened to notice that the internal __omoikane_* function was visible from the page’s JavaScript. By examining the code, I confirmed through static analysis that passing a different Document or Node ID could create a path that reaches the DOM, cookies, or storage of a cross‑origin document without going through the same‑origin policy. I did not create a proof‑of‑concept that actually reproduced the read/write behavior before the fix. After the fix, I verified in regression tests that cross‑origin access is properly blocked.
In a previous article I wrote about the relationship between iframes and Realms. While working on separating Realms, I found this issue at the boundary with Rust host functions outside the Realm. The fix is in this commit and is included in v0.4.0.
The problematic path
Omoikane uses Boa to execute JavaScript, while browser features such as the DOM are handled on the Rust side. JavaScript’s document and Node have wrappers; for example, retrieving innerHTML involves passing the Node ID from the wrapper to a Rust function, which then serializes the Rust‑side Node and returns it. The same applies to cookies and localStorage: after identifying the target Document, the Rust‑side state is read.
These internal functions are not APIs exposed to the page. They are components that connect the JavaScript implementation of the DOM with the Rust implementation. However, before the fix, host functions had been registered for each Realm’s globalThis. The DOM initialization script imported them, and although some names were removed from the global scope after initialization, not all of the registered functions were eliminated. Internal functions that could still be directly referenced from page scripts remained.
The problem was not limited to the functions being visible. Some host functions would look up the supplied Document ID or Node ID in the Rust‑side shared state and process it directly. They did not check on the native side whether the Document of the calling script and the target Document being referenced shared the same origin.
In normal DOM operations, boundaries on the JavaScript side enforce restrictions—for example, you cannot retrieve contentDocument from an iframe of a different origin. However, if an internal function can be called directly, it can bypass those boundaries and reach the Rust‑side state. Document and Node IDs are internal identifiers, not proofs of access rights. If there is an entry point that accepts an ID as an argument, you must not trust where that ID came from.
The scope of the vulnerability identified in the code includes the DOM of cross‑origin Documents within the same Omoikane runtime, cookies that can be read from the page’s JavaScript, and localStorage, among others. It does not involve being able to read the HttpOnly cookie from the page’s JavaScript, nor does it involve directly accessing another site over the network. The issue lies at the origin boundary inside Omoikane that handles multiple Documents.
Why separating Realms didn’t prevent it
When a Realm is created for each iframe’s Document, each gets its own global environment and built‑in objects such as Array. The globalThis of the parent page and the globalThis of the child page are not the same object. This separation is necessary so that variables defined in the child do not automatically become global variables in the parent, and so that the system can track which Realm created a given JavaScript value.
On the other hand, the DOM itself is not confined solely to the JavaScript global object. The HostState component in Omoikane’s Rust side manages information such as the parent and child Documents, the Nodes that belong to them, and their origins. The DOM wrappers placed in each Realm call host functions to reach this shared state. In other words, even though the Realms are separate, the Node IDs used by Rust do not point to independent storage locations for each Realm.
If we consider the parent as origin A and the child iframe as origin B, normally a boundary is hit when the parent tries to obtain iframe.contentDocument. WindowProxy and the DOM wrappers do not pass B’s Document directly. However, an internal host function is also visible in the global environment of A’s Realm, and if that function resolves the received ID by looking it up in the shared HostState, it does not need to go through contentDocument. If the function does not verify both “the call originated from A” and “the target ID belongs to a document of B,” it can bypass the boundary enforced on the JavaScript side and access the internal state directly.
When the two pre‑fix paths are illustrated, they look like this. The dotted line represents the path that allowed the page to call the internal host function directly.
flowchart TB
subgraph A["Realm A / parent page, origin A"]
P["JavaScript of the parent page"]
H["Internal host function<br/>accessible via globalThis"]
end
subgraph B["Realm B / child page, origin B"]
Q["JavaScript of the child page"]
end
subgraph R["Rust / shared HostState"]
D["Document B / Node B"]
end
P -->|Standard DOM manipulation| W["WindowProxy / DOM wrapper"]
W --> C{"Are A and B same‑origin?"}
C -->|No| X["Access denied"]
P -.->|Before fix: Could be called directly| H
H -.->|Retrieve target by ID| D
Q -->|Through one's own DOM wrapper| D
Even with two Realms, the Rust‑side state on the right is not kept separate for each Realm. The dotted‑line path lacks the WindowProxy check that normal DOM operations would enforce. The fix hides the dotted‑line entry point from the page and makes the Rust side that receives the ID also verify the caller’s origin and the target’s origin.
When Boa creates a Realm, it does not automatically infer the meaning of the integer passed to Omoikane’s Rust functions, nor does it automatically determine the access rights of the Node that the integer refers to. From the perspective of a host function, the argument is first a JavaScript value; only after the Rust side interprets it as an ID does the target become known. Whether the ID points to a valid Node and whether the current caller is permitted to interact with that Node are separate conditions. If you treat them as a single condition—“process it because the ID was obtained”—the same problem persists no matter how many Realms you create.
Moreover, the distinction between Realms and origins is not a one‑to‑one mapping. Even when parent and child iframes share the same origin, they may be placed in separate Realms yet still be able to reference each other’s Documents. Conversely, even when using the same Boa runtime and HostState, data belonging to a different origin cannot be read. Rather than granting or denying access wholesale based on Realms, it was necessary to pinpoint the Document from the caller’s Realm and compare its origin with the origin of the Document or Node being accessed.
Before the fix, the “internal function visible to the page” and the “processing that receives an ID without checking its origin” overlapped. Hiding the former would prevent ordinary pages from reaching that entry point, but leaving the latter untouched would allow the problem to resurface when another internal pathway is introduced. Conversely, protecting only the Rust side would still expose unnecessary internal functions to the page. The reason we addressed these two issues separately is that separating Realms alone cannot fulfill either responsibility.
Do not place host functions in the page’s global scope
The first fix involved changing how internal functions are exposed. In the old implementation, register_host_bindings used Boa’s register_global_callable and register_global_property to register the functions and Document IDs needed for DOM initialization into the global scope. This left the responsibility for managing the exposure—such as “remove them after initialization”—to a separate piece of code. Because the location where the functions are added and the location where they are removed are far apart, adding more functions without corresponding removals leaves internal names lingering in the page.
After the fix, functions and values are bundled in BootstrapBindings and passed only to Omoikane’s own DOM bootstrap module. The module loader registers that module record as an internal one and supplies the bindings only when the corresponding import.meta is being initialized. It is not handed to ordinary JavaScript modules loaded by the page.
The bootstrap side then expands the received values into local names within the module and uses them. The properties of globalThis differ from the names that live in the module’s lexical scope. Even if the page creates a global property with the same name, the bindings used internally are not replaced. The temporary import.meta property used to receive the value is also deleted immediately after it is extracted.
The existing DOM bootstrap contains many references to globalThis.__omoikane_*. In this implementation, for each already‑registered internal name, we evaluate source code that replaces those references with names inside the module. The legacy “remove from the global” statements are no longer needed, since the values are not placed in the global scope at all. This step moves everything to the new storage location in one go.
Functions that are no longer placed in the page’s global must be protected from being collected by the garbage collector during initialization. HostState temporarily holds bootstrap objects and includes them in the set of objects subject to tracing. When the module’s load, link, and evaluate finish, that temporary registration is cleaned up. During initialization, we also verify that internal function names are not left as own properties on the page’s global.
Preventing the page’s script from accessing internal functions is important, but that alone was not sufficient. Even if a function could later be reached through another pathway, we must ensure that it does not return data from another origin solely based on the supplied ID.
Verifying Origin on the Rust Side
Another change is to add origin checks at the native entry points that receive Document or Node IDs. ensure_same_origin_document compares the security origin of the calling Document with that of the target Document. ensure_same_origin_node determines the Document to which the target Node belongs and proceeds with the same check. If the origins differ or the necessary information for the check is missing, it falls back to SecurityError.
Some care is required when determining the caller. If you look only at context.realm() at the moment a native function is invoked, you may mistakenly identify the Document that initiated the operation when, for example, a function was created in a different Realm. In the implementation, we first examine Boa’s caller_realm(), then the Realm of the currently executing script or module, and finally the current Realm, and we use the Document ID recorded in that Realm. If there is no Document ID associated with the explicitly specified caller Realm, we reject the request without arbitrarily supplying a convenient ID from elsewhere.
We also refuse to allow access when the target Document’s origin cannot be obtained. Simply comparing with Option directly would be unsafe, because if both pieces of information are missing, None == None evaluates to true in Rust. The corrected code performs the comparison only when both sides actually have an origin. We have also tested scenarios where iframes or the popup lack origin information, ensuring that the Document does not become visible.
The cookie getter and setter run this check immediately after receiving a Document ID. Storage also verifies the Document as part of its common argument handling before determining where to store data. In DOM operations that receive a Node ID, such as innerHTML, we first confirm the Document to which the target Node belongs before reading or writing the Node on the Rust side. Even for operations that do not return a value—such as form submissions or document.write()—if they could modify a Document from another origin, the same issue arises, so we verify the target ID.
The security origin used here is not exactly the same as the information that determines where cookies or storage are saved. In Omoikane, DocumentSecurityOrigin represents an identifier for either a regular tuple‑type origin or an opaque origin. StorageOrigin holds the scheme, host, and port and is used for managing the storage destination. First we determine whether the Document is permissible to access, and only then proceed with cookie or storage handling. We do not make a single decision based solely on a URL string.
Detached Nodes and Old Documents
When looking up a Node’s owner, it is not always possible to traverse up the parent chain to reach its Document. An Element created with document.createElement() has not yet been attached to the tree. Nevertheless, it still has a Document that originated it. A mechanism for recording the owner as a lifetime owner already existed, but the record could be missing when the detached node is first wrapped. In this change we reinforced that record so that it can also be used to determine the origin for nodes that are not part of the tree.
We also revisited the logic that holds Nodes and the logic that reassigns ownership. retain_node_native checks the origin of the Node being retained, and set_owner_native verifies both the target Node and the new owning Document. Even if we protect only the DOM getters, internal functions that modify references or ownership relationships can still accept IDs from another origin, leaving a pathway open. This part was added as an additional fix following the initial change.
Even after an iframe navigates to a new page, JavaScript may still retain functions or DOM wrappers that originated from the old Document. The moment the page switches, references to the old Realm do not disappear entirely. After the fix, we record the origin at Realm creation time, so that even after the old Document’s normal records have been cleaned up, we can still determine the caller’s origin from that Realm. We also retain the origin of any retained old Document separately and clean it up when the corresponding reference is gone.
This cannot be solved simply by “reject everything coming from an old Document.” While it may be acceptable to reuse an old wrapper from the same origin, we must not permit access to a different origin just because the old record has been removed. It was necessary to preserve the necessary provenance information in line with the lives of Nodes and Realms.
Changing the Path for Internal Processing
The part that took the most debugging time this time was the failures that occurred after adding the origin checks. While it is expected that unauthorized operations across origins are blocked, halting DOM initialization or even the iframe’s own operations would render the browser unusable. When tests failed, instead of merely loosening the checks to see if they would pass, we examined each operation to determine exactly which Document it was acting on.
Initially, when we removed the host function from globalThis, the page’s DOM initialization and the child iframe’s initialization both failed. In the early verification, the test that ensures internal functions are not visible from the page also stalled due to a JavaScript error that occurred while building the DOM. This is because the bootstrap itself had previously relied on functions that lived in the global scope. Simply deleting the names does not prevent the internal code from reaching the Rust functions.
Therefore, we limited the destination to the bootstrap module’s import.meta. Here, too, we confirmed that the function remains available until the module extracts its value, that it is correctly passed to both parent and child‑iframe modules, and that no temporary registrations linger after initialization. It was necessary to balance keeping the internal functions hidden with keeping them alive only as long as they are needed for initialization.
When we began checking the origin of Nodes, we encountered false positives for detached nodes. A Node created with document.createElement() has no parent, so merely walking up the parent chain to locate its owning Document is insufficient. During debugging, a child page was manipulating a Node it itself created, yet it was classified as a Node from another origin by SecurityError. Although the caller was the child page, the target Node’s owner was treated as the parent Document. This is why we reinforced the existing lifetime‑owner record so that it can be used from the moment a detached node is first wrapped.
A similar issue arises after an iframe navigates to a different page. While references to the old Document’s wrapper or its Realm are still held, if only the normal Document records are cleaned up, we can no longer determine the caller’s origin. Granting access without that information is risky, but rejecting everything uniformly would break operations that are allowed within the same origin. We retain the provenance of the old Realm and Document as long as needed and evaluate origins separately from the current Document.
Furthermore, adding the check to the host function also affected Omoikane’s internal iframe handling. In history navigation, reloads, and form submissions, the parent’s internal code would temporarily wrap a child Document from another origin and then read the URL or state from that wrapper. This pathway had worked before, but adding the check caused wrapNode during a child page’s reload to become SecurityError. The history update sometimes produced an empty result, and form‑submission tests timed out.
If we were to treat this failure as an exception because it occurs in internal processing, a pathway would remain that allows obtaining a child Document’s wrapper simply by providing its ID. In the fix, we wrap the child Document only when access is permitted, and when it is not, we pass the necessary URL or state for navigation directly, without going through a Document wrapper. We applied the same reasoning to form submissions, changes to the visibility state, and operations that detach from an iframe. Thus, we not only block cross‑origin access from the page, but also eliminate unnecessary reliance on Document wrappers even in legitimate cross‑origin operations within the browser.
postMessage also had an initial check that was too strict. In iframes with an opaque origin, the usual scheme‑host‑port tuple cannot be compared. Moreover, even messages that the iframe sent to itself using the default targetOrigin stopped being delivered. After the fix, the default "/" distinguishes between sending to the same Document and sending to a different Document. The latter comparison is performed only when both the sender’s and the target’s security origins are present. "*" and explicit origin strings are also evaluated according to their respective conditions.
The sender’s origin itself is not derived from the string constructed by JavaScript; instead, it is obtained from the sender Document’s record kept on the Rust side. Since the ability to send messages to a different origin is a feature of postMessage, the solution does not reject all sends outright; rather, it prevents sender impersonation while preserving legitimate deliveries, including messages addressed to itself.
Verification
In the added tests, we confirmed that internal host functions are not visible from an iframe whose origin differs from the page’s script. We also observed that the page’s module cannot obtain the bootstrap‑specific binding from import.meta. On the other hand, ordinary DOM manipulation, as well as the page’s own cookies and storage, remain usable. Even if the page creates a global property with the same name as an internal Document ID, the Document handled by the bootstrap does not get replaced.
In another test, we verified that when a different‑origin Node or Document ID is passed to the wrapper, DOM reads, tree traversals, cookie and storage operations, and writes to the Document are treated as SecurityError. Detached nodes and changes to a node’s retention or ownership were also covered. This is not an example of usage for the page; it is a test to ensure that the native boundary receiving an ID does not trust the caller excessively.
At the same time, we confirmed that same‑origin iframes can be accessed as usual, and that when the origin differs, contentDocument becomes unavailable. The WindowProxy entry point remains, allowing permitted postMessage and navigation operations. Rather than rejecting everything to protect the boundary, we separate permissible operations from those that are not allowed.
In the verification records from the fix, both runs—including the normal cargo test --locked -- --test-threads=4 and the ignored tests—passed 61 groups each. The former comprised 2,682 library tests, the latter 2,710, with 11 doc‑tests in each case. Three JIT differential tests and three Web API surface tests also passed. The fixed WPT smoke tests succeeded on 293 cases, with no regressions. The WPT reftest for the print page, cargo build --locked, and Rust’s format checks also passed. These results are not from GitHub Actions but from running the tests locally at the time of the fix.
Preventing regressions for the paths verified in the tests is different from ensuring that every future host function will automatically be safe. If an internal function receives a new ID, it must not only avoid exposing that ID to the page’s global scope but also verify the Document to which the ID belongs and the caller’s origin.
Conclusion
This issue was not apparent by looking only at the iframe’s WindowProxy. When we examined both the internal host functions visible to the page and the Rust‑side entry point that receives IDs, we found a path that could reach a Document of a different origin.
In the fix, we pass internal functions only to the bootstrap and also have Rust compare the caller’s and target’s origins. Additionally, we re‑examined all operations that touch the same boundary—such as detached nodes, retained old Documents, internal iframe navigation, and form submissions. The lesson learned from this implementation is that, beyond separating realms, it is necessary to verify the origin of the target at the final entry point that reaches shared state.