fix(history): guard browser-owned traversal with sandbox policy

Check the History receiver's active document permission before handing
an opaque history destination back to the top-level browser controller.

Cover browser-owned steps in both directions, back/forward/go, dynamic
sandbox attributes, and a child method borrowed by top-level History.
This commit is contained in:
ldm0
2026-09-26 19:54:57 +08:00
committed by Donough Liu
parent 791c4c1dc5
commit aebba34fb5
2 changed files with 99 additions and 0 deletions
@@ -538,6 +538,17 @@ fn queue_browser_owned_top_level_history_traversal<'s>(
{
return;
}
let top_owner = super::navigation_window::runtime_top_window_owner(scope, owner);
if let Some(source_handle) =
super::navigation_window::child_browsing_context_handle_for_runtime_owner(scope, owner)
&& super::location_navigation::sandbox_blocks_ancestor_or_top_navigation_from_source(
scope,
source_handle,
top_owner,
)
{
return;
}
unsafe { &mut *host_ptr }.record_pending_top_level_history_traversal(delta);
}
@@ -74,6 +74,94 @@ fn top_level_navigation_intents_keep_one_last_writer_state() {
);
}
#[tokio::test]
async fn browser_owned_history_traversal_checks_active_source_sandbox() {
use moli_session_history::{
JointSessionHistory, SessionHistoryContextId, SessionHistoryEntry, SessionHistoryPosition,
};
let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap();
let url = "https://browser-owned-history.test/current";
let restricted = "allow-scripts allow-same-origin";
let permitted = "allow-scripts allow-same-origin allow-top-navigation";
for (initial, changed, allowed) in [
(None, Some(restricted), true),
(Some(permitted), Some(restricted), true),
(Some(restricted), None, false),
(Some(restricted), Some(permitted), false),
] {
let mut vm = new_storage_page_task_executor_test_vm_with_loader(url, &loader);
let mut seed = moli_page_types::initial_navigation_history_seed(false, url);
let mut traversable = JointSessionHistory::new(SessionHistoryPosition::new(1, 3).unwrap());
traversable.attach(
SessionHistoryContextId::ROOT,
None,
SessionHistoryEntry {
key: seed.entries[0].key.clone(),
document: seed.entries[0].document_id.clone(),
},
);
seed.session_history.traversable = Some(Box::new(traversable));
vm.install_navigation_bootstrap_entry(Some(seed));
let initial = serde_json::to_string(&initial).unwrap();
let changed = serde_json::to_string(&changed).unwrap();
vm.eval(&format!(
r#"
globalThis.frame = document.createElement('iframe');
if ({initial} !== null) frame.setAttribute('sandbox', {initial});
(document.body || document.documentElement || document).appendChild(frame);
frame.contentWindow;
"#
))
.unwrap();
vm.drain_ready_page_task_executor_turns_for_setup(&loader, 128)
.await
.unwrap();
vm.eval(&format!(
"if ({changed} === null) frame.removeAttribute('sandbox'); else frame.setAttribute('sandbox', {changed});"
))
.unwrap();
{
let mut host = vm._context_host.borrow_mut();
let model = host.session_histories.get_mut(None);
// Both destinations exist in browser history but have no renderer ROOT entry.
for delta in [-1, 1] {
let step = model.step_by_delta(delta).unwrap();
assert!(
!model
.entries_at(step)
.unwrap()
.contains_key(&SessionHistoryContextId::ROOT)
);
}
}
for (call, delta) in [
("back()", -1),
("forward()", 1),
("go(-1)", -1),
("go(1)", 1),
] {
vm.eval(&format!("frame.contentWindow.history.{call}"))
.unwrap();
let pending = vm
.take_pending_top_level_history_traversal()
.map(|pending| pending.delta);
assert_eq!(
pending,
allowed.then_some(delta),
"initial={initial}, changed={changed}, call={call}"
);
}
// A method borrowed from the child still uses its top-level History receiver.
vm.eval("frame.contentWindow.history.back.call(history)")
.unwrap();
assert_eq!(
vm.take_pending_top_level_history_traversal().unwrap().delta,
-1
);
}
}
#[test]
fn traversal_factories_apply_webidl_boundaries() {
let mut vm = new_parsed_test_vm(