fix(forms): preserve popup navigation source and referrer

Use the shared element navigation path for GET form popup submissions so the
new document retains its creator URL, policy container, and rel semantics.
Resolve child-window creators from the form's owner document, and remove the
obsolete popup path that carried only the opener flag. Apply Referrer Policy
and sanitize the source URL, honoring anchor/area policy overrides.

Exercise all seven rel combinations for form, button, and input submissions
from both top-level and HTTP-loaded iframe documents, plus cross-origin
referrer policy and fragment stripping. Record the three newly
passing form popup WPT cases.
This commit is contained in:
ldm0
2026-09-23 09:04:55 +08:00
parent 60e5f07d8f
commit ba67c82080
6 changed files with 299 additions and 174 deletions
@@ -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
@@ -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
@@ -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,
)
}
@@ -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<HyperlinkPopupCreator<'s>> {
) -> Option<ElementPopupCreator<'s>> {
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<v8::Local<'s, v8::Object>>,
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<v8::Local<'s, v8::Object>>,
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,
@@ -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)
}
@@ -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#"<!doctype html><script>
if (location.pathname !== "/child.html") {
new BroadcastChannel("form-popup-relations").postMessage({
hasOpener: opener !== null,
referrer: document.referrer,
openerPath: opener === null ? null : opener.location.pathname
});
window.close();
}
</script>"#,
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#"<!doctype html><script>
opener.postMessage(document.referrer, "*");
window.close();
</script>"#,
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");