diff --git a/moli-benchmark/wpt-cross-current/failed-cases.txt b/moli-benchmark/wpt-cross-current/failed-cases.txt index 739ae7539..a1d9ee675 100644 --- a/moli-benchmark/wpt-cross-current/failed-cases.txt +++ b/moli-benchmark/wpt-cross-current/failed-cases.txt @@ -2900,9 +2900,6 @@ html/semantics/embedded-content/the-object-element/object-in-display-none-load-e html/semantics/embedded-content/the-object-element/object-in-object-fallback-2.html html/semantics/embedded-content/the-object-element/usemap-casing.html html/semantics/forms/form-submission-0/form-double-submit-requestsubmit.html -html/semantics/forms/form-submission-target/rel-button-target.html -html/semantics/forms/form-submission-target/rel-form-target.html -html/semantics/forms/form-submission-target/rel-input-target.html html/semantics/forms/the-select-element/customizable-select/base-appearance-inheritance.html html/semantics/forms/the-select-element/customizable-select/picker-icon-animation.html html/semantics/forms/the-select-element/customizable-select/select-base-appearance-computed-style.html diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index b1567ca47..f0959da42 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -6495,6 +6495,9 @@ html/semantics/forms/form-submission-0/reparent-form-during-planned-navigation-t html/semantics/forms/form-submission-0/request-submit-activation.html html/semantics/forms/form-submission-0/submit-entity-body.html html/semantics/forms/form-submission-0/url-encoded.html +html/semantics/forms/form-submission-target/rel-button-target.html +html/semantics/forms/form-submission-target/rel-form-target.html +html/semantics/forms/form-submission-target/rel-input-target.html html/semantics/forms/historical.html html/semantics/forms/resetting-a-form/reset-event.html html/semantics/forms/resetting-a-form/reset-form-2.html 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 6443d84fd..31d13d3ad 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 @@ -41,8 +41,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, }; fn array_like_length(scope: &mut v8::PinScope<'_, '_>, object: v8::Local<'_, v8::Object>) -> u32 { @@ -1922,7 +1921,7 @@ fn anchor_click_default_action( ) { return None; } - let _ = navigate_hyperlink_target_browsing_context( + let _ = navigate_element_target_browsing_context( scope, runtime_ptr, handle, @@ -2247,30 +2246,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 @@ -2337,13 +2312,15 @@ pub(in crate::native_bridge) fn navigate_form_target_browsing_context( unsafe { &mut *runtime_ptr }.record_pending_location_navigation(url, history.entry_seed); 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 2a1dd540c..634e48235 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 86690c37a..eb5c9c4e8 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 @@ -29523,6 +29523,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 @@ -29533,24 +29543,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 75e83d83c..f2fcc402a 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 @@ -96,6 +96,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");