From 23c8037f6a43aaa57747e76e8543791aa8011dd2 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 27 Sep 2026 02:54:48 +0800 Subject: [PATCH] fix(dom): preserve document domains until replacement --- .../child_frame_runtime/document.rs | 3 +- .../context_host/child_frames/registry.rs | 12 +-- .../browser_api/document_domain_lifetime.rs | 60 ++++++++++++ .../src/script_vm/tests/browser_api/mod.rs | 2 + .../fixtures/document-domain-lifetime.js | 92 +++++++++++++++++++ 5 files changed, 161 insertions(+), 8 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/document_domain_lifetime.rs create mode 100644 moli-renderer-v8/tests/fixtures/document-domain-lifetime.js diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs index b8d3174877..044fe9c24e 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/document.rs @@ -700,7 +700,8 @@ impl JsContextHost { } let host = unsafe { &mut *host_ptr }; if let Some(entry) = host.child_browsing_contexts.get_mut(&child_handle) { - entry.clear_document_runtime_state(); + // document.open() reuses this Document and its mutable origin. + entry.clear_script_execution_state(); } host.request_child_frame_realm_materialization(child_handle); host.install_empty_child_classic_script_runner_for_current_document( diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs index 20745fa485..00c28c8017 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frames/registry.rs @@ -425,13 +425,11 @@ impl JsContextHost { .as_ref() .map(|entry| entry.classic_script_document_state.clone()) .unwrap_or_default(), - document_domain_override: if attribute_bootstrap_changed { - None - } else { - existing - .as_ref() - .and_then(|entry| entry.document_domain_override()) - }, + // Attribute changes can schedule navigation, but the active + // Document keeps its mutable origin until replacement commits. + document_domain_override: existing + .as_ref() + .and_then(|entry| entry.document_domain_override()), credentialless, service_worker_client_id: existing .as_ref() diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/document_domain_lifetime.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/document_domain_lifetime.rs new file mode 100644 index 0000000000..ceb2d24dcd --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/document_domain_lifetime.rs @@ -0,0 +1,60 @@ +use super::*; + +#[tokio::test(flavor = "current_thread")] +async fn document_domain_survives_stream_replacement_and_pending_navigation() { + const HOST: &str = "document-domain-lifetime.test"; + let child = "

original

\ + "; + let same = StaticHttpServer::spawn_with_bodies(vec![child.to_owned()]).await; + let cross = StaticHttpServer::spawn_with_bodies(vec![ + child.to_owned(), + "replacement".to_owned(), + ]) + .await; + let loader = static_http_loader([same.resolve_entry(HOST), cross.resolve_entry(HOST)]); + let mut vm = new_parsed_page_task_executor_test_vm( + same.url_for_host(HOST, "/entry.html").as_str(), + "parent", + &loader, + ); + let script = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/document-domain-lifetime.js" + )); + vm.exec( + &format!( + r#" +globalThis.__documentDomainLifetimeResult = null; +({script})({{sameURL:{same_url:?},crossURL:{cross_url:?},replacementURL:{replacement_url:?}}}).then( + result => {{__documentDomainLifetimeResult = result}}, + error => {{__documentDomainLifetimeResult = {{error:String(error)}}}} +); +"#, + same_url = same.url_for_host(HOST, "/child.html").as_str(), + cross_url = cross.url_for_host(HOST, "/child.html").as_str(), + replacement_url = cross.url_for_host(HOST, "/replacement.html").as_str(), + ), + None, + ) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(__documentDomainLifetimeResult !== null)", + "true", + "document.domain lifetime checks should finish", + ) + .await; + let result: serde_json::Value = serde_json::from_str( + &vm.eval("JSON.stringify(__documentDomainLifetimeResult)") + .unwrap(), + ) + .unwrap(); + assert_eq!(result["checks"], 48, "{result}"); + assert_eq!(result["failures"], serde_json::json!([]), "{result}"); + assert_eq!(same.finish_targets().await, ["/child.html"]); + assert_eq!( + cross.finish_targets().await, + ["/child.html", "/replacement.html"] + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 3a2f7baca4..c8d50e946f 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -54,3 +54,5 @@ mod web_audio; mod webrtc; mod worker_listener_invocation; + +mod document_domain_lifetime; diff --git a/moli-renderer-v8/tests/fixtures/document-domain-lifetime.js b/moli-renderer-v8/tests/fixtures/document-domain-lifetime.js new file mode 100644 index 0000000000..2d1607208c --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/document-domain-lifetime.js @@ -0,0 +1,92 @@ +async ({sameURL, crossURL, replacementURL}) => { + document.domain = location.hostname; + let checks = 0; + const failures = []; + const check = (label, predicate) => { + checks++; + try { + if (!predicate()) failures.push(label); + } catch (error) { + failures.push(label + ': ' + error); + } + }; + const securityError = callback => { + try { callback(); } + catch (error) { return error instanceof DOMException && error.name === 'SecurityError'; } + return false; + }; + const create = async url => { + const frame = document.createElement('iframe'); + const loaded = new Promise(resolve => { frame.onload = resolve; }); + frame.src = url; + document.body.appendChild(frame); + await loaded; + return frame; + }; + + const openedFrame = await create(sameURL); + const openedWindow = openedFrame.contentWindow; + const openedDocument = openedWindow.document; + check('explicit open returns the same Document', () => openedDocument.open() === openedDocument); + check('explicit open keeps domain access', () => openedWindow.document === openedDocument); + openedDocument.write('

explicit'); + openedDocument.close(); + check('closing the stream keeps domain access', () => openedWindow.document === openedDocument); + check('explicit open replaces content', () => openedDocument.body.textContent === 'explicit'); + openedDocument.write('

implicit'); + openedDocument.close(); + check('implicit open keeps domain access', () => openedWindow.document === openedDocument); + check('implicit open replaces content', () => openedDocument.body.textContent === 'implicit'); + openedFrame.remove(); + + const frame = await create(crossURL); + const window = frame.contentWindow; + const original = window.document; + const originalRoot = original.documentElement; + const Exception = window.DOMException; + const listen = window.addEventListener; + const snapshot = phase => { + check(phase + ': contentDocument', () => frame.contentDocument === original); + check(phase + ': Window.document', () => window.document === original); + check(phase + ': original realm', () => window.DOMException === Exception); + check(phase + ': relaxed domain', () => original.domain === document.domain); + check(phase + ': raw origins still differ', () => window.location.origin !== location.origin); + check(phase + ': active Document preserves content', () => original.documentElement === originalRoot); + }; + snapshot('loaded'); + + const events = []; + for (const type of ['beforeunload', 'pagehide', 'unload']) { + listen.call(window, type, () => { + events.push(type); + snapshot(type); + }); + } + let loaded = new Promise(resolve => { frame.onload = resolve; }); + frame.src = replacementURL; + snapshot('pending committed navigation'); + await loaded; + for (const type of ['beforeunload', 'pagehide', 'unload']) { + check(type + ' ran', () => events.filter(event => event === type).length === 1); + } + check('new network Document has no domain override', () => frame.contentDocument === null); + check('new network Window denies domain access', () => securityError(() => window.document)); + + loaded = new Promise(resolve => { frame.onload = resolve; }); + frame.src = 'about:blank'; + await loaded; + const blank = frame.contentDocument; + check('new about:blank inherits domain access', () => blank !== null && window.document === blank); + check('new about:blank replaces the old Document', () => blank !== original); + check('new about:blank inherits domain', () => blank.domain === document.domain); + + loaded = new Promise(resolve => { frame.onload = resolve; }); + frame.srcdoc = 'srcdoc'; + check('pending srcdoc keeps the current Document', () => window.document === blank); + await loaded; + check('new srcdoc inherits domain access', () => frame.contentDocument !== null && window.document === frame.contentDocument); + check('new srcdoc replaces about:blank', () => frame.contentDocument !== blank); + check('new srcdoc inherits domain', () => frame.contentDocument.domain === document.domain); + frame.remove(); + return {checks, failures}; +}