diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index ba7ca69a52..7382a7baad 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -150,11 +150,14 @@ pub(crate) use location_navigation::{ pub(crate) use navigation_cancellation::{ NavigationCancellationReason, inform_about_canceled_navigation_for_window, }; -pub(crate) use navigation_events::dispatch_cross_document_navigation_navigate_event_for_window_with_form_data; pub(crate) use navigation_events::{ construct_original_hash_change_event, dispatch_beforeunload_for_runtime_owner, dispatch_pagehide_for_runtime_owner, dispatch_unload_for_runtime_owner, }; +pub(crate) use navigation_events::{ + dispatch_cross_document_navigation_navigate_event_for_window_with_form_data, + dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data, +}; pub(crate) use navigation_history_pruning::{ NavigationHistoryPrunePlan, apply_navigation_history_prune_plan, finalize_navigation_history_prune, plan_navigation_history_prune, diff --git a/moli-renderer-v8/src/native_bridge/context_host/activity.rs b/moli-renderer-v8/src/native_bridge/context_host/activity.rs index 3fc824cc1d..cc7fc6b52e 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/activity.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/activity.rs @@ -2,6 +2,18 @@ use super::workers::WorkerExecutionState; use super::*; impl JsContextHost { + pub(crate) fn document_has_browsing_context(&self, document: DomHandle) -> bool { + // A detached iframe compatibility window can be a document's defaultView + // without registering a browsing context. Check the native association. + self.dom_host().document_handle() == document + || self + .child_browsing_context_host_for_document_handle(document) + .is_some() + || self + .lightweight_popup_id_for_document_handle(document) + .is_some() + } + pub(crate) fn document_activity(&self) -> moli_page_types::DocumentActivity { self.document_activity } 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 be9a184023..78806be277 100644 --- a/moli-renderer-v8/src/native_bridge/context_host/popups.rs +++ b/moli-renderer-v8/src/native_bridge/context_host/popups.rs @@ -955,7 +955,7 @@ impl JsContextHost { } else { self.start_lightweight_popup_document_load( navigation_task, - initial_url.clone(), + Request::get_with_url(initial_url.clone()), about_blank_url(), initial_document_state, ) @@ -1064,7 +1064,7 @@ impl JsContextHost { } else { self.start_lightweight_popup_document_load( navigation_task, - target_url, + Request::get_with_url(target_url), previous_url, navigation_state, ) @@ -1471,6 +1471,21 @@ impl JsContextHost { popup_id: u64, target_url: Url, kind: crate::context_bootstrap::LocationNavigationKind, + ) -> bool { + self.navigate_lightweight_popup_window_with_request( + scope, + popup_id, + Request::get_with_url(target_url), + kind, + ) + } + + pub(crate) fn navigate_lightweight_popup_window_with_request( + &mut self, + scope: &mut v8::PinScope<'_, '_>, + popup_id: u64, + request: Request, + kind: crate::context_bootstrap::LocationNavigationKind, ) -> bool { if !self.lightweight_popup_is_open(popup_id) { return false; @@ -1480,10 +1495,13 @@ impl JsContextHost { }; let current_url = lightweight_popup_location_href(scope, window) .or_else(|| self.lightweight_popup_location_url(popup_id)); - if !matches!( - kind, - crate::context_bootstrap::LocationNavigationKind::Reload - ) && urls_refer_to_same_document_except_fragment(current_url.as_ref(), &target_url) + let target_url = request.url.clone(); + if request.method == "GET" + && !matches!( + kind, + crate::context_bootstrap::LocationNavigationKind::Reload + ) + && urls_refer_to_same_document_except_fragment(current_url.as_ref(), &target_url) { let previous_url = current_url.clone(); let base_url = self @@ -1512,11 +1530,13 @@ impl JsContextHost { } return true; } - if !matches!( - kind, - crate::context_bootstrap::LocationNavigationKind::Reload - ) && let Some(previous_url) = - self.pending_lightweight_popup_same_document_previous_url(popup_id, &target_url) + if request.method == "GET" + && !matches!( + kind, + crate::context_bootstrap::LocationNavigationKind::Reload + ) + && let Some(previous_url) = + self.pending_lightweight_popup_same_document_previous_url(popup_id, &target_url) { let base_url = self .lightweight_popup_base_url(scope, popup_id) @@ -1626,7 +1646,7 @@ impl JsContextHost { if self .start_lightweight_popup_document_load( navigation_task, - target_url, + request, previous_url, navigation_state, ) @@ -1710,7 +1730,7 @@ impl JsContextHost { } if let Some(load_id) = self.start_lightweight_popup_document_load( navigation_task, - target_url, + Request::get_with_url(target_url), previous_url, navigation_state, ) { @@ -2184,7 +2204,7 @@ impl JsContextHost { fn start_lightweight_popup_document_load( &mut self, task: LightweightPopupNavigationTaskToken, - target_url: Url, + request: Request, previous_url: Url, document_state: LightweightPopupDocumentState, ) -> Option { @@ -2196,7 +2216,10 @@ impl JsContextHost { self.next_lightweight_popup_document_load_id = self.next_lightweight_popup_document_load_id.wrapping_add(1); let target = LightweightPopupDocumentFetchTarget::new(load_id, task); - let local_snapshot = self.materialize_local_child_snapshot_for_url(&target_url); + let target_url = request.url.clone(); + let local_snapshot = (request.method == "GET") + .then(|| self.materialize_local_child_snapshot_for_url(&target_url)) + .flatten(); let (resource_loader, request_origin) = if local_snapshot.is_none() { let source_owner = self.current_lightweight_popup_document_owner(popup_id)?; let initiating_loader = self.document_resource_loader_for_window_owner( @@ -2267,7 +2290,7 @@ impl JsContextHost { let result = async { let response = task_resource_loader .fetch( - Request::get_with_url(target_url.clone()) + request .with_request_origin(request_origin) .with_page_network_policy() .with_top_level_navigation_cookie_context(), diff --git a/moli-renderer-v8/src/native_bridge/document.rs b/moli-renderer-v8/src/native_bridge/document.rs index b67c11694c..b3fc9286cb 100644 --- a/moli-renderer-v8/src/native_bridge/document.rs +++ b/moli-renderer-v8/src/native_bridge/document.rs @@ -1063,18 +1063,6 @@ fn current_script_belongs_to_document( .is_some_and(|owner_document| owner_document == document_handle) } -fn document_has_browsing_context(runtime: &JsContextHost, handle: DomHandle) -> bool { - // Detached iframe compatibility windows can become a document's defaultView - // without registering a browsing context. They must not make it visible. - runtime.dom_host().document_handle() == handle - || runtime - .child_browsing_context_host_for_document_handle(handle) - .is_some() - || runtime - .lightweight_popup_id_for_document_handle(handle) - .is_some() -} - fn document_hidden_getter_function<'s>( scope: &mut v8::PinScope<'s, '_>, args: v8::FunctionCallbackArguments<'s>, @@ -1086,7 +1074,7 @@ fn document_hidden_getter_function<'s>( return; }; let visible = unsafe { &*runtime_ptr }.document_activity().visible - && document_has_browsing_context(unsafe { &*runtime_ptr }, handle); + && unsafe { &*runtime_ptr }.document_has_browsing_context(handle); rv.set_bool(!visible); } @@ -1101,7 +1089,7 @@ fn document_visibility_state_getter_function<'s>( return; }; let state = if unsafe { &*runtime_ptr }.document_activity().visible - && document_has_browsing_context(unsafe { &*runtime_ptr }, handle) + && unsafe { &*runtime_ptr }.document_has_browsing_context(handle) { "visible" } else { 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 d8c3f568eb..ccab81d4d0 100644 --- a/moli-renderer-v8/src/native_bridge/element/forms/submission.rs +++ b/moli-renderer-v8/src/native_bridge/element/forms/submission.rs @@ -573,15 +573,14 @@ pub(in crate::native_bridge) fn submit_form_default_action( }; // A parsed/detached Document has no browsing context to navigate. Resolve // that from the native document, independently of the form's wrapper kind. - if source_document.is_none_or(|document| { - let runtime = unsafe { &*runtime_ptr }; - document != runtime.document_handle() - && runtime - .child_browsing_context_handle_by_document_handle(scope, document) - .is_none() - }) { + if source_document + .is_none_or(|document| !unsafe { &*runtime_ptr }.document_has_browsing_context(document)) + { return false; } + let source_popup = source_document.and_then(|document| { + unsafe { &*runtime_ptr }.lightweight_popup_id_for_document_handle(document) + }); let Some(request) = build_form_submission_request(scope, runtime_ptr, form_handle, submitter, action) else { @@ -591,6 +590,29 @@ pub(in crate::native_bridge) fn submit_form_default_action( let special_target = target_name .as_deref() .and_then(SpecialBrowsingContextTarget::parse); + // A popup is its own top-level context: _parent and _top also refer to it, + // even when the submit method was borrowed from its opener's realm. + if let Some(popup_id) = source_popup + && (target_name.is_none() + || matches!( + special_target, + Some( + SpecialBrowsingContextTarget::Current + | SpecialBrowsingContextTarget::Parent + | SpecialBrowsingContextTarget::Top + ) + )) + { + return submit_form_to_popup_browsing_context( + scope, + runtime_ptr, + form_handle, + submitter, + popup_id, + request, + user_initiated, + ); + } match target_name.as_deref() { Some(target_name) if special_target.is_none() => { let target_handle = named_iframe_target_handle_for_navigation( @@ -774,6 +796,87 @@ enum FormSubmissionMethod { }, } +fn submit_form_to_popup_browsing_context( + scope: &mut v8::PinScope<'_, '_>, + runtime_ptr: *mut JsContextHost, + form_handle: DomHandle, + submitter: Option, + popup_id: u64, + submission: FormSubmissionMethod, + user_initiated: bool, +) -> bool { + let source_document = unsafe { &*runtime_ptr } + .dom_host() + .owner_document_handle(form_handle); + if source_document.is_none_or(|document| { + unsafe { &*runtime_ptr }.lightweight_popup_id_for_document_handle(document) + != Some(popup_id) + }) { + return false; + } + let Some(window) = unsafe { &*runtime_ptr }.lightweight_popup_window(scope, popup_id) else { + return false; + }; + let (request, form_data) = match submission { + FormSubmissionMethod::Get { resolved_url } => { + let Ok(url) = Url::parse(&resolved_url) else { + return false; + }; + (moli_fetch::Request::get_with_url(url), None) + } + FormSubmissionMethod::Post { + resolved_url, + body, + content_type, + form_data_entries, + } => { + let Some(form_data) = form_data_object_from_entries(scope, &form_data_entries) else { + return false; + }; + let Ok(request) = moli_fetch::Request::new_bytes( + "POST", + resolved_url.as_str(), + Some(body), + vec![("Content-Type".to_owned(), content_type)], + ) else { + return false; + }; + (request, Some(form_data)) + } + }; + let source_element = wrap_handle_object(scope, runtime_ptr, submitter.unwrap_or(form_handle)); + let navigation_type = if user_initiated { "push" } else { "replace" }; + if !crate::context_bootstrap::dispatch_cross_document_navigation_navigate_event_for_window_with_type_and_form_data( + scope, + window, + request.url.as_str(), + navigation_type, + source_element, + user_initiated, + None, + form_data, + ) { + return true; + } + let runtime = unsafe { &mut *runtime_ptr }; + // A navigate handler can close the popup or replace its document. + if source_document.is_none_or(|document| { + runtime.lightweight_popup_id_for_document_handle(document) != Some(popup_id) + }) { + return false; + } + runtime.navigate_lightweight_popup_window_with_request( + scope, + popup_id, + request, + if user_initiated { + crate::context_bootstrap::LocationNavigationKind::Assign + } else { + crate::context_bootstrap::LocationNavigationKind::Replace + }, + ) +} + fn dispatch_named_iframe_form_navigation_event( scope: &mut v8::PinScope<'_, '_>, runtime_ptr: *mut JsContextHost, diff --git a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/wrappers.rs b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/wrappers.rs index 7c3403f12b..917dd3b9c9 100644 --- a/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/wrappers.rs +++ b/moli-renderer-v8/src/script_vm/tests/dom_xhr/forms/wrappers.rs @@ -117,3 +117,126 @@ fn form_wrappers_share_reset_events_and_keep_inert_submissions_inert() { })()"#).unwrap(), "ok"); assert!(vm.take_pending_location_navigation_with_seed().is_none()); } + +#[tokio::test] +async fn form_wrappers_submit_get_to_lightweight_popup() { + assert_lightweight_popup_form_submission("get").await; +} + +#[tokio::test] +async fn form_wrappers_submit_post_to_lightweight_popup() { + assert_lightweight_popup_form_submission("post").await; +} + +async fn assert_lightweight_popup_form_submission(method: &str) { + let cases = [ + ("", "form.submit()"), + ("_self", "HTMLFormElement.prototype.submit.call(form)"), + ("_parent", "form.submit()"), + ("_top", "form.requestSubmit()"), + ]; + // POST to the current URL must still fetch a new document on every submit. + let submissions = if method == "post" { 2 } else { 1 }; + let server = StaticHttpServer::spawn(cases.len() * submissions).await; + let loader = static_http_loader([]); + let opener_url = server.base_url().join("opener.html").unwrap(); + let expected_target = if method == "get" { + "/submit?field=a+b%2Bc" + } else { + "/submit?existing=1" + }; + let expected_url = server.base_url().join(expected_target).unwrap(); + for (target, invocation) in cases { + let mut vm = new_page_task_executor_test_vm_with_loader(opener_url.as_str(), &loader); + vm.eval("globalThis.popup = open(); globalThis.originalOpenerDocument = document;") + .unwrap(); + for _ in 0..submissions { + vm.eval(&format!( + r#"(() => {{ + globalThis.originalPopupDocument = popup.document; + const form = popup.document.body.appendChild(popup.document.createElement('form')); + form.action = '/submit?existing=1'; + form.method = '{method}'; + form.target = '{target}'; + form.innerHTML = ''; + {invocation}; + }})()"# + )) + .expect("popup form submission should evaluate"); + assert!( + vm.take_pending_location_navigation_with_seed().is_none(), + "{method} target={target:?} must not navigate the opener" + ); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(popup.document !== originalPopupDocument && popup.document.body.textContent === 'child fixture')", + "true", + "popup form response should replace the popup document", + ) + .await; + assert_eq!( + vm.eval("popup.location.href").unwrap(), + expected_url.as_str() + ); + assert_eq!(vm.eval("location.href").unwrap(), opener_url.as_str()); + assert_eq!( + vm.eval("document === originalOpenerDocument").unwrap(), + "true" + ); + } + vm.eval("popup.close()").unwrap(); + } + let requests = server.finish().await; + assert_eq!(requests.len(), cases.len() * submissions); + for request in requests { + assert_eq!(request.method, method.to_ascii_uppercase()); + assert_eq!(request.target, expected_target); + if method == "post" { + assert_eq!(request.body, b"field=a+b%2Bc"); + assert_eq!( + request.header_value("content-type"), + Some("application/x-www-form-urlencoded") + ); + } else { + assert!(request.body.is_empty()); + } + } +} + +#[test] +fn form_wrappers_popup_submission_respects_cancellation_and_closed_documents() { + let mut vm = new_storage_test_vm("https://form-popup-cancellation.test/"); + assert_eq!( + vm.eval(r#"(() => { + globalThis.popup = open(); + const original = popup.document; + const form = original.body.appendChild(original.createElement('form')); + form.action = '/submit'; + form.innerHTML = ''; + const events = []; + popup.navigation.addEventListener('navigate', event => { + events.push([event.sourceElement === form, event.formData && event.formData.get('field')]); + event.preventDefault(); + }); + for (const method of ['get', 'post']) { + form.method = method; + HTMLFormElement.prototype.submit.call(form); + if (popup.document !== original || popup.location.href !== 'about:blank') throw Error('canceled navigation'); + } + popup.close(); + for (const method of ['get', 'post']) { + form.method = method; + HTMLFormElement.prototype.submit.call(form); + } + return JSON.stringify(events); + })()"#).unwrap(), + "[[true,null],[true,\"data\"]]" + ); + assert!(vm.take_pending_location_navigation_with_seed().is_none()); + assert!( + !vm._context_host + .borrow() + .has_pending_lightweight_popup_document_loads() + ); +} diff --git a/moli-renderer-v8/src/script_vm/tests/http_fixture.rs b/moli-renderer-v8/src/script_vm/tests/http_fixture.rs index 7ca9168b87..97b193939e 100644 --- a/moli-renderer-v8/src/script_vm/tests/http_fixture.rs +++ b/moli-renderer-v8/src/script_vm/tests/http_fixture.rs @@ -12,6 +12,7 @@ pub(super) struct CapturedHttpRequest { pub(super) host: Option, pub(super) target: String, pub(super) headers: Vec<(String, String)>, + pub(super) body: Vec, } impl CapturedHttpRequest { @@ -144,6 +145,7 @@ impl StaticHttpServer { host, target, headers, + body: request[header_end..header_end + content_length].to_vec(), }; let body = response_body(index, &captured); requests.push(captured);