diff --git a/moli-benchmark/wpt-cross-current/passed-cases.txt b/moli-benchmark/wpt-cross-current/passed-cases.txt index 7e7c53cd45..0dde3c9fe4 100644 --- a/moli-benchmark/wpt-cross-current/passed-cases.txt +++ b/moli-benchmark/wpt-cross-current/passed-cases.txt @@ -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 diff --git a/moli-core/tests/fixtures/retained-child-window.js b/moli-core/tests/fixtures/retained-child-window.js index 326ad99fe4..e034c3a101 100644 --- a/moli-core/tests/fixtures/retained-child-window.js +++ b/moli-core/tests/fixtures/retained-child-window.js @@ -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); diff --git a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs index e3bd740698..2bf9593406 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_navigation.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_navigation.rs @@ -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>, 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, diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime.rs index 63a6e192d5..43f37ce973 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime.rs @@ -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, diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime/access.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime/access.rs index 8dc5d0a096..0ee185cf82 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime/access.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime/access.rs @@ -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(); diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime/helpers.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime/helpers.rs index da1fb2828c..5c59824b42 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime/helpers.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime/helpers.rs @@ -30,7 +30,24 @@ pub(super) fn parsed_location_url<'s>( scope: &mut v8::PinScope<'s, '_>, object: v8::Local<'s, v8::Object>, ) -> Option { - 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 { + 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>( diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs index 0cea411d9c..26f5157fc2 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime/install.rs @@ -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) { diff --git a/moli-renderer-v8/src/context_bootstrap/location_runtime/methods.rs b/moli-renderer-v8/src/context_bootstrap/location_runtime/methods.rs index 2e72ab101c..a307edaebb 100644 --- a/moli-renderer-v8/src/context_bootstrap/location_runtime/methods.rs +++ b/moli-renderer-v8/src/context_bootstrap/location_runtime/methods.rs @@ -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); diff --git a/moli-renderer-v8/src/script_vm/tests/retained_child_window.rs b/moli-renderer-v8/src/script_vm/tests/retained_child_window.rs index 4b2decabcc..c4f8354370 100644 --- a/moli-renderer-v8/src/script_vm/tests/retained_child_window.rs +++ b/moli-renderer-v8/src/script_vm/tests/retained_child_window.rs @@ -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![ + "Location lifecycle target".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] { diff --git a/moli-renderer-v8/tests/fixtures/inactive-location.js b/moli-renderer-v8/tests/fixtures/inactive-location.js new file mode 100644 index 0000000000..e6a11bfc3c --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/inactive-location.js @@ -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 = '

srcdoc

'; + 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; +}