fix(navigation): preserve foreign child navigation semantics

Route cross-origin child Location writes through browsing-context navigation
commands so the destination does not receive a cancelable navigate event and
its history seed preserves push and origin-filtered entries. Synchronize native
Window surfaces in their own realm to avoid cross-origin access exceptions for
named-target clicks and window.open.

Add regression coverage for all four navigation entry points, activation type,
and history filtering after return-to-origin and about:blank navigations.
Historical WPT probes pass all five cases at b4f9a9677f and fail at 67283068e3.

Fixes: 67283068e3 (refactor(window): dispatch cross-origin access through native tables)

Validation:
- cargo fmt --all
- cargo clippy --workspace --all-targets --all-features -- -D warnings
- cargo nextest run --no-fail-fast
This commit is contained in:
ldm0
2026-10-18 00:06:57 +08:00
parent fc9e84a099
commit 880e91a251
4 changed files with 200 additions and 0 deletions
@@ -140,6 +140,11 @@ impl JsContextHost {
let Some(wrapper) = self.child_window_proxy_records.live_window(scope, handle) else {
return;
};
let Some(context) = wrapper.get_creation_context(scope) else {
return;
};
// Native history synchronization may be initiated by a foreign Window.
let scope = &mut v8::ContextScope::new(scope, context);
let Some(entry_seed) = self
.child_browsing_contexts
.get(&handle)
@@ -96,6 +96,11 @@ fn navigate_window<'s>(
} else {
raw
};
if !caller_can_access_window(scope, window)
&& let Some(navigated) = navigate_cross_origin_child(scope, window, &raw, kind)
{
return navigated;
}
let Some(context) = window.get_creation_context(scope) else {
return false;
};
@@ -115,6 +120,46 @@ fn navigate_window<'s>(
true
}
fn navigate_cross_origin_child<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
raw: &str,
kind: crate::context_bootstrap::LocationNavigationKind,
) -> Option<bool> {
let context = window.get_creation_context(scope)?;
let host_ptr = crate::util::context_host_ptr_from_context_slot(context)?;
let host = unsafe { &mut *host_ptr };
let handle = host
.window_execution_context_identity_for_access_check(context)
.filter(|identity| host.window_execution_context_identity_is_current(*identity))
.and_then(|identity| identity.dispatch_scope().child_window())
.or_else(|| {
context
.get_slot::<ChildWindowProxyFacadeContextHandle>()
.map(|handle| handle.0)
})?;
let target = url::Url::parse(raw).ok()?;
if cross_origin_location_target_is_same_document(host, handle, &target) {
return None;
}
// A foreign initiator navigates the browsing context, without firing a
// destination navigate event or editing the outgoing Window's local history.
Some(match kind {
crate::context_bootstrap::LocationNavigationKind::Assign => {
host.navigate_child_browsing_context_to_url(scope, handle, target.as_str())
}
crate::context_bootstrap::LocationNavigationKind::Replace => host
.queue_child_browsing_context_navigation_from_existing_seed(
handle,
target.as_str(),
true,
),
crate::context_bootstrap::LocationNavigationKind::Reload => {
host.queue_child_browsing_context_reload_from_existing_seed(handle, target.as_str())
}
})
}
fn shared_cross_origin_location<'s>(
scope: &mut v8::PinScope<'s, '_>,
window: v8::Local<'s, v8::Object>,
@@ -0,0 +1,149 @@
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn cross_origin_child_navigation_does_not_dispatch_a_destination_navigate_event() {
let server = StaticHttpServer::spawn_with_responder(
12,
"Content-Type: text/html; charset=utf-8\r\n",
|_, _| {
r#"<!doctype html><script>
navigation.onnavigate = event => {
event.preventDefault();
top.postMessage('UNEXPECTED NAVIGATE', '*');
};
onload = () => {
if (location.search === '?next') top.postMessage('DONE', '*');
};
</script>"#
.to_owned()
},
)
.await;
let loader = static_http_loader([
server.resolve_entry("parent.cross-navigation.test"),
server.resolve_entry("child.cross-navigation.test"),
]);
let parent = server.url_for_host("parent.cross-navigation.test", "/parent");
let child = server.url_for_host("child.cross-navigation.test", "/child");
let destination = server.url_for_host("child.cross-navigation.test", "/child?next");
for action in ["href", "window", "click", "open"] {
let mut vm = new_storage_page_task_executor_test_vm_with_loader(parent.as_str(), &loader);
vm.eval(&format!(
r#"
globalThis.loads = 0;
globalThis.messages = [];
globalThis.sourceNavigations = 0;
navigation.onnavigate = () => ++sourceNavigations;
addEventListener('message', event => messages.push(event.data));
const root = document.documentElement || document.appendChild(document.createElement('html'));
const frame = document.createElement('iframe');
frame.name = 'target';
frame.onload = () => ++loads;
frame.src = {child:?};
root.appendChild(frame);
"#,
child = child.as_str(),
))
.unwrap();
advance_page_task_executor_until_eval_equals(
&mut vm,
&loader,
"String(loads)",
"1",
"cross-origin child should finish its initial load",
)
.await;
let url = format!("{:?}", destination.as_str());
let script = match action {
"href" => format!("frame.contentWindow.location.href = {url}; 'queued'"),
"window" => format!("frame.contentWindow.location = {url}; 'queued'"),
"open" => format!("window.open({url}, 'target'); 'queued'"),
"click" => format!(
"const link = document.createElement('a'); link.href = {url}; \
link.target = 'target'; root.appendChild(link); link.click(); 'queued'"
),
_ => unreachable!(),
};
vm.eval(&script).unwrap();
advance_page_task_executor_until_eval_equals(
&mut vm,
&loader,
"String(messages.includes('DONE'))",
"true",
"foreign navigation must commit without firing its canceling navigate handler",
)
.await;
assert_eq!(
vm.eval("JSON.stringify([messages, sourceNavigations, loads])")
.unwrap(),
r#"[["DONE"],0,2]"#,
"{action}",
);
}
}
#[tokio::test(flavor = "current_thread")]
async fn cross_origin_location_navigation_preserves_push_and_filters_previous_origin_entries() {
let server = StaticHttpServer::spawn_with_responder(
4,
"Content-Type: text/html; charset=utf-8\r\n",
|_, _| "<!doctype html><p>ready</p>".to_owned(),
)
.await;
let loader = static_http_loader([
server.resolve_entry("parent.cross-history.test"),
server.resolve_entry("child.cross-history.test"),
]);
let parent = server.url_for_host("parent.cross-history.test", "/parent");
let child = server.url_for_host("child.cross-history.test", "/child");
let destination = server.url_for_host("parent.cross-history.test", "/returned");
for target in [destination.as_str(), "about:blank"] {
let mut vm = new_storage_page_task_executor_test_vm_with_loader(parent.as_str(), &loader);
vm.eval(&format!(
r#"
globalThis.loads = 0;
const root = document.documentElement || document.appendChild(document.createElement('html'));
const frame = document.createElement('iframe');
frame.onload = () => ++loads;
frame.src = {child:?};
root.appendChild(frame);
"#,
child = child.as_str(),
))
.unwrap();
advance_page_task_executor_until_eval_equals(
&mut vm,
&loader,
"String(loads)",
"1",
"foreign child should commit before navigation back to its parent's origin",
)
.await;
vm.eval(&format!(
"frame.contentWindow.location = {target:?}; 'queued'"
))
.unwrap();
advance_page_task_executor_until_eval_equals(
&mut vm,
&loader,
"String(loads)",
"2",
"new child document should commit",
)
.await;
assert_eq!(
vm.eval("String(frame.contentWindow.navigation.entries().length)")
.unwrap(),
"1",
"entries from the previous origin must not leak into {target}",
);
if target != "about:blank" {
assert_eq!(
vm.eval("frame.contentWindow.navigation.activation.navigationType")
.unwrap(),
"push",
"a loaded foreign document is not an initial empty document",
);
}
}
}
@@ -1,6 +1,7 @@
mod beforeinput_target_validation;
mod credential_container;
mod credential_json_options;
mod cross_origin_navigation;
mod picture_in_picture_interfaces;
mod visual_viewport_receiver;
mod writable_stream_native_receivers;