From 880e91a251f32da8f4bf0af0a558f9f74e8d68db Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sun, 18 Oct 2026 00:06:57 +0800 Subject: [PATCH] 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 b4f9a9677fc and fail at 67283068e35. Fixes: 67283068e35 (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 --- .../context_host/child_frame_runtime/sync.rs | 5 + .../child_frame_runtime/window/location.rs | 45 ++++++ .../tests/cross_origin_navigation.rs | 149 ++++++++++++++++++ moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + 4 files changed, 200 insertions(+) create mode 100644 moli-renderer-v8/src/script_vm/tests/cross_origin_navigation.rs diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs index 05d64c2d7d..17c6c922e8 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/sync.rs @@ -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) diff --git a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window/location.rs b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window/location.rs index d1545ffc56..ac0bf08b8a 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window/location.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/child_frame_runtime/window/location.rs @@ -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 { + 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::() + .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>, diff --git a/moli-renderer-v8/src/script_vm/tests/cross_origin_navigation.rs b/moli-renderer-v8/src/script_vm/tests/cross_origin_navigation.rs new file mode 100644 index 0000000000..ceaf58aa27 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/cross_origin_navigation.rs @@ -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#""# + .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", + |_, _| "

ready

".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", + ); + } + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index ccfc74af4c..4444193cc0 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -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;