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: aac5e678d3, 8305e0dcd9
This commit is contained in:
ldm0
2026-09-14 22:33:17 +08:00
parent f6f95676a1
commit c23efff0ce
6 changed files with 329 additions and 172 deletions
+2 -1
View File
@@ -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,
+31
View File
@@ -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<String> {
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);
}
}
@@ -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,
)
}
@@ -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,
@@ -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)
}
@@ -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#"<!doctype html><body><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");