mirror of
https://github.com/lexmount/moli.git
synced 2026-11-01 08:06:12 +00:00
fix(navigation): preserve foreign child navigation semantics
Route cross-origin child Location writes through browsing-context navigation commands so the destination does not receive a cancelable navigate event and its history seed preserves push and origin-filtered entries. Synchronize native Window surfaces in their own realm to avoid cross-origin access exceptions for named-target clicks and window.open. Add regression coverage for all four navigation entry points, activation type, and history filtering after return-to-origin and about:blank navigations. Historical WPT probes pass all five cases atb4f9a9677fand fail at67283068e3. Fixes:67283068e3(refactor(window): dispatch cross-origin access through native tables) Validation: - cargo fmt --all - cargo clippy --workspace --all-targets --all-features -- -D warnings - cargo nextest run --no-fail-fast
This commit is contained in:
@@ -140,6 +140,11 @@ impl JsContextHost {
|
||||
let Some(wrapper) = self.child_window_proxy_records.live_window(scope, handle) else {
|
||||
return;
|
||||
};
|
||||
let Some(context) = wrapper.get_creation_context(scope) else {
|
||||
return;
|
||||
};
|
||||
// Native history synchronization may be initiated by a foreign Window.
|
||||
let scope = &mut v8::ContextScope::new(scope, context);
|
||||
let Some(entry_seed) = self
|
||||
.child_browsing_contexts
|
||||
.get(&handle)
|
||||
|
||||
+45
@@ -96,6 +96,11 @@ fn navigate_window<'s>(
|
||||
} else {
|
||||
raw
|
||||
};
|
||||
if !caller_can_access_window(scope, window)
|
||||
&& let Some(navigated) = navigate_cross_origin_child(scope, window, &raw, kind)
|
||||
{
|
||||
return navigated;
|
||||
}
|
||||
let Some(context) = window.get_creation_context(scope) else {
|
||||
return false;
|
||||
};
|
||||
@@ -115,6 +120,46 @@ fn navigate_window<'s>(
|
||||
true
|
||||
}
|
||||
|
||||
fn navigate_cross_origin_child<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
window: v8::Local<'s, v8::Object>,
|
||||
raw: &str,
|
||||
kind: crate::context_bootstrap::LocationNavigationKind,
|
||||
) -> Option<bool> {
|
||||
let context = window.get_creation_context(scope)?;
|
||||
let host_ptr = crate::util::context_host_ptr_from_context_slot(context)?;
|
||||
let host = unsafe { &mut *host_ptr };
|
||||
let handle = host
|
||||
.window_execution_context_identity_for_access_check(context)
|
||||
.filter(|identity| host.window_execution_context_identity_is_current(*identity))
|
||||
.and_then(|identity| identity.dispatch_scope().child_window())
|
||||
.or_else(|| {
|
||||
context
|
||||
.get_slot::<ChildWindowProxyFacadeContextHandle>()
|
||||
.map(|handle| handle.0)
|
||||
})?;
|
||||
let target = url::Url::parse(raw).ok()?;
|
||||
if cross_origin_location_target_is_same_document(host, handle, &target) {
|
||||
return None;
|
||||
}
|
||||
// A foreign initiator navigates the browsing context, without firing a
|
||||
// destination navigate event or editing the outgoing Window's local history.
|
||||
Some(match kind {
|
||||
crate::context_bootstrap::LocationNavigationKind::Assign => {
|
||||
host.navigate_child_browsing_context_to_url(scope, handle, target.as_str())
|
||||
}
|
||||
crate::context_bootstrap::LocationNavigationKind::Replace => host
|
||||
.queue_child_browsing_context_navigation_from_existing_seed(
|
||||
handle,
|
||||
target.as_str(),
|
||||
true,
|
||||
),
|
||||
crate::context_bootstrap::LocationNavigationKind::Reload => {
|
||||
host.queue_child_browsing_context_reload_from_existing_seed(handle, target.as_str())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
fn shared_cross_origin_location<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
window: v8::Local<'s, v8::Object>,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cross_origin_child_navigation_does_not_dispatch_a_destination_navigate_event() {
|
||||
let server = StaticHttpServer::spawn_with_responder(
|
||||
12,
|
||||
"Content-Type: text/html; charset=utf-8\r\n",
|
||||
|_, _| {
|
||||
r#"<!doctype html><script>
|
||||
navigation.onnavigate = event => {
|
||||
event.preventDefault();
|
||||
top.postMessage('UNEXPECTED NAVIGATE', '*');
|
||||
};
|
||||
onload = () => {
|
||||
if (location.search === '?next') top.postMessage('DONE', '*');
|
||||
};
|
||||
</script>"#
|
||||
.to_owned()
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let loader = static_http_loader([
|
||||
server.resolve_entry("parent.cross-navigation.test"),
|
||||
server.resolve_entry("child.cross-navigation.test"),
|
||||
]);
|
||||
let parent = server.url_for_host("parent.cross-navigation.test", "/parent");
|
||||
let child = server.url_for_host("child.cross-navigation.test", "/child");
|
||||
let destination = server.url_for_host("child.cross-navigation.test", "/child?next");
|
||||
for action in ["href", "window", "click", "open"] {
|
||||
let mut vm = new_storage_page_task_executor_test_vm_with_loader(parent.as_str(), &loader);
|
||||
vm.eval(&format!(
|
||||
r#"
|
||||
globalThis.loads = 0;
|
||||
globalThis.messages = [];
|
||||
globalThis.sourceNavigations = 0;
|
||||
navigation.onnavigate = () => ++sourceNavigations;
|
||||
addEventListener('message', event => messages.push(event.data));
|
||||
const root = document.documentElement || document.appendChild(document.createElement('html'));
|
||||
const frame = document.createElement('iframe');
|
||||
frame.name = 'target';
|
||||
frame.onload = () => ++loads;
|
||||
frame.src = {child:?};
|
||||
root.appendChild(frame);
|
||||
"#,
|
||||
child = child.as_str(),
|
||||
))
|
||||
.unwrap();
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"String(loads)",
|
||||
"1",
|
||||
"cross-origin child should finish its initial load",
|
||||
)
|
||||
.await;
|
||||
let url = format!("{:?}", destination.as_str());
|
||||
let script = match action {
|
||||
"href" => format!("frame.contentWindow.location.href = {url}; 'queued'"),
|
||||
"window" => format!("frame.contentWindow.location = {url}; 'queued'"),
|
||||
"open" => format!("window.open({url}, 'target'); 'queued'"),
|
||||
"click" => format!(
|
||||
"const link = document.createElement('a'); link.href = {url}; \
|
||||
link.target = 'target'; root.appendChild(link); link.click(); 'queued'"
|
||||
),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
vm.eval(&script).unwrap();
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"String(messages.includes('DONE'))",
|
||||
"true",
|
||||
"foreign navigation must commit without firing its canceling navigate handler",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
vm.eval("JSON.stringify([messages, sourceNavigations, loads])")
|
||||
.unwrap(),
|
||||
r#"[["DONE"],0,2]"#,
|
||||
"{action}",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test(flavor = "current_thread")]
|
||||
async fn cross_origin_location_navigation_preserves_push_and_filters_previous_origin_entries() {
|
||||
let server = StaticHttpServer::spawn_with_responder(
|
||||
4,
|
||||
"Content-Type: text/html; charset=utf-8\r\n",
|
||||
|_, _| "<!doctype html><p>ready</p>".to_owned(),
|
||||
)
|
||||
.await;
|
||||
let loader = static_http_loader([
|
||||
server.resolve_entry("parent.cross-history.test"),
|
||||
server.resolve_entry("child.cross-history.test"),
|
||||
]);
|
||||
let parent = server.url_for_host("parent.cross-history.test", "/parent");
|
||||
let child = server.url_for_host("child.cross-history.test", "/child");
|
||||
let destination = server.url_for_host("parent.cross-history.test", "/returned");
|
||||
for target in [destination.as_str(), "about:blank"] {
|
||||
let mut vm = new_storage_page_task_executor_test_vm_with_loader(parent.as_str(), &loader);
|
||||
vm.eval(&format!(
|
||||
r#"
|
||||
globalThis.loads = 0;
|
||||
const root = document.documentElement || document.appendChild(document.createElement('html'));
|
||||
const frame = document.createElement('iframe');
|
||||
frame.onload = () => ++loads;
|
||||
frame.src = {child:?};
|
||||
root.appendChild(frame);
|
||||
"#,
|
||||
child = child.as_str(),
|
||||
))
|
||||
.unwrap();
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"String(loads)",
|
||||
"1",
|
||||
"foreign child should commit before navigation back to its parent's origin",
|
||||
)
|
||||
.await;
|
||||
vm.eval(&format!(
|
||||
"frame.contentWindow.location = {target:?}; 'queued'"
|
||||
))
|
||||
.unwrap();
|
||||
advance_page_task_executor_until_eval_equals(
|
||||
&mut vm,
|
||||
&loader,
|
||||
"String(loads)",
|
||||
"2",
|
||||
"new child document should commit",
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
vm.eval("String(frame.contentWindow.navigation.entries().length)")
|
||||
.unwrap(),
|
||||
"1",
|
||||
"entries from the previous origin must not leak into {target}",
|
||||
);
|
||||
if target != "about:blank" {
|
||||
assert_eq!(
|
||||
vm.eval("frame.contentWindow.navigation.activation.navigationType")
|
||||
.unwrap(),
|
||||
"push",
|
||||
"a loaded foreign document is not an initial empty document",
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
mod beforeinput_target_validation;
|
||||
mod credential_container;
|
||||
mod credential_json_options;
|
||||
mod cross_origin_navigation;
|
||||
mod picture_in_picture_interfaces;
|
||||
mod visual_viewport_receiver;
|
||||
mod writable_stream_native_receivers;
|
||||
|
||||
Reference in New Issue
Block a user