From 2295eaf6080bb1d2b499fdaaaa80cb4e222fdb31 Mon Sep 17 00:00:00 2001 From: ldm0 Date: Tue, 22 Sep 2026 04:57:03 +0800 Subject: [PATCH] fix(url): preserve intrinsic identity through the Window alias --- .../exposed_interfaces/install.rs | 1 + .../exposed_interfaces/tests.rs | 32 +++- .../context_bootstrap/url_form/callbacks.rs | 17 +- .../script_vm/tests/lazy_window_surfaces.rs | 1 + .../tests/lazy_window_surfaces/url_alias.rs | 164 ++++++++++++++++++ 5 files changed, 204 insertions(+), 11 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces/url_alias.rs diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/install.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/install.rs index 51f8f2668..c434b9dfa 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/install.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/install.rs @@ -17,6 +17,7 @@ use crate::util::{ }; const LEGACY_WINDOW_INTERFACE_ALIASES: &[(&str, &str)] = &[ + ("webkitURL", "URL"), ("webkitAudioContext", "AudioContext"), ("WebKitCSSMatrix", "DOMMatrix"), ]; diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/tests.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/tests.rs index 45d0f44dd..ec057407a 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/tests.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/tests.rs @@ -329,6 +329,31 @@ fn worker_realm_lazy_properties_follow_chromium_exposure_sets() { assert!(first_url.strict_equals(second_url)); assert_eq!(registry.build_count(url_id), 1); + let source = v8str( + scope, + r#"(() => { + const Original = URL; + const parse = Original.parse; + globalThis.URL = null; + try { + const value = parse('https://example.test/?q=value'); + return value instanceof Original && + Object.getPrototypeOf(value) === Original.prototype && + value.searchParams.get('q') === 'value' && globalThis.URL === null; + } finally { + globalThis.URL = Original; + } + })()"#, + ); + let script = v8::Script::compile(scope, source, None).expect("worker URL factory script"); + assert!( + crate::script_execution::execute_compiled_script(scope, script) + .expect("worker URL factory evaluation") + .is_true(), + "worker URL factories must not follow the public constructor binding" + ); + assert_eq!(registry.build_count(url_id), 1); + [ "Worker", "XMLHttpRequest", @@ -337,6 +362,7 @@ fn worker_realm_lazy_properties_follow_chromium_exposure_sets() { "FileSystemSyncAccessHandle", "URL", "WorkerLocation", + "webkitURL", ] .iter() .map(|name| { @@ -349,14 +375,14 @@ fn worker_realm_lazy_properties_follow_chromium_exposure_sets() { assert_eq!( own_properties(super::RealmKind::DedicatedWorker), - vec![true, true, true, false, true, true, false] + vec![true, true, true, false, true, true, false, false] ); assert_eq!( own_properties(super::RealmKind::SharedWorker), - vec![true, true, true, false, false, true, false] + vec![true, true, true, false, false, true, false, false] ); assert_eq!( own_properties(super::RealmKind::ServiceWorker), - vec![false, false, false, false, false, true, false] + vec![false, false, false, false, false, true, false, false] ); } diff --git a/moli-renderer-v8/src/context_bootstrap/url_form/callbacks.rs b/moli-renderer-v8/src/context_bootstrap/url_form/callbacks.rs index 8649554db..f3dc7653e 100644 --- a/moli-renderer-v8/src/context_bootstrap/url_form/callbacks.rs +++ b/moli-renderer-v8/src/context_bootstrap/url_form/callbacks.rs @@ -3,6 +3,7 @@ use super::helpers::{ url_href_slot, }; use super::*; +use crate::context_bootstrap::{ensure_intrinsic_interface_constructor, shared::throw_error}; use crate::util::get_private_value; use crate::web_api_interfaces; use crate::webidl; @@ -128,14 +129,14 @@ pub(super) fn url_parse_callback<'s>( rv.set(v8::null(scope).into()); return; }; - let Some(constructor) = scope - .get_current_context() - .global(scope) - .get(scope, v8str(scope, "URL").into()) - .and_then(|value| v8::Local::::try_from(value).ok()) - else { - rv.set(v8::null(scope).into()); - return; + // The factory creates a URL in its own realm, regardless of author changes + // to the public URL binding (including during argument conversion). + let constructor = match ensure_intrinsic_interface_constructor(scope, "URL") { + Ok(constructor) => constructor, + Err(error) => { + throw_error(scope, &format!("Failed to create URL: {error}")); + return; + } }; let Some(object) = constructor.new_instance(scope, &[url_value.into()]) else { rv.set(v8::null(scope).into()); diff --git a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs index 7492e111e..cba881fac 100644 --- a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs +++ b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces.rs @@ -3,6 +3,7 @@ use super::*; mod eval; mod rebind; mod seeds; +mod url_alias; fn current_surface_state(scope: &mut v8::PinScope<'_, '_>) -> (bool, bool, bool) { let diagnostics = crate::context_bootstrap::window_lazy_surface_diagnostics(scope); diff --git a/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces/url_alias.rs b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces/url_alias.rs new file mode 100644 index 000000000..3d57dea85 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/lazy_window_surfaces/url_alias.rs @@ -0,0 +1,164 @@ +use super::*; + +#[test] +fn legacy_url_alias_is_lazy_and_shares_the_realm_constructor() { + for scheme in ["http", "https"] { + for first in [ + "webkitURL", + "URL", + "Object.getOwnPropertyDescriptor(globalThis, 'webkitURL').value", + ] { + let mut vm = new_storage_test_vm(&format!("{scheme}://url-alias.test/")); + assert_eq!(constructor_materialization_count(&mut vm, "URL"), 0); + assert_eq!( + vm.eval("'webkitURL' in globalThis && Object.hasOwn(globalThis, 'webkitURL')") + .unwrap(), + "true" + ); + assert_eq!(constructor_materialization_count(&mut vm, "URL"), 0); + vm.eval(&format!("void ({first})")).unwrap(); + assert_eq!(constructor_materialization_count(&mut vm, "URL"), 1); + assert_eq!( + vm.eval(r#"(() => { + const alias = webkitURL; + const value = new alias('next?q=value', 'https://example.test/base/'); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, 'webkitURL'); + return alias === URL && alias.name === 'URL' && alias.length === 1 && + value instanceof URL && Object.getPrototypeOf(value) === URL.prototype && + value.href === 'https://example.test/base/next?q=value' && + value.searchParams.get('q') === 'value' && + alias.parse('/next', 'https://example.test/').href === 'https://example.test/next' && + alias.canParse('https://example.test/') && !alias.canParse('relative') && + alias.createObjectURL === URL.createObjectURL && + alias.revokeObjectURL === URL.revokeObjectURL && + descriptor.value === alias && descriptor.writable && descriptor.configurable && + !descriptor.enumerable && !('get' in descriptor) && !('set' in descriptor); + })()"#).unwrap(), + "true", + "{scheme}: first access through {first}" + ); + assert_eq!(constructor_materialization_count(&mut vm, "URL"), 1); + } + } +} + +#[test] +fn legacy_url_alias_bindings_can_be_replaced_or_deleted_independently_before_first_read() { + for binding in ["URL", "webkitURL"] { + for mode in ["assignment", "deletion", "getter", "readonly"] { + let mut vm = new_storage_test_vm("https://url-alias-rebinding.test/"); + vm.eval(&format!( + "globalThis.__binding = {binding:?}; globalThis.__mode = {mode:?};" + )) + .unwrap(); + assert_eq!( + vm.eval(r#"(() => { + const other = __binding === 'URL' ? 'webkitURL' : 'URL'; + const sentinel = {}; + let getterCalls = 0; + const getter = () => { getterCalls++; throw new Error('must not read replaced binding'); }; + if (__mode === 'assignment') globalThis[__binding] = sentinel; + if (__mode === 'deletion' && !Reflect.deleteProperty(globalThis, __binding)) return false; + if (__mode === 'getter') Object.defineProperty(globalThis, __binding, {get: getter, configurable: true}); + if (__mode === 'readonly') Object.defineProperty(globalThis, __binding, + {value: sentinel, writable: false, configurable: false}); + const constructor = globalThis[other]; + if (typeof constructor !== 'function' || constructor.name !== 'URL') return false; + const url = new constructor('https://example.test/?q=value'); + const parsed = constructor.parse('/parsed', 'https://example.test/'); + if (url.href !== 'https://example.test/?q=value' || url.searchParams.get('q') !== 'value' || + Object.getPrototypeOf(url) !== constructor.prototype || + parsed.href !== 'https://example.test/parsed' || getterCalls !== 0) return false; + const descriptor = Object.getOwnPropertyDescriptor(globalThis, __binding); + if (__mode === 'deletion') return descriptor === undefined && !(__binding in globalThis); + if (__mode === 'getter') return descriptor.get === getter; + return descriptor.value === sentinel && + (__mode !== 'readonly' || (!descriptor.writable && !descriptor.configurable)); + })()"#).unwrap(), + "true", + "{mode} of {binding} must not change or be undone by the other binding" + ); + assert_eq!(constructor_materialization_count(&mut vm, "URL"), 1); + } + } +} + +#[test] +fn url_parse_ignores_a_public_constructor_replaced_during_argument_conversion() { + let mut vm = new_storage_test_vm("https://url-parse-intrinsic.test/"); + assert_eq!( + vm.eval( + r#"(() => { + const Original = URL; + const parse = Original.parse; + let conversions = 0; + let constructorCalls = 0; + const input = {toString() { + conversions++; + globalThis.URL = function Replacement() { constructorCalls++; return {forged: true}; }; + return '/parsed'; + }}; + const result = parse.call({ignored: true}, input, 'https://example.test/base'); + return conversions === 1 && constructorCalls === 0 && + result.href === 'https://example.test/parsed' && + Object.getPrototypeOf(result) === Original.prototype && result instanceof Original && + globalThis.URL !== Original; + })()"# + ) + .unwrap(), + "true" + ); +} + +#[test] +fn legacy_url_alias_materializes_in_its_own_window_realm() { + let mut vm = new_parsed_test_vm( + "https://url-alias-realms.test/", + "", + ); + assert_eq!( + vm.eval( + r#"(() => { + const frame = document.body.appendChild(document.createElement('iframe')); + const child = frame.contentWindow; + const alias = child.webkitURL; + const url = new alias('https://example.test/'); + const valid = alias === child.URL && alias !== webkitURL && webkitURL === URL && + Object.getPrototypeOf(alias) === child.Function.prototype && + Object.getPrototypeOf(url) === child.URL.prototype && + url instanceof child.URL && !(url instanceof URL); + child.URL = {}; + const parsed = alias.parse.call(URL, 'https://example.test/parsed'); + frame.remove(); + return valid && Object.getPrototypeOf(parsed) === alias.prototype && + parsed instanceof alias && !(parsed instanceof URL) && + new alias('/retained', 'https://example.test/').href === + 'https://example.test/retained'; + })()"# + ) + .unwrap(), + "true" + ); +} + +#[test] +fn legacy_url_alias_takes_precedence_over_window_named_properties() { + let mut vm = new_parsed_test_vm( + "https://url-alias-named.test/", + "
", + ); + assert_eq!(constructor_materialization_count(&mut vm, "URL"), 0); + assert_eq!( + vm.eval( + r#"(() => { + const named = document.getElementById('webkitURL'); + const alias = window.webkitURL; + if (typeof alias !== 'function' || alias !== URL || alias === named) return false; + delete window.webkitURL; + return window.webkitURL === named && typeof URL === 'function'; + })()"# + ) + .unwrap(), + "true" + ); +}