mirror of
https://github.com/lexmount/moli.git
synced 2026-10-07 08:01:00 +00:00
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:
@@ -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
@@ -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(¤t_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] {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user