fix(dom): preserve document domains until replacement

This commit is contained in:
ldm0
2026-09-27 02:54:48 +08:00
parent 2434b54320
commit 23c8037f6a
5 changed files with 161 additions and 8 deletions
@@ -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(
@@ -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()
@@ -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 = "<!doctype html><body><p id=original>original</p>\
<script>document.domain = location.hostname;</script>";
let same = StaticHttpServer::spawn_with_bodies(vec![child.to_owned()]).await;
let cross = StaticHttpServer::spawn_with_bodies(vec![
child.to_owned(),
"<!doctype html><body>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(),
"<!doctype html><body>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"]
);
}
@@ -54,3 +54,5 @@ mod web_audio;
mod webrtc;
mod worker_listener_invocation;
mod document_domain_lifetime;
@@ -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('<!doctype html><body><p>explicit');
openedDocument.close();
check('closing the stream keeps domain access', () => openedWindow.document === openedDocument);
check('explicit open replaces content', () => openedDocument.body.textContent === 'explicit');
openedDocument.write('<!doctype html><body><p>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 = '<!doctype html><body>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};
}