mirror of
https://github.com/lexmount/moli.git
synced 2026-11-13 16:10:01 +00:00
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.
This commit is contained in:
@@ -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::{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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![
|
||||
"<!doctype html><body>local".to_owned(),
|
||||
"<!doctype html><body>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"]
|
||||
);
|
||||
}
|
||||
@@ -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::<WindowCancelAnimationFrameArgs>(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::<WindowCancelIdleCallbackArgs>(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;
|
||||
|
||||
@@ -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};
|
||||
}
|
||||
Reference in New Issue
Block a user