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");