From c23efff0ced20860f994b8a7277625c647e533ca Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 14 Sep 2026 22:33:17 +0800 Subject: [PATCH] fix(forms): preserve popup navigation source and referrer Route form popup submissions through the source element navigation path. Preserve the originating child Window and apply rel and referrer policies to the resulting document. Cover form and submitter targets, same-origin child sources, and cross-origin policy filtering. Factor the existing referrer selection algorithm so local navigation targets can use it without creating an HTTP header. Source: aac5e678d381119d8318bc21a930f574913a0ec2, 8305e0dcd902663843e5b6033036692c203a1925 --- moli-fetch/src/lib.rs | 3 +- moli-fetch/src/referrer_policy.rs | 31 +++ .../element/activation/default_action.rs | 37 +--- .../element/activation/targets.rs | 178 +++++---------- .../src/script_vm/tests/browser_api/misc.rs | 48 +++-- .../script_vm/tests/browser_api/navigation.rs | 204 ++++++++++++++++++ 6 files changed, 329 insertions(+), 172 deletions(-) diff --git a/moli-fetch/src/lib.rs b/moli-fetch/src/lib.rs index 1195f38921..4e2039dcb8 100644 --- a/moli-fetch/src/lib.rs +++ b/moli-fetch/src/lib.rs @@ -58,7 +58,8 @@ pub use network_fetch_result::{ NetworkResponseObservation, }; pub use referrer_policy::{ - DEFAULT_REFERRER_POLICY, origin_referrer_url, referrer_header_value, sanitized_referrer_url, + DEFAULT_REFERRER_POLICY, origin_referrer_url, referrer_header_value, referrer_value, + sanitized_referrer_url, }; pub use request::{ BrowserNavigationRequestKind, BrowserRequestMetadata, FetchPriorityHint, Request, RequestAuth, diff --git a/moli-fetch/src/referrer_policy.rs b/moli-fetch/src/referrer_policy.rs index 01a1f218dc..e8a041f213 100644 --- a/moli-fetch/src/referrer_policy.rs +++ b/moli-fetch/src/referrer_policy.rs @@ -12,6 +12,25 @@ pub fn referrer_header_value( if !matches!(request_url.scheme(), "http" | "https") { return None; } + + referrer_value( + referrer_url, + request_url, + referrer_policy, + document_referrer_policy, + ) +} + +/// Applies Referrer Policy without requiring an HTTP(S) destination. +/// +/// Local navigations still use the selected value for `Document.referrer`, +/// even though they do not emit a `Referer` request header. +pub fn referrer_value( + referrer_url: &Url, + request_url: &Url, + referrer_policy: Option<&str>, + document_referrer_policy: Option<&str>, +) -> Option { if !matches!(referrer_url.scheme(), "http" | "https") { return None; } @@ -207,4 +226,16 @@ mod tests { None ); } + + #[test] + fn non_http_navigation_target_still_has_a_policy_selected_referrer() { + let source = url("https://example.com/docs/page.html?x=1#section"); + let target = url("about:blank"); + + assert_eq!( + referrer_value(&source, &target, None, None), + Some("https://example.com/".to_owned()) + ); + assert_eq!(referrer_header_value(&source, &target, None, None), None); + } } 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 6d47e6df11..b6b0f44ee5 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 @@ -35,8 +35,7 @@ use super::super::{ }; use super::targets::{ SpecialBrowsingContextTarget, named_iframe_target_handle_for_navigation, - navigate_hyperlink_source_browsing_context, navigate_hyperlink_target_browsing_context, - navigate_target_browsing_context, + navigate_element_target_browsing_context, navigate_hyperlink_source_browsing_context, }; const BUTTON_COMMAND_FOR_ELEMENT_SLOT: &str = "__moliButtonCommandForElement"; @@ -1978,7 +1977,7 @@ fn anchor_click_default_action( ) { return None; } - let _ = navigate_hyperlink_target_browsing_context( + let _ = navigate_element_target_browsing_context( scope, runtime_ptr, handle, @@ -2287,30 +2286,6 @@ pub(in crate::native_bridge) fn navigate_form_target_browsing_context( resolved_url: &str, ) -> bool { let special_target = target_name.and_then(SpecialBrowsingContextTarget::parse); - let exposes_opener = { - let runtime = unsafe { &*runtime_ptr }; - let rel = runtime - .dom_host() - .node(form_handle) - .and_then(Node::as_element) - .and_then(|element| element.attribute("rel")) - .unwrap_or_default(); - let mut has_opener = false; - let mut has_noopener = false; - let mut has_noreferrer = false; - for token in rel.split_ascii_whitespace() { - if token.eq_ignore_ascii_case("opener") { - has_opener = true; - } else if token.eq_ignore_ascii_case("noopener") { - has_noopener = true; - } else if token.eq_ignore_ascii_case("noreferrer") { - has_noreferrer = true; - } - } - !has_noreferrer - && !has_noopener - && (has_opener || special_target != Some(SpecialBrowsingContextTarget::Blank)) - }; if target_name.is_none() || special_target == Some(SpecialBrowsingContextTarget::Current) { let runtime = unsafe { &*runtime_ptr }; let document_handle = runtime @@ -2347,13 +2322,15 @@ pub(in crate::native_bridge) fn navigate_form_target_browsing_context( unsafe { &mut *runtime_ptr }.record_pending_location_navigation(url, None); return true; } - navigate_target_browsing_context( + let source_element = node_wrapper_from_handle(scope, form_handle); + navigate_element_target_browsing_context( scope, runtime_ptr, + form_handle, target_name, resolved_url, - None, - exposes_opener, + source_element, + RendererPopupDisposition::Foreground, ) } 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 d94c8d3042..33821ad829 100644 --- a/moli-renderer-v8/src/native_bridge/element/activation/targets.rs +++ b/moli-renderer-v8/src/native_bridge/element/activation/targets.rs @@ -62,52 +62,17 @@ fn queue_top_level_location_navigation( true } -fn queue_popup_target_navigation( - scope: &mut v8::PinScope<'_, '_>, - runtime_ptr: *mut JsContextHost, - target_name: &str, - resolved_url: &str, - exposes_opener: bool, -) -> bool { - let runtime = unsafe { &mut *runtime_ptr }; - let dispatch_scope = runtime.entered_owner_dispatch_scope(scope); - let Some((_, root_document, source)) = - runtime.renderer_window_document_source_for_dispatch_scope(dispatch_scope) - else { - return false; - }; - let window_open_event = RendererPendingWindowOpenEvent::browser_window( - resolved_url, - target_name, - runtime.protocol_user_gesture_activation(), - ); - runtime.record_pending_popup_activation( - RendererPendingPopupActivation::window( - root_document, - source, - exposes_opener, - None, - resolved_url.to_owned(), - target_name.to_owned(), - RendererPopupDisposition::Foreground, - ) - .with_initial_auxiliary_state(None, None), - Some(window_open_event), - ); - true -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] -struct HyperlinkPopupRelations { +struct ElementPopupRelations { suppress_opener: bool, suppress_referrer: bool, } -fn hyperlink_popup_relations( +fn element_popup_relations( runtime: &JsContextHost, source_handle: DomHandle, target_name: &str, -) -> HyperlinkPopupRelations { +) -> ElementPopupRelations { let rel = runtime .dom_host() .node(source_handle) @@ -126,7 +91,7 @@ fn hyperlink_popup_relations( has_noreferrer = true; } } - HyperlinkPopupRelations { + ElementPopupRelations { suppress_opener: has_noreferrer || has_noopener || (target_name.eq_ignore_ascii_case("_blank") && !has_opener), @@ -134,24 +99,43 @@ fn hyperlink_popup_relations( } } -struct HyperlinkPopupCreator<'s> { +struct ElementPopupCreator<'s> { opener: v8::Local<'s, v8::Object>, base_url: url::Url, policy_container: DocumentPolicyContainer, document_url: url::Url, } -fn hyperlink_popup_creator<'s>( +fn element_popup_referrer_policy( + runtime: &JsContextHost, + source_handle: DomHandle, +) -> Option<&'static str> { + let element = runtime + .dom_host() + .node(source_handle) + .and_then(crate::dom::native::Node::as_element)?; + if !matches!( + (element.namespace(), element.local_name()), + ("http://www.w3.org/1999/xhtml", "a" | "area") | ("http://www.w3.org/2000/svg", "a") + ) { + return None; + } + let policy = + super::super::canonical_referrer_policy_value(element.attribute("referrerpolicy")?); + (!policy.is_empty()).then_some(policy) +} + +fn element_popup_creator<'s>( scope: &mut v8::PinScope<'s, '_>, runtime_ptr: *mut JsContextHost, source_handle: DomHandle, -) -> Option> { +) -> Option> { let runtime = unsafe { &*runtime_ptr }; let document = runtime.dom_host().owner_document_handle(source_handle)?; let base_url = runtime.document_base_url_for_handle(document); let document_url = runtime.document_url_for_handle(document); if document == runtime.document_handle() { - return Some(HyperlinkPopupCreator { + return Some(ElementPopupCreator { opener: scope.get_current_context().global(scope), base_url, policy_container: runtime.document_policy_container().clone(), @@ -159,7 +143,7 @@ fn hyperlink_popup_creator<'s>( }); } if let Some(popup_id) = runtime.lightweight_popup_id_for_document_handle(document) { - return Some(HyperlinkPopupCreator { + return Some(ElementPopupCreator { opener: runtime.lightweight_popup_window(scope, popup_id)?, base_url, policy_container: runtime @@ -168,10 +152,16 @@ fn hyperlink_popup_creator<'s>( document_url, }); } - None + let frame = runtime.child_browsing_context_handle_by_document_handle(scope, document)?; + Some(ElementPopupCreator { + opener: runtime.existing_child_browsing_context_window_wrapper(scope, frame)?, + base_url, + policy_container: runtime.child_browsing_context_policy_container_snapshot(frame)?, + document_url, + }) } -fn navigate_hyperlink_popup_target( +fn navigate_element_popup_target( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, source_handle: DomHandle, @@ -179,7 +169,7 @@ fn navigate_hyperlink_popup_target( resolved_url: &str, disposition: RendererPopupDisposition, ) -> bool { - let relations = hyperlink_popup_relations(unsafe { &*runtime_ptr }, source_handle, target_name); + let relations = element_popup_relations(unsafe { &*runtime_ptr }, source_handle, target_name); let Some(dispatch_scope) = browsing_context_dispatch_scope_for_node(scope, runtime_ptr, source_handle) else { @@ -190,7 +180,7 @@ fn navigate_hyperlink_popup_target( else { return false; }; - let Some(mut creator) = hyperlink_popup_creator(scope, runtime_ptr, source_handle) else { + let Some(mut creator) = element_popup_creator(scope, runtime_ptr, source_handle) else { let runtime = unsafe { &mut *runtime_ptr }; let window_open_event = RendererPendingWindowOpenEvent::browser_window( resolved_url, @@ -215,7 +205,18 @@ fn navigate_hyperlink_popup_target( creator.policy_container.document_referrer = if relations.suppress_referrer { String::new() } else { - creator.document_url.to_string() + let policy = element_popup_referrer_policy(unsafe { &*runtime_ptr }, source_handle); + url::Url::parse(resolved_url) + .ok() + .and_then(|target| { + moli_fetch::referrer_value( + &creator.document_url, + &target, + policy, + creator.policy_container.referrer_policy.as_deref(), + ) + }) + .unwrap_or_default() }; let opener = (!relations.suppress_opener).then_some(creator.opener); let runtime = unsafe { &mut *runtime_ptr }; @@ -273,7 +274,7 @@ fn navigate_hyperlink_popup_target( true } -fn hyperlink_javascript_url_allowed_by_csp( +fn element_javascript_url_allowed_by_csp( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, source_handle: DomHandle, @@ -414,76 +415,7 @@ pub(super) fn navigate_hyperlink_source_browsing_context( } } -pub(crate) fn navigate_target_browsing_context<'s>( - scope: &mut v8::PinScope<'s, '_>, - runtime_ptr: *mut JsContextHost, - target_name: Option<&str>, - resolved_url: &str, - source_element: Option>, - exposes_opener: bool, -) -> bool { - let special_target = target_name.and_then(SpecialBrowsingContextTarget::parse); - if target_name.is_none() - || matches!( - special_target, - Some( - SpecialBrowsingContextTarget::Current - | SpecialBrowsingContextTarget::Top - | SpecialBrowsingContextTarget::Parent - ) - ) - { - return match special_target { - Some(target) => { - navigate_existing_browsing_context_target(scope, runtime_ptr, target, resolved_url) - .is_some() - } - None => { - let dispatch_scope = unsafe { &*runtime_ptr }.entered_owner_dispatch_scope(scope); - let Some(source_window) = - browsing_context_window_for_dispatch_scope(scope, runtime_ptr, dispatch_scope) - else { - return false; - }; - navigate_special_target_from_window( - scope, - runtime_ptr, - source_window, - None, - resolved_url, - ) - .is_some() - } - }; - } - if special_target == Some(SpecialBrowsingContextTarget::Blank) { - return queue_popup_target_navigation( - scope, - runtime_ptr, - "_blank", - resolved_url, - exposes_opener, - ); - } - let Some(target_name) = target_name else { - unreachable!("missing target was handled as the source browsing context"); - }; - navigate_named_iframe_target( - scope, - runtime_ptr, - target_name, - resolved_url, - source_element, - ) || queue_popup_target_navigation( - scope, - runtime_ptr, - target_name, - resolved_url, - exposes_opener, - ) -} - -pub(in crate::native_bridge) fn navigate_hyperlink_target_browsing_context<'s>( +pub(in crate::native_bridge) fn navigate_element_target_browsing_context<'s>( scope: &mut v8::PinScope<'s, '_>, runtime_ptr: *mut JsContextHost, source_handle: DomHandle, @@ -492,12 +424,12 @@ pub(in crate::native_bridge) fn navigate_hyperlink_target_browsing_context<'s>( source_element: Option>, popup_disposition: RendererPopupDisposition, ) -> bool { - if !hyperlink_javascript_url_allowed_by_csp(scope, runtime_ptr, source_handle, resolved_url) { + 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_hyperlink_popup_target( + return navigate_element_popup_target( scope, runtime_ptr, source_handle, @@ -515,7 +447,7 @@ pub(in crate::native_bridge) fn navigate_hyperlink_target_browsing_context<'s>( target_name, resolved_url, source_element, - ) || navigate_hyperlink_popup_target( + ) || navigate_element_popup_target( scope, runtime_ptr, source_handle, diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs index dd85399cd0..a882eceac0 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/misc.rs @@ -27129,6 +27129,16 @@ async fn spawn_lightweight_popup_response_html_server( io_label: &'static str, policy_header: &'static str, body: &'static str, +) -> (String, tokio::task::JoinHandle<()>) { + spawn_lightweight_popup_html_responses(bind_label, io_label, policy_header, body, 1).await +} + +pub(super) async fn spawn_lightweight_popup_html_responses( + bind_label: &'static str, + io_label: &'static str, + policy_header: &'static str, + body: &'static str, + response_count: usize, ) -> (String, tokio::task::JoinHandle<()>) { let listener = tokio::net::TcpListener::bind("127.0.0.1:0") .await @@ -27139,24 +27149,26 @@ async fn spawn_lightweight_popup_response_html_server( let server = tokio::spawn(async move { use tokio::io::{AsyncReadExt, AsyncWriteExt}; - let (mut stream, _) = listener - .accept() - .await - .unwrap_or_else(|_| panic!("accept {io_label} request")); - let mut buffer = [0; 1024]; - let _ = stream - .read(&mut buffer) - .await - .unwrap_or_else(|_| panic!("read {io_label} request")); - let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n{policy_header}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", - body.len(), - body - ); - stream - .write_all(response.as_bytes()) - .await - .unwrap_or_else(|_| panic!("write {io_label} response")); + for _ in 0..response_count { + let (mut stream, _) = listener + .accept() + .await + .unwrap_or_else(|_| panic!("accept {io_label} request")); + let mut buffer = [0; 1024]; + let _ = stream + .read(&mut buffer) + .await + .unwrap_or_else(|_| panic!("read {io_label} request")); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\n{policy_header}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + body + ); + stream + .write_all(response.as_bytes()) + .await + .unwrap_or_else(|_| panic!("write {io_label} response")); + } }); (format!("http://{addr}/popup.html"), server) } diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs index 9a10908a22..1537fe3968 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/navigation.rs @@ -51,6 +51,210 @@ fn form_target_blank_reloads_rel_opener_policy_for_each_submission() { } } +#[tokio::test] +async fn form_target_blank_preserves_source_referrer_and_relations() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + for child_document in [false, true] { + for submitter in ["form", "button", "input"] { + for (rel, has_opener, has_referrer) in [ + ("", false, true), + ("noopener", false, true), + ("noreferrer", false, false), + ("opener", true, true), + ("noopener noreferrer", false, false), + ("noreferrer opener", false, false), + ("opener noopener", false, true), + ] { + let (popup_url, server) = super::misc::spawn_lightweight_popup_html_responses( + "form popup relation server", + "form popup relation", + "Cache-Control: no-store", + r#""#, + if child_document { 2 } else { 1 }, + ) + .await; + let parent_url = format!( + "{}?source=top#fragment", + popup_url.replace("/popup.html", "/parent.html") + ); + let child_url = format!( + "{}?source=child#fragment", + popup_url.replace("/popup.html", "/child.html") + ); + let mut vm = new_broadcast_channel_page_test_vm_with_loader(&parent_url, &loader); + vm.eval(&format!( + r#" + globalThis.results = []; + globalThis.channel = new BroadcastChannel("form-popup-relations"); + channel.onmessage = event => results.push(event.data); + const html = document.createElement("html"); + const body = document.createElement("body"); + html.appendChild(body); + document.appendChild(html); + if ({child_document}) {{ + globalThis.frame = document.createElement("iframe"); + frame.src = {child_url:?}; + body.appendChild(frame); + }} + 'created' + "#, + )) + .expect("form source document should be created"); + if child_document { + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + &format!("String(frame.contentDocument?.URL === {child_url:?} && frame.contentDocument.readyState === 'complete')"), + "true", + "form source iframe should load", + ) + .await; + } + vm.eval(&format!( + r#" + const owner = {child_document} ? frame.contentDocument : document; + const form = owner.createElement("form"); + form.action = {popup_url:?}; + form.rel = {rel:?}; + owner.body.appendChild(form); + if ({submitter:?} === "form") {{ + form.target = "_BLANK"; + form.submit(); + }} else {{ + const control = owner.createElement({submitter:?}); + control.type = "submit"; + control.formTarget = "_blank"; + form.appendChild(control); + control.click(); + }} + 'submitted' + "#, + )) + .expect("form popup submission should evaluate"); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(results.length)", + "1", + "form popup should report its loaded document relations", + ) + .await; + let actual: serde_json::Value = serde_json::from_str( + &vm.eval("JSON.stringify(results[0])").expect("popup result"), + ) + .expect("popup JSON"); + let source_path = if child_document { + "/child.html" + } else { + "/parent.html" + }; + let referrer = if has_referrer { + format!( + "{}?source={}", + popup_url.replace("/popup.html", source_path), + if child_document { "child" } else { "top" } + ) + } else { + String::new() + }; + assert_eq!( + actual, + serde_json::json!({ + "hasOpener": has_opener, + "referrer": referrer, + "openerPath": has_opener.then_some(source_path), + }), + "child={child_document}, submitter={submitter}, rel={rel:?}", + ); + server.await.expect("form popup server should finish"); + } + } + } +} + +#[tokio::test] +async fn popup_navigation_applies_referrer_policy_and_link_overrides() { + const SOURCE: &str = "http://referrer-source.test/page.html?source=1#fragment"; + const FULL: &str = "http://referrer-source.test/page.html?source=1"; + const ORIGIN: &str = "http://referrer-source.test/"; + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader"); + for (tag, document_policy, element_policy, expected) in [ + ("form", None, None, ORIGIN), + ("form", Some("origin"), None, ORIGIN), + ("form", Some("no-referrer"), None, ""), + ("form", Some("same-origin"), None, ""), + ("form", Some("unsafe-url"), None, FULL), + ("form", Some("no-referrer"), Some("unsafe-url"), ""), + ("a", Some("no-referrer"), Some("unsafe-url"), FULL), + ("area", Some("unsafe-url"), Some("no-referrer"), ""), + ("a", Some("origin"), Some("invalid-policy"), ORIGIN), + ] { + let (popup_url, server) = super::misc::spawn_lightweight_popup_html_responses( + "popup referrer policy server", + "popup referrer policy", + "Cache-Control: no-store", + r#""#, + 1, + ) + .await; + let mut vm = new_broadcast_channel_page_test_vm_with_loader(SOURCE, &loader); + vm.set_response_referrer_policy(document_policy.map(str::to_owned)); + vm.eval(&format!( + r#" + globalThis.results = []; + onmessage = event => results.push(event.data); + const html = document.createElement("html"); + const body = document.createElement("body"); + html.appendChild(body); + document.appendChild(html); + const element = document.createElement({tag:?}); + element.target = "_blank"; + element.rel = "opener"; + element.setAttribute("referrerpolicy", {element_policy:?}); + body.appendChild(element); + if ({tag:?} === "form") {{ + element.action = {popup_url:?}; + element.submit(); + }} else {{ + element.href = {popup_url:?}; + element.click(); + }} + 'submitted' + "#, + element_policy = element_policy.unwrap_or_default(), + )) + .expect("cross-origin popup should be submitted"); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(results.length)", + "1", + "cross-origin popup should report its referrer", + ) + .await; + assert_eq!( + vm.eval("results[0]").expect("reported referrer"), + expected, + "tag={tag}, document policy={document_policy:?}, element policy={element_policy:?}", + ); + server + .await + .expect("popup referrer policy server should finish"); + } +} + #[tokio::test] async fn hyperlink_target_blank_reloads_rel_opener_policy_for_each_activation() { let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).expect("loader");