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