From e426ce91da7c7b44028dd280a1fefa6e72903f6f Mon Sep 17 00:00:00 2001 From: ldm0 Date: Thu, 24 Sep 2026 10:44:27 +0800 Subject: [PATCH] fix(navigation): preserve hyperlink navigate events Route existing hyperlink targets through native Window identity and the shared navigation path. Preserve click involvement and sourceElement, suppress cross-origin source exposure, and honor cancellation and interception in child frames and named popups. Retain popup referrer policy and synchronous fragment activation after the navigate event accepts it. Add regression coverage for trusted and synthetic clicks, ancestor targets, canceled navigation, interception, and popup policy. --- .../page/tests/same_document_history.rs | 1 + .../hyperlink_navigation.js | 50 ++++ .../hyperlink_navigation.rs | 132 ++++++++++ moli-renderer-v8/src/context_bootstrap.rs | 5 +- .../context_bootstrap/location_navigation.rs | 107 ++++++-- .../src/native_bridge/context_host/popups.rs | 18 +- moli-renderer-v8/src/native_bridge/element.rs | 4 +- .../element/activation/default_action.rs | 70 +----- .../native_bridge/element/activation/mod.rs | 4 +- .../element/activation/targets.rs | 236 ++++++++++-------- .../native_bridge/element/forms/submission.rs | 2 +- .../script_vm/tests/browser_api/details.rs | 19 ++ .../src/script_vm/tests/dom_xhr/forms.rs | 6 + 13 files changed, 449 insertions(+), 205 deletions(-) create mode 100644 moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.js create mode 100644 moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.rs diff --git a/moli-protocol/src/domains/page/tests/same_document_history.rs b/moli-protocol/src/domains/page/tests/same_document_history.rs index a229d07115..e9a44f51b9 100644 --- a/moli-protocol/src/domains/page/tests/same_document_history.rs +++ b/moli-protocol/src/domains/page/tests/same_document_history.rs @@ -1,6 +1,7 @@ use super::*; mod child_history_traversal; +mod hyperlink_navigation; mod popup_network_navigation; mod response_gate; mod stale_history_traversal; diff --git a/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.js b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.js new file mode 100644 index 0000000000..7d5b9de40c --- /dev/null +++ b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.js @@ -0,0 +1,50 @@ +async function setupClickProbe(target, mode, destination, action = 'cancel') { + const root = window; + let owner = root, targetWindow = root, frame; + document.body.style.margin = '0'; + const childURL = new URL('?child', location.href).href; + if (target.startsWith('child') || target === 'named-child') { + frame = document.createElement('iframe'); frame.name = 'click-destination'; + frame.style.cssText = 'position:absolute;left:0;top:0;border:0;width:300px;height:200px'; + frame.src = childURL; + await new Promise(resolve => {frame.onload = resolve; document.body.append(frame)}); + targetWindow = frame.contentWindow; + if (target.startsWith('child')) owner = targetWindow; + if (target === 'child-top' || target === 'child-parent') targetWindow = root; + } else if (target === 'named-popup') { + targetWindow = open(childURL, 'click-destination'); + await new Promise(resolve => targetWindow.addEventListener('load', resolve, {once:true})); + } + await new Promise(resolve => setTimeout(resolve,0)); + const link = owner.document.createElement('a'); + link.id = 'link'; link.textContent = 'navigate'; + link.style.cssText = 'position:absolute;left:0;top:0;display:block;width:120px;height:80px'; + const url = new URL(targetWindow.location.href); + if (destination === 'fragment') url.hash = 'click'; else url.search = '?new-document'; + link.href = url.href; + if (target.startsWith('named')) link.target = 'click-destination'; + if (target === 'child-top') link.target = '_top'; + if (target === 'child-parent') link.target = '_parent'; + owner.document.body.append(link); + root.records = []; root.clicks = []; + root.probeTarget = targetWindow; root.probeInitialURL = targetWindow.location.href; + root.probeInitialDocument = targetWindow.document; root.probeDestination = url.href; + root.probeSuccess = false; + targetWindow.navigation.onnavigatesuccess = () => { root.probeSuccess = true; }; + targetWindow.navigation.onnavigate = event => { + root.records.push({userInitiated:event.userInitiated, source:event.sourceElement === link, + sourceNull:event.sourceElement === null, sameDocument:event.destination.sameDocument, + type:event.navigationType, hashChange:event.hashChange}); + if (action === 'cancel') event.preventDefault(); + if (action === 'intercept') event.intercept({handler: () => Promise.resolve()}); + }; + link.onclick = event => { + root.clicks.push(event.isTrusted); + if (mode === 'location') {event.preventDefault(); targetWindow.location.href = url.href;} + if (mode === 'nested-click' && event.isTrusted) {event.preventDefault(); link.click();} + }; + root.probeOwner = owner; root.probeLink = link; + if (mode === 'script') link.click(); + if (mode === 'dispatch') link.dispatchEvent(new owner.MouseEvent('click', {bubbles:true,cancelable:true})); + return {x:20,y:20}; +} diff --git a/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.rs b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.rs new file mode 100644 index 0000000000..50229a11c1 --- /dev/null +++ b/moli-protocol/src/domains/page/tests/same_document_history/hyperlink_navigation.rs @@ -0,0 +1,132 @@ +use super::*; + +const SETUP: &str = include_str!("hyperlink_navigation.js"); + +async fn activate( + page: &mut SameDocumentPage, + target: &str, + mode: &str, + destination: &str, + action: &str, +) { + page.evaluate(&format!( + "{SETUP}\nsetupClickProbe({target:?}, {mode:?}, {destination:?}, {action:?})" + )) + .await; + if matches!(mode, "trusted" | "location" | "nested-click") { + for (kind, buttons) in [("mousePressed", 1), ("mouseReleased", 0)] { + page.command("Input.dispatchMouseEvent", json!({ + "type": kind, "x": 20, "y": 20, "button": "left", "buttons": buttons, "clickCount": 1, + })).await; + } + } + page.evaluate("new Promise(resolve => setTimeout(resolve, 0))") + .await; +} + +async fn check_click_involvement(target: &str) { + for destination in ["fragment", "document"] { + for mode in ["trusted", "script", "dispatch", "location", "nested-click"] { + let mut page = SameDocumentPage::new().await; + activate(&mut page, target, mode, destination, "cancel").await; + let result = page.evaluate(r#"({records, clicks, unchanged: probeTarget.location.href === probeInitialURL && probeTarget.document === probeInitialDocument})"#).await; + let clicks = match mode { + "trusted" | "location" => json!([true]), + "nested-click" => json!([true, false]), + _ => json!([false]), + }; + assert_eq!( + result, + json!({ + "records": [{ + "userInitiated": mode == "trusted", "source": mode != "location", "sourceNull": mode == "location", + "sameDocument": destination == "fragment", "hashChange": destination == "fragment", "type": "push", + }], "clicks": clicks, "unchanged": true, + }), + "{target}/{destination}/{mode}" + ); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn hyperlink_navigation_top_click_involvement() { + check_click_involvement("top").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn hyperlink_navigation_child_click_involvement() { + check_click_involvement("child").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn hyperlink_navigation_named_child_click_involvement() { + check_click_involvement("named-child").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn hyperlink_navigation_named_popup_click_involvement() { + check_click_involvement("named-popup").await; +} + +#[tokio::test(flavor = "multi_thread")] +async fn hyperlink_navigation_commits_and_intercepts_existing_targets() { + for target in ["top", "child", "named-child", "named-popup"] { + for (destination, action) in [("fragment", "proceed"), ("document", "intercept")] { + let mut page = SameDocumentPage::new().await; + activate(&mut page, target, "trusted", destination, action).await; + let result = page.evaluate(r#"({ + reached: probeTarget.location.href === probeDestination, + retained: probeTarget.document === probeInitialDocument, + success: probeSuccess, events: records.length, userInitiated: records[0]?.userInitiated, + })"#).await; + assert_eq!( + result, + json!({"reached": true, "retained": true, "success": true, "events": 1, "userInitiated": true}), + "{target}/{destination}/{action}" + ); + } + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn hyperlink_navigation_child_ancestor_click_involvement() { + for target in ["child-top", "child-parent"] { + check_click_involvement(target).await; + } +} + +#[tokio::test(flavor = "multi_thread")] +async fn hyperlink_navigation_named_popup_preserves_referrer_policy() { + for policy in ["no-referrer", "origin"] { + let route = axum::Router::new().route("/referrer.html", axum::routing::get(|headers: axum::http::HeaderMap| async move { + let referer = headers.get("referer").and_then(|value| value.to_str().ok()).unwrap_or_default(); + axum::response::Html(format!(r#""#, serde_json::to_string(referer).unwrap())) + })); + let mut page = SameDocumentPage::with_routes(route).await; + let result = page.evaluate(&format!(r#"(async () => {{ + const popup = open('?popup', 'referrer-target'); + await new Promise(resolve => popup.addEventListener('load', resolve, {{once:true}})); + const link = document.createElement('a'); + link.href = '/referrer.html'; link.target = 'referrer-target'; link.referrerPolicy = {policy:?}; + document.body.append(link); + const events = []; + popup.navigation.onnavigate = event => events.push(event.sourceElement === link); + const report = new Promise(resolve => addEventListener('message', event => {{ + if (event.data?.type === 'referrer-report') resolve(event.data); + }})); + link.click(); + return {{report:await report, events}}; + }})()"#)).await; + let expected = if policy == "no-referrer" { + String::new() + } else { + format!("{}/", page.base_url.trim_end_matches("/history.html")) + }; + assert_eq!(result["report"]["referrer"], expected, "{policy}: {result}"); + assert_eq!(result["events"], json!([true]), "{policy}: {result}"); + if policy == "no-referrer" { + assert_eq!(result["report"]["header"], "", "{result}"); + } + } +} diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 2c5ebd504a..1eb86e92ca 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -196,9 +196,8 @@ pub(crate) use css_fontface_runtime::{ pub(crate) use form_navigation::FormNavigationHistory; pub(crate) use history_mutation::update_history_for_document_open; pub(crate) use location_navigation::{ - LocationNavigationKind, - dispatch_top_level_navigation_event_with_source_element, is_native_location, - navigate_borrowed_location, navigate_child_meta_refresh, + HyperlinkNavigationOptions, LocationNavigationKind, is_native_location, + navigate_borrowed_location, navigate_child_meta_refresh, navigate_location_object_for_hyperlink, navigate_location_object_with_child_navigate_event, history_entry_seed_for_cross_document_location, navigate_location_object_with_child_navigate_event_and_initiator_url, diff --git a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs index 4471dc4d15..8b5ca7c3e3 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs @@ -85,6 +85,40 @@ struct LocationNavigationOptions { dispatch_child_navigate_event_for_all_kinds: bool, force_exact_same_document_navigation: bool, explicit_initiator_url: Option, + user_initiated: bool, + hyperlink_source_can_access_target: Option, + named_hyperlink_popup: Option, +} + +pub(crate) struct HyperlinkNavigationOptions { + pub(crate) user_initiated: bool, + pub(crate) source_can_access_target: bool, + pub(crate) initiator_url: url::Url, + pub(crate) named_popup: Option, +} + +pub(crate) fn navigate_location_object_for_hyperlink<'s>( + scope: &mut v8::PinScope<'s, '_>, + location: v8::Local<'s, v8::Object>, + href: &str, + source_element: v8::Local<'s, v8::Object>, + options: HyperlinkNavigationOptions, +) { + navigate_location_object_with_source_element_and_child_navigate_event( + scope, + location, + LocationNavigationKind::Assign, + Some(href.to_owned()), + Some(source_element), + LocationNavigationOptions { + user_initiated: options.user_initiated, + hyperlink_source_can_access_target: Some(options.source_can_access_target), + force_exact_same_document_navigation: true, + explicit_initiator_url: Some(options.initiator_url), + named_hyperlink_popup: options.named_popup, + ..LocationNavigationOptions::default() + }, + ); } pub(crate) fn navigate_location_object<'s>( @@ -347,7 +381,12 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( dispatch_child_navigate_event_for_all_kinds, force_exact_same_document_navigation, explicit_initiator_url, + user_initiated, + hyperlink_source_can_access_target, + named_hyperlink_popup, } = options; + let event_source_element = + source_element.filter(|_| hyperlink_source_can_access_target != Some(false)); let current_href = location_href_slot(scope, location).unwrap_or_default(); let current_url = url::Url::parse(¤t_href).ok(); let raw_target_is_fragment_only = raw_target @@ -511,11 +550,11 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( ), true, true, - false, + user_initiated, None, None, None, - source_element, + event_source_element, ) }); if navigate_outcome @@ -546,6 +585,27 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( { return; } + // Preserve synchronous fragment activation only after the navigate event + // accepts it. Intercepted navigation owns its scroll timing separately. + if hyperlink_source_can_access_target.is_some() + && runtime_window_is_global(scope, owner) + && !navigate_outcome + .as_ref() + .is_some_and(|outcome| outcome.intercepted) + && let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) + && let Err(error) = crate::native_bridge::element::scroll_to_document_fragment_target( + scope, host_ptr, &resolved, + ) + { + if let Some(message) = v8_string( + scope, + &format!("Layout failed while scrolling to fragment: {error}"), + ) { + let exception = v8::Exception::error(scope, message); + scope.throw_exception(exception); + } + return; + } if let Some(navigation) = navigation { cancel_pending_same_document_navigation_finishes(scope, navigation); } @@ -685,8 +745,12 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( && resolved.scheme() == "javascript" { if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { - let _ = unsafe { &mut *host_ptr } - .navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind); + if let Some(popup) = named_hyperlink_popup { + popup.navigate(scope, host_ptr, resolved.as_str()); + } else { + let _ = unsafe { &mut *host_ptr } + .navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind); + } } return; } @@ -697,6 +761,7 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( child_browsing_context_handle_for_runtime_owner(scope, owner) }; if child_handle.is_none() + && hyperlink_source_can_access_target != Some(false) && let Some(navigation) = super::navigation_window::window_navigation_for_holder(scope, owner) { @@ -731,12 +796,14 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( navigation_type, false, false, - true, - false, + current_url.as_ref().is_some_and(|current| { + super::history_mutation::document_can_have_url_rewritten(current, &resolved) + }), + user_initiated, None, destination_state, None, - source_element, + event_source_element, ); if outcome.abort_error.is_some() { return; @@ -769,8 +836,12 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( if let Some(popup_id) = popup_id { if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) { - let _ = unsafe { &mut *host_ptr } - .navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind); + if let Some(popup) = named_hyperlink_popup { + popup.navigate(scope, host_ptr, resolved.as_str()); + } else { + let _ = unsafe { &mut *host_ptr } + .navigate_lightweight_popup_window_to_url(scope, popup_id, resolved, kind); + } } return; } @@ -790,6 +861,7 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( }; if (matches!(kind, LocationNavigationKind::Assign) || dispatch_child_navigate_event_for_all_kinds) + && hyperlink_source_can_access_target != Some(false) && !is_javascript_url && let Some(window) = window_for_child_cross_document_location_navigation(scope, owner) && !dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data( @@ -801,8 +873,8 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( LocationNavigationKind::Replace => "replace", LocationNavigationKind::Reload => "reload", }, - source_element, - false, + event_source_element, + user_initiated, None, None, ) @@ -841,14 +913,19 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>( return; } - if resolved.scheme() != "javascript" { + // A new top-level hyperlink leaves the source Location and history intact + // until the browser commits the response, just as the activation path did. + let browser_owned_hyperlink = hyperlink_source_can_access_target.is_some() && !exact_same_href; + if !browser_owned_hyperlink && resolved.scheme() != "javascript" { sync_location_object(scope, location, resolved.as_str()); } let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { return; }; - let entry_seed = if matches!(kind, LocationNavigationKind::Reload) { + let entry_seed = if browser_owned_hyperlink { + None + } else if matches!(kind, LocationNavigationKind::Reload) { history_entry_seed_for_reload(scope, owner) } else { history_entry_seed_for_cross_document_location(scope, owner, &resolved, kind) @@ -1047,6 +1124,7 @@ pub(crate) fn dispatch_top_level_navigation_event_with_source_element<'s>( can_intercept: bool, user_initiated: bool, download_request: Option<&str>, + form_data: Option>, ) -> bool { let Some(navigation) = super::navigation_window::window_navigation_for_holder(scope, owner) else { @@ -1060,7 +1138,7 @@ pub(crate) fn dispatch_top_level_navigation_event_with_source_element<'s>( cancel_pending_precommit_history_traversal(scope, navigation); cancel_active_intercepted_same_document_navigation(scope, navigation); cancel_pending_same_document_navigation_finishes_including_reentrant(scope, navigation); - let outcome = dispatch_navigation_navigate_event_with_outcome( + let outcome = dispatch_navigation_navigate_event_with_form_data_and_outcome( scope, navigation, href, @@ -1072,6 +1150,7 @@ pub(crate) fn dispatch_top_level_navigation_event_with_source_element<'s>( download_request, None, None, + form_data, source_element, ); if outcome.abort_error.is_some() { diff --git a/moli-renderer-v8/src/native_bridge/context_host/popups.rs b/moli-renderer-v8/src/native_bridge/context_host/popups.rs index 6c0d5afa33..fe93991340 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -839,6 +839,13 @@ impl JsContextHost { } } + pub(crate) fn named_lightweight_popup_id(&self, target_name: &str) -> Option { + let name = trackable_lightweight_popup_window_name(target_name)?; + self.lightweight_popup_browsing_contexts.iter().find_map(|(id, record)| { + (record.is_open() && record.name_lookup_allowed && record.name.get() == name).then_some(*id) + }) + } + pub(crate) fn open_lightweight_popup_window<'s>( &mut self, scope: &mut v8::PinScope<'s, '_>, @@ -851,16 +858,7 @@ impl JsContextHost { creator_policy_container: DocumentPolicyContainer, update_existing_opener: bool, ) -> Option> { - if let Some(name) = trackable_lightweight_popup_window_name(target_name) - && let Some(popup_id) = - self.lightweight_popup_browsing_contexts - .iter() - .find_map(|(id, record)| { - (record.is_open() - && record.name_lookup_allowed - && record.name.get() == name) - .then_some(*id) - }) + if let Some(popup_id) = self.named_lightweight_popup_id(target_name) { let window = self.lightweight_popup_window(scope, popup_id)?; if update_existing_opener && let Some(opener) = opener { diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index c75b10a8a5..b09ed2c5ef 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -196,7 +196,7 @@ use super::document::{ }; pub(crate) use activation::perform_clipboard_key_default_action; pub(crate) use activation::{ - SpecialBrowsingContextTarget, navigate_existing_browsing_context_target, + NamedHyperlinkPopup, SpecialBrowsingContextTarget, navigate_existing_browsing_context_target, navigate_named_iframe_target, }; pub(crate) use activation::{ @@ -206,7 +206,7 @@ pub(crate) use activation::{ dispatched_click_activation_target, finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action, perform_click_default_action_for_dispatched_event, perform_drop_default_action, prepare_legacy_activation_for_dispatched_click, - replace_contenteditable_selection, scroll_to_url_fragment_or_top, + replace_contenteditable_selection, scroll_to_document_fragment_target, scroll_to_url_fragment_or_top, select_contenteditable_contents, }; pub(crate) use activation::{document_copy_command_supported, run_document_copy_command}; diff --git a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs index 288abaa145..3d2af8be1b 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/default_action.rs @@ -1,7 +1,4 @@ -use crate::context_bootstrap::{ - LocationNavigationKind, navigate_location_object_with_source_element, - readonly_data_transfer_for_input, selection_value_for_window, -}; +use crate::context_bootstrap::{readonly_data_transfer_for_input, selection_value_for_window}; use crate::dom::{ forms::{ButtonTypeState, InputType}, native::{Element, Node, SelectedFile}, @@ -41,7 +38,7 @@ use super::super::{ }; use super::targets::{ SpecialBrowsingContextTarget, named_iframe_target_handle_for_navigation, - navigate_element_target_browsing_context, navigate_hyperlink_source_browsing_context, + navigate_element_target_browsing_context, }; fn data_transfer_selected_files<'s>( @@ -1900,66 +1897,6 @@ fn anchor_click_default_action( unreachable!("download hyperlink policy returned before target selection") } }; - let special_target = target_name - .as_deref() - .and_then(SpecialBrowsingContextTarget::parse); - if (target_name.is_none() || special_target == Some(SpecialBrowsingContextTarget::Current)) - && navigate_hyperlink_source_browsing_context(scope, runtime_ptr, handle, &resolved) - { - return None; - } - if (target_name.is_none() || special_target == Some(SpecialBrowsingContextTarget::Current)) - && runtime.host_document().url().as_str() == resolved - { - let global = scope.get_current_context().global(scope); - if let Some(location) = global - .get(scope, v8str(scope, "location").into()) - .and_then(|value| v8::Local::::try_from(value).ok()) - { - let source_element = node_wrapper_from_handle(scope, handle); - navigate_location_object_with_source_element( - scope, - location, - LocationNavigationKind::Assign, - Some(resolved), - source_element, - ); - } - return None; - } - if (target_name.is_none() - || matches!( - special_target, - Some( - SpecialBrowsingContextTarget::Current - | SpecialBrowsingContextTarget::Top - | SpecialBrowsingContextTarget::Parent - ) - )) - && let Ok(url) = url::Url::parse(&resolved) - && same_document_fragment_target(runtime.host_document().url(), &url) - { - if let Err(error) = scroll_to_document_fragment_target(scope, runtime_ptr, &url) { - throw_activation_layout_error(scope, "scrolling to fragment", error); - return None; - } - let global = scope.get_current_context().global(scope); - if let Some(location) = global - .get(scope, v8str(scope, "location").into()) - .and_then(|value| v8::Local::::try_from(value).ok()) - { - let source_element = node_wrapper_from_handle(scope, handle); - navigate_location_object_with_source_element( - scope, - location, - LocationNavigationKind::Assign, - Some(resolved), - source_element, - ); - } - return None; - } - let source_element = node_wrapper_from_handle(scope, handle); if click_listener_changed_named_child_navigation( scope, runtime_ptr, @@ -1976,7 +1913,6 @@ fn anchor_click_default_action( handle, target_name.as_deref(), &resolved, - source_element, user_initiated, popup_disposition, ); @@ -2160,7 +2096,7 @@ pub(crate) fn scroll_to_url_fragment_or_top( Ok(()) } -fn scroll_to_document_fragment_target( +pub(crate) fn scroll_to_document_fragment_target( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, target_url: &url::Url, diff --git a/moli-renderer-v8/src/native_bridge/element/activation/mod.rs b/moli-renderer-v8/src/native_bridge/element/activation/mod.rs index ac5a7ebad5..c3eb8bddfb 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/mod.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/mod.rs @@ -15,11 +15,11 @@ pub(crate) use default_action::{ dispatched_click_activation_target, finish_legacy_activation_for_dispatched_click, perform_auxiliary_link_default_action, perform_click_default_action_for_dispatched_event, perform_drop_default_action, prepare_legacy_activation_for_dispatched_click, - replace_contenteditable_selection, scroll_to_url_fragment_or_top, + replace_contenteditable_selection, scroll_to_document_fragment_target, scroll_to_url_fragment_or_top, select_contenteditable_contents, }; pub(in crate::native_bridge) use targets::choose_form_navigation_target; pub(crate) use targets::{ - SpecialBrowsingContextTarget, navigate_existing_browsing_context_target, + NamedHyperlinkPopup, SpecialBrowsingContextTarget, navigate_existing_browsing_context_target, navigate_named_iframe_target, }; diff --git a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs index 472d8f7a98..0bcc5215e6 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs @@ -71,6 +71,30 @@ struct ElementPopupRelations { suppress_referrer: bool, } +pub(crate) struct NamedHyperlinkPopup { + source_handle: DomHandle, + target_name: String, + disposition: RendererPopupDisposition, +} + +impl NamedHyperlinkPopup { + pub(crate) fn navigate( + &self, + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + href: &str, + ) { + navigate_element_popup_target( + scope, + runtime_ptr, + self.source_handle, + &self.target_name, + href, + self.disposition, + ); + } +} + fn element_popup_relations( runtime: &JsContextHost, source_handle: DomHandle, @@ -579,137 +603,137 @@ pub(crate) fn navigate_existing_browsing_context_target<'s>( navigate_special_target_from_window(scope, source_window, Some(target), resolved_url) } -pub(super) fn navigate_hyperlink_source_browsing_context( +/// Resolve an existing target without reading script-visible WindowProxy properties. +fn existing_hyperlink_target( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, source_handle: DomHandle, - resolved_url: &str, -) -> bool { - let Some(dispatch_scope) = - browsing_context_dispatch_scope_for_node(scope, runtime_ptr, source_handle) - else { - return false; + source: crate::native_bridge::OwnerDispatchScope, + target_name: Option<&str>, +) -> Option { + use crate::native_bridge::OwnerDispatchScope; + let runtime = unsafe { &*runtime_ptr }; + let Some(name) = target_name else { + return Some(source); }; - match dispatch_scope { - crate::native_bridge::OwnerDispatchScope::Top => false, - crate::native_bridge::OwnerDispatchScope::Child(handle) => unsafe { &mut *runtime_ptr } - .navigate_child_browsing_context_to_url(scope, handle, resolved_url), - crate::native_bridge::OwnerDispatchScope::LightweightPopup(popup_id) => { - let Some(source_window) = - unsafe { &*runtime_ptr }.lightweight_popup_window(scope, popup_id) - else { - return false; - }; - navigate_special_target_from_window( - scope, - source_window, - Some(SpecialBrowsingContextTarget::Current), - Some(resolved_url), - ) - .is_some() + match SpecialBrowsingContextTarget::parse(name) { + Some(SpecialBrowsingContextTarget::Current) => Some(source), + Some( + special @ (SpecialBrowsingContextTarget::Top | SpecialBrowsingContextTarget::Parent), + ) => { + let mut target = source; + while let OwnerDispatchScope::Child(handle) = target { + target = runtime.owner_dispatch_scope_for_node(handle)?; + if special == SpecialBrowsingContextTarget::Parent { + break; + } + } + Some(target) + } + Some(SpecialBrowsingContextTarget::Blank) => None, + None => { + let document = runtime.dom_host().owner_document_handle(source_handle); + named_iframe_target_handle_for_navigation(scope, runtime_ptr, name, document) + .map(OwnerDispatchScope::Child) + .or_else(|| { + unsafe { &*runtime_ptr } + .named_lightweight_popup_id(name) + .map(OwnerDispatchScope::LightweightPopup) + }) } } } -pub(in crate::native_bridge) fn navigate_element_target_browsing_context<'s>( - scope: &mut v8::PinScope<'s, '_>, +pub(in crate::native_bridge) fn navigate_element_target_browsing_context( + scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, source_handle: DomHandle, target_name: Option<&str>, resolved_url: &str, - source_element: Option>, user_initiated: bool, popup_disposition: RendererPopupDisposition, ) -> bool { + use crate::native_bridge::OwnerDispatchScope; if !element_javascript_url_allowed_by_csp(scope, runtime_ptr, source_handle, resolved_url) { return true; } - let special_target = target_name.and_then(SpecialBrowsingContextTarget::parse); - if special_target == Some(SpecialBrowsingContextTarget::Blank) { - return navigate_element_popup_target( - scope, - runtime_ptr, - source_handle, - "_blank", - resolved_url, - popup_disposition, - ); - } - if let Some(target_name) = target_name - && special_target.is_none() - { - let source_document = unsafe { &*runtime_ptr } - .dom_host() - .node(source_handle) - .and_then(|node| node.owner_document()); - return navigate_named_iframe_target_from_document( - scope, - runtime_ptr, - target_name, - resolved_url, - source_document, - source_element, - ) || navigate_element_popup_target( - scope, - runtime_ptr, - source_handle, - target_name, - resolved_url, - popup_disposition, - ); - } - let Some(dispatch_scope) = - browsing_context_dispatch_scope_for_node(scope, runtime_ptr, source_handle) - else { + let Some(source) = unsafe { &*runtime_ptr }.owner_dispatch_scope_for_node(source_handle) else { return false; }; - let Some(source_window) = - browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope) + let Some(target) = + existing_hyperlink_target(scope, runtime_ptr, source_handle, source, target_name) else { - return false; + return target_name.is_some_and(|name| { + navigate_element_popup_target( + scope, + runtime_ptr, + source_handle, + name, + resolved_url, + popup_disposition, + ) + }); }; - let Some(target_window) = - special_target_window_from_window(scope, source_window, special_target) - else { - return false; - }; - if resolved_url.is_empty() { + let runtime = unsafe { &mut *runtime_ptr }; + if runtime.sandbox_blocks_ancestor_navigation(source, target) { return true; } - if matches!( - runtime_window_dispatch_scope(scope, target_window), - Some(crate::native_bridge::OwnerDispatchScope::Top) - ) { - // Check the hyperlink's native Document before any event or intercepted commit. - if let crate::native_bridge::OwnerDispatchScope::Child(source_frame) = dispatch_scope - && sandbox_blocks_ancestor_or_top_navigation_from_source( - scope, - source_frame, - target_window, - ) - { - return true; - } - let runtime = unsafe { &*runtime_ptr }; - let can_intercept = url::Url::parse(resolved_url) - .is_ok_and(|url| moli_url::same_origin(runtime.document_url(), &url)); - // Every top-target hyperlink dispatches once after resolving its owner. - // Cancellation or interception must prevent the navigation from queuing. - if !dispatch_top_level_navigation_event_with_source_element( - scope, - target_window, - resolved_url, - "push", - source_element, - can_intercept, + let Some(document) = runtime.dom_host().owner_document_handle(source_handle) else { + return false; + }; + let initiator_url = runtime.document_url_for_handle(document); + let Some(source_element) = crate::util::node_wrapper_from_handle(scope, source_handle) else { + return false; + }; + // Resolve the native target realm before reading its private Location slot. + // The source's access check controls event dispatch and sourceElement exposure. + let context = match target { + OwnerDispatchScope::Child(handle) => runtime + .ensure_prebootstrapped_child_default_context(scope, handle) + .ok(), + _ => runtime + .current_registered_window_execution_context_identity(target) + .and_then(|identity| runtime.window_execution_context(scope, identity.owner(), target)) + .map(|(_, context)| context), + }; + let Some(context) = context else { return false }; + let can_access = runtime + .current_registered_window_execution_context_identity(source) + .is_some_and(|identity| { + runtime.window_execution_context_can_access_dispatch_scope(identity, target) + }); + let scope = &mut v8::ContextScope::new(scope, context); + let window = match target { + OwnerDispatchScope::LightweightPopup(id) => runtime.lightweight_popup_window(scope, id), + _ => Some(context.global(scope)), + }; + let Some(location) = window + .and_then(|window| crate::context_bootstrap::window_location_for_holder(scope, window)) + else { + return false; + }; + crate::context_bootstrap::navigate_location_object_for_hyperlink( + scope, + location, + resolved_url, + source_element, + crate::context_bootstrap::HyperlinkNavigationOptions { user_initiated, - None, - ) { - return true; - } - return queue_top_level_location_navigation(scope, runtime_ptr, resolved_url); - } - navigate_target_window_location(scope, target_window, resolved_url) + source_can_access_target: can_access, + initiator_url, + named_popup: target_name + .filter(|name| { + matches!(target, OwnerDispatchScope::LightweightPopup(_)) + && SpecialBrowsingContextTarget::parse(name).is_none() + }) + .map(|name| NamedHyperlinkPopup { + source_handle, + target_name: name.to_owned(), + disposition: popup_disposition, + }), + }, + ); + true } pub(crate) fn navigate_named_iframe_target<'s>( diff --git a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs index 518cb49a98..19c2c30091 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs @@ -870,7 +870,7 @@ pub(in crate::native_bridge) fn apply_planned_form_navigation( OwnerDispatchScope::Top => crate::context_bootstrap::dispatch_top_level_navigation_event_with_source_element( scope, window, navigation.request.url().as_str(), navigation.mutation.navigation_type(), - Some(source_element), true, navigation.user_initiated, form_data, + Some(source_element), true, navigation.user_initiated, None, form_data, ), OwnerDispatchScope::Child(_) | OwnerDispatchScope::LightweightPopup(_) => crate::context_bootstrap::dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data( diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs index 489f1304c7..0e5d211a99 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/details.rs @@ -223,3 +223,22 @@ fn fragment_navigation_reveals_closed_details_ancestors_but_not_their_summary() r##"{"targetState":[true,true,"#target"],"summaryState":[true,false,"#inner-summary"]}"## ); } + +#[test] +fn canceled_fragment_hyperlink_does_not_reveal_closed_details() { + let mut vm = new_parsed_test_vm( + "https://details-canceled-fragment.test/", + r##"Go +
Summary
Target
"##, + ); + let result = vm + .eval( + r#"(() => { + navigation.onnavigate = event => event.preventDefault(); + document.getElementById('link').click(); + return JSON.stringify([document.getElementById('closed').open, location.hash]); + })()"#, + ) + .expect("canceled fragment activation"); + assert_eq!(result, r#"[false,""]"#); +} diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs index dd41457798..365cb0b70e 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms.rs @@ -3955,6 +3955,12 @@ fn anchor_click_queues_pending_top_level_location_navigation() { ) .expect("anchor click should execute"); + assert_eq!( + vm.eval("location.href") + .expect("source Location remains readable"), + "https://anchor-click-navigation.test/path/index.html", + "a pending hyperlink must not change the source URL before commit" + ); let pending = vm .take_pending_location_navigation_with_seed() .expect("anchor click should queue a pending location navigation");