fix(page-visibility): keep synthetic iframe documents hidden

Reading contentWindow on an iframe in a windowless document must not make
its synthetic child document visible. Determine browsing-context ownership
from native top-level, child frame, and popup registrations, preserving
the document's retained visibility state separately from defaultView.

Cover both getter access orders, nested synthetic frames, and cross-realm
visibility/defaultView getters on live documents and popups.

Follow-up to #501.
This commit is contained in:
ldm0
2026-09-14 17:14:45 +08:00
parent f8f9c003fe
commit 884ba3f7fc
3 changed files with 129 additions and 11 deletions
+18 -11
View File
@@ -1450,18 +1450,25 @@ fn current_script_belongs_to_document(
.is_some_and(|owner_document| owner_document == document_handle)
}
fn document_is_hidden<'s>(
scope: &mut v8::PinScope<'s, '_>,
runtime_ptr: *mut JsContextHost,
handle: DomHandle,
document: v8::Local<'s, v8::Object>,
) -> bool {
unsafe { &*runtime_ptr }
fn document_has_browsing_context(runtime: &JsContextHost, handle: DomHandle) -> bool {
// Detached iframe compatibility windows can become a document's defaultView
// without registering a browsing context. They must not make it visible.
runtime.dom_host().document_handle() == handle
|| runtime
.child_browsing_context_host_for_document_handle(handle)
.is_some()
|| runtime
.lightweight_popup_id_for_document_handle(handle)
.is_some()
}
fn document_is_hidden(runtime: &JsContextHost, handle: DomHandle) -> bool {
runtime
.dom_host()
.node(handle)
.and_then(Node::as_document)
.is_some_and(|document| document.visibility_hidden())
|| document_associated_window_for_object(scope, runtime_ptr, handle, document).is_none()
.is_none_or(|document| document.visibility_hidden())
|| !document_has_browsing_context(runtime, handle)
}
fn document_hidden_getter_function<'s>(
@@ -1474,7 +1481,7 @@ fn document_hidden_getter_function<'s>(
rv.set_undefined();
return;
};
rv.set_bool(document_is_hidden(scope, runtime_ptr, handle, args.this()));
rv.set_bool(document_is_hidden(unsafe { &*runtime_ptr }, handle));
}
fn document_visibility_state_getter_function<'s>(
@@ -1487,7 +1494,7 @@ fn document_visibility_state_getter_function<'s>(
rv.set_undefined();
return;
};
let state = if document_is_hidden(scope, runtime_ptr, handle, args.this()) {
let state = if document_is_hidden(unsafe { &*runtime_ptr }, handle) {
"hidden"
} else {
"visible"
@@ -3474,6 +3474,58 @@ fn detached_document_view_uses_document_prototype_accessors() {
);
}
#[test]
fn detached_iframe_windows_do_not_change_document_visibility() {
let mut vm = new_storage_test_vm("https://detached-iframe-visibility.test/");
let result = vm
.eval(
r#"
(() => {
const errors = [];
const check = (condition, message) => { if (!condition) errors.push(message); };
const factories = [
["createHTMLDocument", () => document.implementation.createHTMLDocument("")],
["DOMParser", () => new DOMParser().parseFromString("<body></body>", "text/html")],
["createDocument", () => document.implementation.createDocument("urn:test", "root", null)]
];
for (const [name, create] of factories) {
for (const first of ["document", "window"]) {
const outer = create();
const frame = outer.createElementNS("http://www.w3.org/1999/xhtml", "iframe");
frame.srcdoc = "<p>hello</p>";
outer.documentElement.appendChild(frame);
const label = `${name}/${first}`;
check(outer.defaultView === null, `${label}: windowless owner`);
let child;
if (first === "document") {
child = frame.contentDocument;
check(child.hidden && child.visibilityState === "hidden", `${label}: before contentWindow`);
}
const view = frame.contentWindow;
child ||= frame.contentDocument;
check(view !== null && view.document === child, `${label}: synthetic window`);
check(child.defaultView === view, `${label}: associated window retained`);
check(child.hidden && child.visibilityState === "hidden", `${label}: after contentWindow`);
check(outer.hidden && outer.visibilityState === "hidden", `${label}: owner stays hidden`);
const nested = child.createElement("iframe");
nested.srcdoc = "<p>nested</p>";
child.body.appendChild(nested);
const nestedChild = nested.contentDocument;
check(nestedChild.hidden && nestedChild.visibilityState === "hidden", `${label}: nested before contentWindow`);
check(nested.contentWindow.document === nestedChild, `${label}: nested window`);
check(nestedChild.hidden && nestedChild.visibilityState === "hidden", `${label}: nested after contentWindow`);
}
}
return JSON.stringify(errors);
})()
"#,
)
.expect("detached iframe visibility probe should evaluate");
assert_eq!(result, "[]");
}
#[test]
fn detached_document_state_and_collections_use_document_prototype_accessors() {
let mut vm = new_storage_test_vm("https://detached-document-prototype-state.test/");
@@ -52,6 +52,65 @@ fn input_show_picker_enforces_brand_without_rejecting_inherited_child_origin() {
);
}
#[test]
fn document_visibility_and_default_view_follow_receiver_across_realms() {
let mut vm = new_storage_test_vm("https://document-visibility-realms.test/");
vm.eval(
r#"
(() => {
const frame = document.createElement("iframe");
frame.id = "visibility-live-frame";
(document.body || document.documentElement || document).appendChild(frame);
})()
"#,
)
.expect("visibility child frame should be created");
materialize_single_child_default_realm_for_test(
&mut vm,
"document visibility and defaultView child Realm",
);
let result = vm
.eval(
r#"
(() => {
const errors = [];
const check = (condition, message) => { if (!condition) errors.push(message); };
const childWindow = document.getElementById("visibility-live-frame").contentWindow;
const child = childWindow.document;
const parentView = Object.getOwnPropertyDescriptor(Document.prototype, "defaultView").get;
const childView = Object.getOwnPropertyDescriptor(childWindow.Document.prototype, "defaultView").get;
const parentHidden = Object.getOwnPropertyDescriptor(Document.prototype, "hidden").get;
const childHidden = Object.getOwnPropertyDescriptor(childWindow.Document.prototype, "hidden").get;
const parentVisibility = Object.getOwnPropertyDescriptor(Document.prototype, "visibilityState").get;
const childVisibility = Object.getOwnPropertyDescriptor(childWindow.Document.prototype, "visibilityState").get;
check(parentView.call(document) === window, "parent defaultView");
check(parentView.call(child) === childWindow, "parent getter on child defaultView");
check(childView.call(document) === window, "child getter on parent defaultView");
check(childView.call(child) === childWindow, "child defaultView");
check(!parentHidden.call(child) && parentVisibility.call(child) === "visible", "child is visible through parent getters");
check(!childHidden.call(document) && childVisibility.call(document) === "visible", "parent is visible through child getters");
const windowless = child.implementation.createHTMLDocument("");
check(childView.call(windowless) === null, "windowless defaultView through child getter");
check(parentHidden.call(windowless) && parentVisibility.call(windowless) === "hidden", "windowless remains hidden through parent getters");
const popup = window.open("about:blank", "visibility-popup");
check(popup !== null, "popup created");
try {
check(!popup.document.hidden && popup.document.visibilityState === "visible", "live popup is visible");
check(parentView.call(popup.document) === popup, "popup defaultView through parent getter");
} finally {
popup.close();
}
return JSON.stringify(errors);
})()
"#,
)
.expect("cross-realm document visibility and view probe should evaluate");
assert_eq!(result, "[]");
}
#[test]
fn cross_realm_dom_bindings_reject_incompatible_receivers_in_their_own_realm() {
let mut vm = new_storage_test_vm("https://cross-realm-dom-receivers.test/");