From 3c207eeef109ad0b660b19df6ff1cbfceeeff13b Mon Sep 17 00:00:00 2001 From: ldm0 Date: Mon, 21 Sep 2026 19:02:26 +0800 Subject: [PATCH] fix(window): validate scheduling method receivers Use the shared native Window and same-origin receiver check before timer, animation, idle, and microtask methods convert arguments or queue work. Reject foreign Windows and forged receivers in the callee realm while preserving genuine borrowed calls and native bridge wrappers. Cover all nine methods across parent/child realms, conversion ordering, author Proxies, nullish defaults, and discarded foreign Windows. Keep Worker callbacks and task lifetime handling intact. Validation: cargo fmt --all; strict workspace/all-targets/all-features Clippy; full nextest 19586 passed, 13 skipped; focused 1357 passed; 863 browser checks agree with Chromium. Official cross-origin WPT remains 78/90 with its first restricted-method failure advancing to getComputedStyle; 14 control pages are unchanged. --- moli-renderer-v8/src/context_bootstrap.rs | 2 +- .../src/context_bootstrap/window_receiver.rs | 6 +- moli-renderer-v8/src/queue_microtask.rs | 11 +- moli-renderer-v8/src/script_vm/tests/mod.rs | 1 + .../tests/window_scheduling_receivers.rs | 103 ++++++++++++++++ moli-renderer-v8/src/window_host.rs | 22 +++- .../fixtures/window-scheduling-receivers.js | 111 ++++++++++++++++++ 7 files changed, 245 insertions(+), 11 deletions(-) create mode 100644 moli-renderer-v8/src/script_vm/tests/window_scheduling_receivers.rs create mode 100644 moli-renderer-v8/tests/fixtures/window-scheduling-receivers.js diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index e6cc54de40..03d86c0cab 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -257,7 +257,7 @@ pub(crate) use window_accessors::{ mod window_events; mod window_lazy_surface; mod window_receiver; -pub(crate) use window_receiver::is_window_receiver; +pub(crate) use window_receiver::{is_window_receiver, require_same_origin_window_receiver}; mod window_runtime; pub(crate) use form_data_runtime::{ diff --git a/moli-renderer-v8/src/context_bootstrap/window_receiver.rs b/moli-renderer-v8/src/context_bootstrap/window_receiver.rs index 4b94cb4bae..41bff66cc9 100644 --- a/moli-renderer-v8/src/context_bootstrap/window_receiver.rs +++ b/moli-renderer-v8/src/context_bootstrap/window_receiver.rs @@ -21,12 +21,12 @@ pub(crate) fn is_window_receiver<'s>( && context_host_ptr_from_context_slot(current_context).is_some() } -pub(in crate::context_bootstrap) fn require_same_origin_window_receiver<'s>( +pub(crate) fn require_same_origin_window_receiver<'s>( scope: &mut v8::PinScope<'s, '_>, receiver: v8::Local<'s, v8::Object>, lenient_this: bool, ) -> bool { - // An extracted accessor bypasses WindowProxy's property access check. + // An extracted method or accessor bypasses WindowProxy's property access check. // Authorize native globals before reading slots, which can themselves // trigger V8's cross-origin fallback. Leniency only applies to objects // without the Window brand, never to a cross-origin Window. @@ -43,7 +43,7 @@ pub(in crate::context_bootstrap) fn require_same_origin_window_receiver<'s>( return true; } if !lenient_this { - throw_type_error(scope, "Window getter called on incompatible receiver."); + throw_type_error(scope, "Window member called on incompatible receiver."); } return false; } diff --git a/moli-renderer-v8/src/queue_microtask.rs b/moli-renderer-v8/src/queue_microtask.rs index 27b1f31931..8171718a00 100644 --- a/moli-renderer-v8/src/queue_microtask.rs +++ b/moli-renderer-v8/src/queue_microtask.rs @@ -12,7 +12,16 @@ use crate::{ }; // The two global surfaces enqueue the same browser-created V8 trampoline. -pub(crate) use queue_microtask_callback as window_queue_microtask_callback; +pub(crate) fn window_queue_microtask_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + rv: v8::ReturnValue<'_, v8::Value>, +) { + if !crate::context_bootstrap::require_same_origin_window_receiver(scope, args.this(), false) { + return; + } + queue_microtask_callback(scope, args, rv); +} pub(crate) use queue_microtask_callback as worker_queue_microtask_callback; #[derive(webidl::WebIdlArgs)] diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 53f677bf4d..3f964c24c8 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2481,3 +2481,4 @@ mod sqlite_web_storage; mod svg_number_list_native_receivers; mod webtransport_stream_interface_exposure; mod window_restricted_accessors; +mod window_scheduling_receivers; diff --git a/moli-renderer-v8/src/script_vm/tests/window_scheduling_receivers.rs b/moli-renderer-v8/src/script_vm/tests/window_scheduling_receivers.rs new file mode 100644 index 0000000000..81ae941dc2 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/window_scheduling_receivers.rs @@ -0,0 +1,103 @@ +use super::*; + +#[test] +fn window_scheduling_methods_accept_native_bridge_receivers_without_inheriting_their_brand() { + let mut vm = new_storage_test_vm("https://window-scheduling-receivers.test/"); + assert_eq!( + vm.eval( + r#" +(() => { + const native = __moliNativeBridge.window; + const cancellations = {setTimeout:'clearTimeout', setInterval:'clearInterval', + requestAnimationFrame:'cancelAnimationFrame', requestIdleCallback:'cancelIdleCallback'}; + let traps = 0, conversions = 0; + const proxy = new Proxy(native, {get() { traps++; throw new Error('unexpected trap'); }}); + const number = {valueOf() { conversions++; return 2147483647; }}; + globalThis.__nativeSchedulingCallbacks = 0; + for (const name of [...Object.keys(cancellations), ...Object.values(cancellations), 'queueMicrotask']) { + const args = cancellations[name] || name === 'queueMicrotask' ? + [() => { __nativeSchedulingCallbacks++; }, number] : [number]; + for (const receiver of [proxy, Object.create(native), {__moliNativeBridge}]) { + try { + window[name].apply(receiver, args); + return name + ' accepted forged receiver'; + } catch (error) { + if (!(error instanceof TypeError)) return name + ' wrong receiver error'; + } + } + if (conversions !== 0) return name + ' converted before rejecting forged receiver'; + const id = window[name].apply(native, args); + if (cancellations[name]) { + if (!Number.isInteger(id) || id <= 0) return name + ' native timer handle'; + window[cancellations[name]].call(native, id); + } else if (id !== undefined) { + return name + ' native return value'; + } + conversions = 0; + } + return traps === 0 ? 'ok' : 'receiver invoked traps'; +})() +"#, + ) + .unwrap(), + "ok", + ); + assert_eq!(vm.eval("__nativeSchedulingCallbacks").unwrap(), "1"); +} + +#[tokio::test(flavor = "current_thread")] +async fn window_scheduling_methods_validate_receivers_before_conversion_and_queuing() { + let script = include_str!(concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/window-scheduling-receivers.js" + )); + let server = StaticHttpServer::spawn_with_bodies(vec![ + "local".to_owned(), + "remote".to_owned(), + ]) + .await; + let loader = static_http_loader([ + server.resolve_entry("localhost"), + server.resolve_entry("127.0.0.1"), + ]); + let parent_url = server.url_for_host("localhost", "/page.html"); + let same_url = server.url_for_host("localhost", "/local.html"); + let cross_url = server.url_for_host("127.0.0.1", "/remote.html"); + let mut vm = new_storage_page_task_executor_test_vm_with_loader(parent_url.as_str(), &loader); + vm.exec( + &format!( + r#" +if (!document.documentElement) document.appendChild(document.createElement('html')); +if (!document.body) document.documentElement.appendChild(document.createElement('body')); +globalThis.__schedulingReceiversResult = null; +({script})({{sameURL: {same_url:?}, crossURL: {cross_url:?}}}).then( + result => {{ __schedulingReceiversResult = result; }}, + error => {{ __schedulingReceiversResult = {{error: String(error)}}; }} +); +"#, + same_url = same_url.as_str(), + cross_url = cross_url.as_str(), + ), + None, + ) + .unwrap(); + advance_page_task_executor_until_eval_equals( + &mut vm, + &loader, + "String(__schedulingReceiversResult !== null)", + "true", + "Window scheduling receiver regression should finish", + ) + .await; + let result: serde_json::Value = serde_json::from_str( + &vm.eval("JSON.stringify(__schedulingReceiversResult)") + .expect("Window scheduling receiver observations"), + ) + .unwrap(); + assert_eq!(result["checks"], 863, "{result}"); + assert_eq!(result["failures"], serde_json::json!([]), "{result}"); + assert_eq!( + server.finish_targets().await, + ["/local.html", "/remote.html"] + ); +} diff --git a/moli-renderer-v8/src/window_host.rs b/moli-renderer-v8/src/window_host.rs index 2d2c9b45b4..08c9b4bda8 100644 --- a/moli-renderer-v8/src/window_host.rs +++ b/moli-renderer-v8/src/window_host.rs @@ -11,6 +11,7 @@ use super::{ context_bootstrap::is_simple_event_target, context_bootstrap::mark_event_trusted, context_bootstrap::performance_slot_number, + context_bootstrap::require_same_origin_window_receiver, context_bootstrap::set_event_trusted, context_bootstrap::simple_event_target_add_event_listener_callback, context_bootstrap::simple_event_target_dispatch_event_callback, @@ -634,6 +635,9 @@ pub(super) fn window_set_timeout_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !require_same_origin_window_receiver(scope, args.this(), false) { + return; + } rv.set_uint32(0); let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { return; @@ -679,6 +683,9 @@ pub(super) fn window_set_interval_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !require_same_origin_window_receiver(scope, args.this(), false) { + return; + } rv.set_uint32(0); let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { return; @@ -817,8 +824,7 @@ pub(super) fn window_clear_timer_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - if !crate::context_bootstrap::is_window_receiver(scope, args.this()) { - throw_type_error(scope, "Illegal invocation"); + if !require_same_origin_window_receiver(scope, args.this(), false) { return; } let id_val = args.get(0); @@ -831,8 +837,7 @@ pub(super) fn window_cancel_animation_frame_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - if !crate::context_bootstrap::is_window_receiver(scope, args.this()) { - throw_type_error(scope, "Illegal invocation"); + if !require_same_origin_window_receiver(scope, args.this(), false) { return; } let Some(parsed) = webidl::parse_args::(scope, &args) else { @@ -846,8 +851,7 @@ pub(super) fn window_cancel_idle_callback<'s>( args: v8::FunctionCallbackArguments<'s>, _rv: v8::ReturnValue<'_, v8::Value>, ) { - if !crate::context_bootstrap::is_window_receiver(scope, args.this()) { - throw_type_error(scope, "Illegal invocation"); + if !require_same_origin_window_receiver(scope, args.this(), false) { return; } let Some(parsed) = webidl::parse_args::(scope, &args) else { @@ -876,6 +880,9 @@ pub(super) fn window_request_animation_frame_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !require_same_origin_window_receiver(scope, args.this(), false) { + return; + } rv.set_uint32(0); let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { return; @@ -918,6 +925,9 @@ pub(super) fn window_request_idle_callback<'s>( args: v8::FunctionCallbackArguments<'s>, mut rv: v8::ReturnValue<'_, v8::Value>, ) { + if !require_same_origin_window_receiver(scope, args.this(), false) { + return; + } rv.set_uint32(0); let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) else { return; diff --git a/moli-renderer-v8/tests/fixtures/window-scheduling-receivers.js b/moli-renderer-v8/tests/fixtures/window-scheduling-receivers.js new file mode 100644 index 0000000000..0f6a55b249 --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/window-scheduling-receivers.js @@ -0,0 +1,111 @@ +async function probeWindowSchedulingReceivers({sameURL, crossURL}) { + let checks = 0, conversions = 0, traps = 0, deniedCallbacks = 0, acceptedMicrotasks = 0; + const failures = []; + const equal = (actual, expected, label) => { + checks++; + if (JSON.stringify(actual) !== JSON.stringify(expected)) failures.push({label, actual, expected}); + }; + const observe = (callback, realm) => { + try { callback(); return 'returned'; } + catch (error) { + return [error.name, error instanceof realm.TypeError, error instanceof realm.DOMException]; + } + }; + const frame = async url => { + const element = document.createElement('iframe'); + const loaded = new Promise(resolve => element.onload = resolve); + element.src = url; + document.body.append(element); + await loaded; + return element; + }; + const localFrame = await frame(sameURL), remoteFrame = await frame(crossURL); + const local = localFrame.contentWindow, remote = remoteFrame.contentWindow; + const methods = ['setTimeout', 'setInterval', 'clearTimeout', 'clearInterval', + 'requestAnimationFrame', 'cancelAnimationFrame', 'requestIdleCallback', 'cancelIdleCallback', + 'queueMicrotask']; + const cancellations = {setTimeout:'clearTimeout', setInterval:'clearInterval', + requestAnimationFrame:'cancelAnimationFrame', requestIdleCallback:'cancelIdleCallback'}; + const realms = [['parent', window], ['child', local]].map(([label, realm]) => ({ + label, realm, functions:Object.fromEntries(methods.map(name => [name, realm[name]])) + })); + const handler = new Proxy({}, {get() { traps++; throw new Error('unexpected Proxy trap'); }}); + const revoked = Proxy.revocable(remote, {}); + revoked.revoke(); + const invalid = [ + ['plain', {}], ['inherited', Object.create(window)], ['cross-inherited', Object.create(remote)], + ['prototype', Object.create(Window.prototype)], ['proxy', new Proxy(window, handler)], + ['cross-proxy', new Proxy(remote, handler)], ['revoked', revoked.proxy], + ['forged bridge', {__moliNativeBridge:window.__moliNativeBridge}], + ['bridge getter', {get __moliNativeBridge() { traps++; throw new Error('unexpected bridge getter'); }}], + ['document', document], ['primitive', 1] + ]; + const deniedCallback = () => { deniedCallbacks++; }; + const number = {valueOf() { conversions++; return 2147483647; }}; + const source = {toString() { conversions++; return ''; }}; + const options = {get timeout() { conversions++; return 2147483647; }}; + const argumentsFor = name => { + if (name === 'setTimeout' || name === 'setInterval') { + return [[source, number], [deniedCallback, number], [Symbol('source')], []]; + } + if (name === 'requestIdleCallback') return [[deniedCallback, options], [null], []]; + if (name === 'requestAnimationFrame' || name === 'queueMicrotask') return [[deniedCallback], [null], []]; + return [[number], [Symbol('handle')], []]; + }; + for (const {label, realm, functions} of realms) { + for (const name of methods) { + for (const [kind, receiver, expected] of [ + ['cross-origin', remote, ['SecurityError', false, true]], + ...invalid.map(([kind, receiver]) => [kind, receiver, ['TypeError', true, false]]) + ]) { + for (const args of argumentsFor(name)) { + equal(observe(() => { + const id = functions[name].apply(receiver, args); + // Keep an unfixed baseline bounded if it incorrectly schedules work. + if (cancellations[name]) functions[cancellations[name]].call(realm, id); + }, realm), expected, label + ' ' + name + ' rejects ' + kind + ' with ' + args.length + ' arguments'); + } + } + } + } + equal(conversions, 0, 'denied receivers are checked before string, number, or dictionary conversion'); + equal(traps, 0, 'receiver checks invoke no author getters or Proxy traps'); + await Promise.resolve(); + equal(deniedCallbacks, 0, 'denied calls do not enqueue microtasks or callbacks'); + + for (const {label, realm, functions} of realms) { + for (const receiver of [window, local, null, undefined]) { + const target = receiver ?? realm; + for (const name of methods) { + const callback = new Proxy(() => { + if (name === 'queueMicrotask') acceptedMicrotasks++; + else deniedCallbacks++; + }, {}); + const args = name === 'setTimeout' || name === 'setInterval' ? [callback, 2147483647] : + name === 'requestAnimationFrame' || name === 'requestIdleCallback' || name === 'queueMicrotask' ? + [callback] : [2147483647]; + equal(observe(() => { + const id = functions[name].apply(receiver, args); + if (cancellations[name]) { + equal(Number.isInteger(id) && id > 0, true, label + ' ' + name + ' returns a timer handle'); + target[cancellations[name]](id); + } else { + equal(id, undefined, label + ' ' + name + ' returns undefined'); + } + }, realm), 'returned', label + ' ' + name + ' accepts a genuine or nullish receiver'); + } + } + } + await Promise.resolve(); + equal(acceptedMicrotasks, 8, 'accepted callable Proxies run as microtasks'); + equal(deniedCallbacks, 0, 'cancelled work does not execute'); + remoteFrame.remove(); + for (const {label, realm, functions} of realms) { + for (const name of methods) { + equal(observe(() => functions[name].call(remote), realm), ['SecurityError', false, true], + label + ' ' + name + ' still denies a discarded cross-origin Window'); + } + } + localFrame.remove(); + return {checks, failures}; +}