fix(runtime): stop navigation through inactive Location objects

Check the relevant Document lifetime after argument conversion and before
parsing or navigating. Inactive Location objects expose about:blank while
retained Documents keep their URLs. Track the actual Window owner so old
Locations cannot navigate a replacement iframe Window or document.

Cover removal, reinsertion, navigation, srcdoc, closed popups, conversion
side effects, and receiver validation. Update the retained child Window
expectation and record the no-browsing-context WPT pass.
This commit is contained in:
ldm0
2026-09-23 00:14:09 +08:00
parent dc79c48c14
commit 0ea09e300c
10 changed files with 255 additions and 10 deletions
@@ -6437,6 +6437,7 @@ html/browsers/history/the-location-interface/location_reload.html
html/browsers/history/the-location-interface/location_reload_javascript_url.html
html/browsers/history/the-location-interface/location_replace.html
html/browsers/history/the-location-interface/location_search.html
html/browsers/history/the-location-interface/no-browsing-context.window.js?moli-wpt-script=window
html/browsers/history/the-location-interface/reload_document_write.html
html/browsers/history/the-location-interface/replace-with-nested-iframe.html
html/browsers/history/the-location-interface/security_location_0.htm
+2 -1
View File
@@ -57,7 +57,8 @@
await tick();
check(mode + ':retired-timer-inactive', () => timerCalls, 0);
check(mode + ':document', () => win.document === doc, true);
check(mode + ':location', () => win.location.href, url);
// With no relevant Document, Location exposes about:blank; the retained Document keeps its URL.
check(mode + ':location', () => win.location.href, 'about:blank');
result.observations.push({mode, defaultViewIsNull: doc.defaultView === null});
check(mode + ':own-data', () => win.retainedMarker === marker, true);
check(mode + ':intrinsic', () => win.Event === eventConstructor, true);
@@ -1,6 +1,6 @@
use super::location_runtime::{
is_same_document_fragment_navigation, location_href_slot, resolve_location_navigation_target,
sync_location_object,
is_same_document_fragment_navigation, location_has_relevant_document, location_href_slot,
resolve_location_navigation_target, sync_location_object,
};
use super::navigation_activation::{clear_navigation_transition, install_navigation_transition};
use super::navigation_callbacks::cancel_active_intercepted_same_document_navigation;
@@ -276,6 +276,11 @@ fn navigate_location_object_with_source_element_and_child_navigate_event<'s>(
source_element: Option<v8::Local<'s, v8::Object>>,
options: LocationNavigationOptions,
) {
// The Location setters and methods return before URL parsing when their
// relevant Document is null, including when argument conversion removed it.
if !location_has_relevant_document(scope, location) {
return;
}
let LocationNavigationOptions {
dispatch_child_navigate_event_for_all_kinds,
force_exact_same_document_navigation,
@@ -18,7 +18,7 @@ pub(in crate::context_bootstrap) use access::{location_target, wrap_location_obj
pub(super) use install::{
build_location_constructor_template, build_location_runtime_object,
install_location_runtime_state, location_belongs_to_current_local_window,
location_owner_has_current_realm,
location_has_relevant_document, location_owner_has_current_realm,
};
pub(super) use navigation::{
is_same_document_fragment_navigation, resolve_location_navigation_target,
@@ -231,7 +231,7 @@ pub(super) fn require_entry_origin<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> bool {
if !super::install::location_belongs_to_current_local_window(scope, object) {
if !super::install::location_has_relevant_document(scope, object) {
return true;
}
let entry = scope.get_entered_or_microtask_context();
@@ -30,7 +30,24 @@ pub(super) fn parsed_location_url<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> Option<url::Url> {
location_href_slot(scope, object).and_then(|href| url::Url::parse(&href).ok())
location_url(scope, object).and_then(|href| url::Url::parse(&href).ok())
}
pub(super) fn location_url<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> Option<String> {
let href = require_location_href_slot(scope, object)?;
// A retained Location has no relevant Document after its Window loses its
// browsing context. Its URL is then about:blank, independent of the old
// Document's URL or any new Window created for the same iframe element.
Some(
if super::install::location_has_relevant_document(scope, object) {
href
} else {
"about:blank".to_owned()
},
)
}
pub(super) fn require_location_href_slot<'s>(
@@ -345,6 +345,37 @@ pub(in crate::context_bootstrap) fn location_belongs_to_current_local_window<'s>
})
}
pub(in crate::context_bootstrap) fn location_has_relevant_document<'s>(
scope: &mut v8::PinScope<'s, '_>,
location: v8::Local<'s, v8::Object>,
) -> bool {
let owner = runtime_window_owner(scope, location);
let Some(dispatch_scope) = runtime_window_dispatch_scope(scope, owner) else {
return false;
};
let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else {
return false;
};
let host = unsafe { &*host_ptr };
match dispatch_scope {
crate::native_bridge::OwnerDispatchScope::Top => owner
.get_creation_context(scope)
.and_then(|context| host.window_execution_context_identity_for_access_check(context))
.is_some_and(|identity| host.window_execution_context_identity_is_current(identity)),
crate::native_bridge::OwnerDispatchScope::Child(_) => {
// The same iframe element can acquire a different LocalWindow.
// A Location retained from the old Window must remain inactive.
location_belongs_to_current_local_window(scope, location)
}
crate::native_bridge::OwnerDispatchScope::LightweightPopup(popup_id) => {
// Popup shells share the opener realm. Their own document record,
// rather than that realm, determines when close destroys them.
host.current_lightweight_popup_document_owner(popup_id)
.is_some()
}
}
}
pub(in crate::context_bootstrap) fn location_owner_has_current_realm<'s>(
scope: &mut v8::PinScope<'s, '_>,
owner: v8::Local<'s, v8::Object>,
@@ -446,7 +477,7 @@ fn location_attribute_getter<'s>(
{
return;
}
let Some(current_href) = require_location_href_slot(scope, holder) else {
let Some(current_href) = super::helpers::location_url(scope, holder) else {
return;
};
match attribute {
@@ -471,8 +502,8 @@ fn location_attribute_getter<'s>(
set_return_string(scope, rv, &search);
}
LocationAttribute::Pathname => {
let pathname = location_href_slot(scope, holder)
.and_then(|href| url::Url::parse(&href).ok())
let pathname = url::Url::parse(&current_href)
.ok()
.map(|url| url.path().to_owned())
.unwrap_or_default();
set_return_string(scope, rv, &pathname);
@@ -538,6 +569,11 @@ fn location_writable_attribute_setter_callback<'s>(
let Some(value) = v8_value_to_string(scope, args.get(0)) else {
return;
};
// Conversion can itself remove the iframe, so check the relevant Document
// afterwards and before component parsing or navigation side effects.
if !location_has_relevant_document(scope, holder) {
return;
}
if !matches!(attribute, LocationAttribute::Href)
&& !super::access::require_entry_origin(scope, holder)
{
@@ -88,7 +88,7 @@ pub(super) fn location_to_string_callback<'s>(
{
return;
}
let Some(href) = require_location_href_slot(scope, args.this()) else {
let Some(href) = super::helpers::location_url(scope, args.this()) else {
return;
};
set_return_string(scope, &mut rv, &href);
@@ -1,5 +1,59 @@
use super::*;
#[tokio::test(flavor = "current_thread")]
async fn inactive_locations_have_blank_urls_and_cannot_navigate() {
let server = StaticHttpServer::spawn_with_bodies(vec![
"<!doctype html><body>Location lifecycle target</body>".to_owned(); 4
])
.await;
let loader = static_http_loader([server.resolve_entry("www.example.test")]);
let parent_url = server.url_for_host("www.example.test", "/page.html");
let mut vm = new_storage_page_task_executor_test_vm_with_loader(parent_url.as_str(), &loader);
let script = include_str!(concat!(
env!("CARGO_MANIFEST_DIR"),
"/tests/fixtures/inactive-location.js"
));
vm.exec(
&format!(
r#"
if (!document.documentElement) document.appendChild(document.createElement('html'));
if (!document.body) document.documentElement.appendChild(document.createElement('body'));
globalThis.__inactiveLocationResult = null;
({script})().then(
result => {{ globalThis.__inactiveLocationResult = result; }},
error => {{ globalThis.__inactiveLocationResult = {{error: String(error)}}; }}
);
"#,
),
None,
)
.expect("inactive Location probe should start");
advance_page_task_executor_until_eval_equals(
&mut vm,
&loader,
"String(__inactiveLocationResult !== null)",
"true",
"inactive Location probe should finish",
)
.await;
let result: serde_json::Value = serde_json::from_str(
&vm.eval("JSON.stringify(__inactiveLocationResult)")
.expect("inactive Location observations"),
)
.unwrap();
assert_eq!(result["checks"], 295, "{result}");
assert_eq!(result["failures"], serde_json::json!([]), "{result}");
assert_eq!(
server.finish_targets().await,
vec![
"/removed.html?query=one",
"/removed.html?query=one",
"/before.html",
"/after.html"
]
);
}
#[tokio::test(flavor = "current_thread")]
async fn retained_location_rechecks_origin_domain_access() {
for parent_first in [false, true] {
+131
View File
@@ -0,0 +1,131 @@
async () => {
const result = {checks: 0, failures: []};
const check = (name, action, expected) => {
let actual;
try { actual = action(); } catch (error) { actual = error.name; }
result.checks++;
if (actual !== expected) result.failures.push({name, actual, expected});
};
const load = async path => {
const frame = document.createElement('iframe');
const loaded = new Promise(resolve => frame.onload = resolve);
frame.src = path;
document.body.append(frame);
await loaded;
return frame;
};
const properties = {
href: 'about:blank', origin: 'null', protocol: 'about:', host: '',
hostname: '', port: '', pathname: 'blank', search: '', hash: ''
};
const mutations = [
...['href', 'protocol', 'host', 'hostname', 'port', 'pathname', 'search', 'hash']
.map(name => [name, (loc, value) => { loc[name] = value; }]),
...['assign', 'replace'].map(name => [name, (loc, value) => loc[name](value)]),
['window-location', (loc, value, win) => { win.location = value; }]
];
const detached = (label, win, loc, doc, oldURL) => {
check(`${label}:identity`, () => win.location === loc, true);
check(`${label}:document-URL`, () => doc.URL, oldURL);
for (const [name, expected] of Object.entries(properties)) {
check(`${label}:get-${name}`, () => loc[name], expected);
}
check(`${label}:stringifier`, () => String(loc), 'about:blank');
check(`${label}:ancestor-origins`, () => loc.ancestorOrigins.length, 0);
const emptyOrigins = loc.ancestorOrigins;
check(`${label}:stable-origins`, () => loc.ancestorOrigins === emptyOrigins, true);
for (const [name, mutate] of mutations) {
check(`${label}:set-${name}`, () => { mutate(loc, 'http://test:test/', win); return 'done'; }, 'done');
check(`${label}:after-${name}`, () => loc.href, 'about:blank');
let converted = 0;
const sentinel = {name: 'ConversionSentinel'};
const poison = {toString() { converted++; throw sentinel; }};
check(`${label}:conversion-${name}`, () => mutate(loc, poison, win), 'ConversionSentinel');
check(`${label}:conversion-count-${name}`, () => converted, 1);
}
check(`${label}:reload`, () => loc.reload({toString() { throw new Error('not converted'); }}), undefined);
for (const name of ['assign', 'replace']) {
check(`${label}:required-${name}`, () => loc[name](), 'TypeError');
}
check(`${label}:unchanged-document-URL`, () => doc.URL, oldURL);
};
check('live:main-url', () => location.href, document.URL);
const blankFrame = document.createElement('iframe');
document.body.append(blankFrame);
const blankWindow = blankFrame.contentWindow;
const blankLocation = blankWindow.location;
const blankDocument = blankWindow.document;
blankFrame.remove();
detached('blank', blankWindow, blankLocation, blankDocument, 'about:blank');
const frame = await load('/removed.html?query=one#fragment');
const win = frame.contentWindow;
const loc = win.location;
const doc = win.document;
const href = loc.href;
frame.remove();
detached('loaded', win, loc, doc, href);
const loadedAgain = new Promise(resolve => frame.onload = resolve);
document.body.append(frame);
await loadedAgain;
check('reinsert:fresh-location', () => frame.contentWindow.location !== loc, true);
check('reinsert:old-location', () => loc.href, 'about:blank');
check('reinsert:old-navigation', () => { loc.href = 'http://test:test/'; return 'done'; }, 'done');
check('reinsert:fresh-url', () => frame.contentWindow.location.href, href);
frame.remove();
for (const [name, mutate] of mutations) {
const frame = document.createElement('iframe');
document.body.append(frame);
const win = frame.contentWindow;
const loc = win.location;
let converted = 0;
const value = {toString() { converted++; frame.remove(); return 'http://test:test/'; }};
check(`conversion-removes:${name}`, () => { mutate(loc, value, win); return 'done'; }, 'done');
check(`conversion-removes-count:${name}`, () => converted, 1);
check(`conversion-removes-url:${name}`, () => loc.href, 'about:blank');
}
const srcdocFrame = document.createElement('iframe');
const srcdocLoaded = new Promise(resolve => srcdocFrame.onload = resolve);
srcdocFrame.srcdoc = '<!doctype html><p>srcdoc</p>';
document.body.append(srcdocFrame);
await srcdocLoaded;
const srcdocWindow = srcdocFrame.contentWindow;
const srcdocLocation = srcdocWindow.location;
const srcdocDocument = srcdocWindow.document;
const srcdocURL = srcdocDocument.URL;
srcdocFrame.remove();
detached('srcdoc', srcdocWindow, srcdocLocation, srcdocDocument, srcdocURL);
const popup = window.open('about:blank');
if (!popup) throw new Error('popup should be created');
const popupLocation = popup.location;
const popupDocument = popup.document;
popup.close();
// close() queues destruction; yield before probing the retired Location.
await new Promise(resolve => setTimeout(resolve, 10));
detached('popup', popup, popupLocation, popupDocument, 'about:blank');
const setter = Object.getOwnPropertyDescriptor(blankLocation, 'href').set;
let conversions = 0;
const poison = {toString() { conversions++; return 'http://test:test/'; }};
for (const receiver of [{}, Object.create(blankLocation), new Proxy(blankLocation, {})]) {
check('inactive:invalid-receiver', () => setter.call(receiver, poison), 'TypeError');
check('inactive:brand-before-conversion', () => conversions, 0);
}
const navigatedFrame = await load('/before.html');
const oldLocation = navigatedFrame.contentWindow.location;
const nextLoad = new Promise(resolve => navigatedFrame.onload = resolve);
navigatedFrame.src = '/after.html';
await nextLoad;
check('retired:fresh-location', () => oldLocation !== navigatedFrame.contentWindow.location, true);
const activeURL = navigatedFrame.contentWindow.location.href;
check('retired:invalid-navigation', () => { oldLocation.assign('http://test:test/'); return 'done'; }, 'done');
oldLocation.hash = '#stale';
await new Promise(resolve => setTimeout(resolve, 0));
check('retired:current-document-unchanged', () => navigatedFrame.contentWindow.location.href, activeURL);
navigatedFrame.remove();
return result;
}