mirror of
https://github.com/lexmount/moli.git
synced 2026-10-30 16:07:24 +00:00
fix(dom): validate AbortController receivers with native brands
This commit is contained in:
@@ -789,7 +789,8 @@ struct WorkerAbortSignalTemplateDeclaration {
|
||||
interface = web_api_interfaces::AbortController,
|
||||
constructor_callback = crate::worker::abort::worker_abort_controller_constructor_callback,
|
||||
constructor_length = 0,
|
||||
enumerable
|
||||
enumerable,
|
||||
receiver
|
||||
)]
|
||||
struct WorkerAbortControllerTemplateDeclaration {
|
||||
#[webapi(
|
||||
@@ -800,7 +801,7 @@ struct WorkerAbortControllerTemplateDeclaration {
|
||||
|
||||
#[webapi(
|
||||
method = "abort",
|
||||
length = 1,
|
||||
length = 0,
|
||||
callback = crate::worker::abort::worker_abort_controller_abort_callback
|
||||
)]
|
||||
abort: (),
|
||||
|
||||
@@ -60,7 +60,7 @@ struct AbortSignalPrototypeDeclaration {
|
||||
}
|
||||
|
||||
#[derive(WebApiFunctionTemplate)]
|
||||
#[webapi(interface = web_api_interfaces::AbortController, enumerable)]
|
||||
#[webapi(interface = web_api_interfaces::AbortController, enumerable, receiver)]
|
||||
struct AbortControllerPrototypeDeclaration {
|
||||
#[webapi(method, length = 0, callback = abort::abort_controller_abort_callback)]
|
||||
abort: (),
|
||||
|
||||
@@ -36,7 +36,9 @@ pub(crate) fn abort_controller_signal_getter_callback<'s>(
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
mut rv: v8::ReturnValue<'_, v8::Value>,
|
||||
) {
|
||||
match get_private_value(scope, args.this(), ABORT_CONTROLLER_SIGNAL_SLOT) {
|
||||
let controller = moli_webapi_declare::web_api_object_target(scope, args.this())
|
||||
.expect("AbortController receiver is checked by the binding");
|
||||
match get_private_value(scope, controller, ABORT_CONTROLLER_SIGNAL_SLOT) {
|
||||
Some(value) => rv.set(value),
|
||||
None => rv.set_null(),
|
||||
}
|
||||
@@ -51,7 +53,8 @@ pub(crate) fn abort_controller_abort_callback<'s>(
|
||||
rv.set_undefined();
|
||||
return;
|
||||
};
|
||||
let controller = args.this();
|
||||
let controller = moli_webapi_declare::web_api_object_target(scope, args.this())
|
||||
.expect("AbortController receiver is checked by the binding");
|
||||
let Some(_) = AbortStore::controller_id_from_object(scope, controller) else {
|
||||
rv.set_undefined();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn abort_controller_receivers_use_native_brands_and_callee_realms() {
|
||||
let mut vm = new_parsed_test_vm(
|
||||
"https://abort-controller-receivers.test/",
|
||||
"<!doctype html><iframe id=child></iframe>",
|
||||
);
|
||||
let result = vm
|
||||
.eval(include_str!(
|
||||
"../../../../tests/fixtures/abort-controller-receivers.js"
|
||||
))
|
||||
.expect("AbortController receiver matrix should complete");
|
||||
assert_eq!(
|
||||
result,
|
||||
"true",
|
||||
"{}",
|
||||
vm.eval("JSON.stringify(__uiEventResults.rows.filter(row => !row.passed))")
|
||||
.unwrap(),
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn abort_controller_receivers_accept_registered_native_proxies_and_preserve_reasons() {
|
||||
let mut vm = new_storage_html_test_vm("https://abort-controller-native-proxy.test/");
|
||||
vm.eval(
|
||||
r#"
|
||||
globalThis.nativeControllerTarget = new AbortController();
|
||||
globalThis.nativeControllerTraps = 0;
|
||||
globalThis.nativeController = new Proxy(nativeControllerTarget, {
|
||||
get() {nativeControllerTraps++; throw new Error('get trap');},
|
||||
getPrototypeOf() {nativeControllerTraps++; throw new Error('prototype trap');}
|
||||
});
|
||||
globalThis.nativeAbortReason = document.implementation.createHTMLDocument('').createElement('select');
|
||||
"#,
|
||||
)
|
||||
.unwrap();
|
||||
vm.with_default_context_scope_and_checkpoint_for_test(|scope, _host_ptr| {
|
||||
let global = scope.get_current_context().global(scope);
|
||||
let key = v8::String::new(scope, "nativeController").unwrap();
|
||||
let proxy = global.get(scope, key.into()).unwrap();
|
||||
let proxy = v8::Local::<v8::Proxy>::try_from(proxy).unwrap();
|
||||
moli_webapi_declare::register_web_api_proxy(scope, proxy).unwrap();
|
||||
assert!(crate::web_api_interfaces::AbortController::is_instance(
|
||||
scope,
|
||||
proxy.into()
|
||||
));
|
||||
let key = v8::String::new(scope, "nativeAbortReason").unwrap();
|
||||
let reason = global.get(scope, key.into()).unwrap();
|
||||
assert!(reason.is_proxy(), "reason must exercise a native Proxy");
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
vm.eval(
|
||||
r#"(() => {
|
||||
const get = Object.getOwnPropertyDescriptor(AbortController.prototype, 'signal').get;
|
||||
const signal = get.call(nativeController);
|
||||
if (signal !== nativeControllerTarget.signal) return false;
|
||||
AbortController.prototype.abort.call(nativeController, nativeAbortReason);
|
||||
if (!signal.aborted || signal.reason !== nativeAbortReason) return false;
|
||||
if (get.call(nativeController) !== signal || nativeControllerTraps !== 0) return false;
|
||||
const author = new Proxy(nativeController, {});
|
||||
for (const call of [() => get.call(author), () => AbortController.prototype.abort.call(author)]) {
|
||||
try { call(); return false; } catch (error) { if (!(error instanceof TypeError)) return false; }
|
||||
}
|
||||
return nativeControllerTraps === 0;
|
||||
})()"#,
|
||||
)
|
||||
.unwrap(),
|
||||
"true",
|
||||
);
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
use super::*;
|
||||
|
||||
mod abort_controller_receivers;
|
||||
mod abort_signal_events;
|
||||
mod abort_signal_statics;
|
||||
mod bar_prop;
|
||||
|
||||
@@ -454,7 +454,9 @@ pub(crate) fn worker_abort_controller_signal_getter<'s>(
|
||||
args: v8::FunctionCallbackArguments<'s>,
|
||||
mut rv: v8::ReturnValue<'_, v8::Value>,
|
||||
) {
|
||||
match get_private_value(scope, args.this(), WORKER_ABORT_CONTROLLER_SIGNAL_SLOT) {
|
||||
let controller = moli_webapi_declare::web_api_object_target(scope, args.this())
|
||||
.expect("AbortController receiver is checked by the binding");
|
||||
match get_private_value(scope, controller, WORKER_ABORT_CONTROLLER_SIGNAL_SLOT) {
|
||||
Some(value) => rv.set(value),
|
||||
None => {
|
||||
scope.throw_exception(v8::Exception::type_error(
|
||||
@@ -474,7 +476,8 @@ pub(crate) fn worker_abort_controller_abort_callback<'s>(
|
||||
rv.set_undefined();
|
||||
return;
|
||||
};
|
||||
let controller = args.this();
|
||||
let controller = moli_webapi_declare::web_api_object_target(scope, args.this())
|
||||
.expect("AbortController receiver is checked by the binding");
|
||||
let Some(controller_id) = WorkerAbortStore::controller_id_from_object(scope, controller) else {
|
||||
rv.set_undefined();
|
||||
return;
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn worker_abort_controller_receivers_use_native_brands() {
|
||||
ensure_v8();
|
||||
let probe = include_str!("../../../../tests/fixtures/abort-controller-receivers.js");
|
||||
let mut handle = spawn_worker(
|
||||
format!("{probe}\npostMessage(__uiEventResults); close();"),
|
||||
"https://abort-controller-receivers.test/worker.js".into(),
|
||||
);
|
||||
let message = timeout(TIMEOUT, handle.recv()).await.unwrap().unwrap();
|
||||
let result: serde_json::Value = serde_json::from_str(&expect_post_json(message)).unwrap();
|
||||
assert_eq!(result["complete"], true, "{result}");
|
||||
assert_eq!(result["total"], 62, "{result}");
|
||||
assert_eq!(result["passed"], result["total"], "{result}");
|
||||
}
|
||||
@@ -1550,3 +1550,5 @@ mod payment_events;
|
||||
mod performance_handler_order;
|
||||
|
||||
mod performance_user_timing;
|
||||
|
||||
mod abort_controller_receivers;
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
(() => {
|
||||
const own = (object, key) => Object.prototype.hasOwnProperty.call(object, key);
|
||||
const typeError = (body, realm) => {
|
||||
try { body(); return false; } catch (error) { return error instanceof realm.TypeError; }
|
||||
};
|
||||
const raises = (body, expected) => {
|
||||
try { body(); return false; } catch (error) { return Object.is(error, expected); }
|
||||
};
|
||||
globalThis.abortControllerReceiverProbe = (realm = globalThis, peers = [['self', realm]]) => {
|
||||
const rows = [];
|
||||
const check = (name, body) => {
|
||||
try { rows.push({name, passed: body() === true}); }
|
||||
catch (error) { rows.push({name, passed: false, error: String(error)}); }
|
||||
};
|
||||
const C = realm.AbortController;
|
||||
const signalDescriptor = Object.getOwnPropertyDescriptor(C.prototype, 'signal');
|
||||
const getter = signalDescriptor.get;
|
||||
const abortDescriptor = Object.getOwnPropertyDescriptor(C.prototype, 'abort');
|
||||
const abort = abortDescriptor.value;
|
||||
check('signal prototype descriptor', () => signalDescriptor.enumerable &&
|
||||
signalDescriptor.configurable && signalDescriptor.set === undefined &&
|
||||
getter.name === 'get signal' && getter.length === 0 &&
|
||||
Object.getPrototypeOf(getter) === realm.Function.prototype);
|
||||
check('abort prototype descriptor', () => abortDescriptor.enumerable &&
|
||||
abortDescriptor.configurable && abortDescriptor.writable && abort.name === 'abort' &&
|
||||
abort.length === 0 && Object.getPrototypeOf(abort) === realm.Function.prototype);
|
||||
check('constructor metadata and call requirements', () => C.length === 0 &&
|
||||
C.name === 'AbortController' && typeError(() => C(), realm));
|
||||
check('instances inherit methods and signal', () => {
|
||||
const controller = new C();
|
||||
return !own(controller, 'signal') && !own(controller, 'abort') &&
|
||||
Object.getPrototypeOf(controller) === C.prototype && controller.abort === abort;
|
||||
});
|
||||
check('signal is SameObject across abort', () => {
|
||||
const controller = new C(), signal = getter.call(controller);
|
||||
const before = getter.call(controller) === signal && !signal.aborted && signal.reason === undefined;
|
||||
abort.call(controller, 'reason');
|
||||
return before && getter.call(controller) === signal && controller.signal === signal &&
|
||||
signal.aborted && signal.reason === 'reason';
|
||||
});
|
||||
check('signal is readonly without converting assigned values', () => {
|
||||
const controller = new C(), signal = controller.signal;
|
||||
let calls = 0;
|
||||
const value = {[Symbol.toPrimitive]() {calls++; throw new Error('coercion');}};
|
||||
const set = realm.Function('controller', 'value', '"use strict"; controller.signal = value;');
|
||||
return Reflect.set(controller, 'signal', value) === false &&
|
||||
typeError(() => set(controller, value), realm) && controller.signal === signal && calls === 0;
|
||||
});
|
||||
for (const [peerName, peer] of peers) {
|
||||
const controller = new peer.AbortController();
|
||||
let traps = 0;
|
||||
const author = new Proxy(controller, {
|
||||
get() {traps++;}, getPrototypeOf() {traps++;}, has() {traps++;},
|
||||
ownKeys() {traps++;}, getOwnPropertyDescriptor() {traps++;}
|
||||
});
|
||||
const revoked = Proxy.revocable(controller, {}); revoked.revoke();
|
||||
const invalid = [['plain', {}], ['interface prototype', peer.AbortController.prototype],
|
||||
['forged prototype', Object.create(peer.AbortController.prototype)],
|
||||
['inherited instance', Object.create(controller)], ['author Proxy', author],
|
||||
['revoked Proxy', revoked.proxy], ['signal', controller.signal],
|
||||
['global', peer], ['null', null], ['undefined', undefined], ['number', 1],
|
||||
['boolean', true], ['string', 'controller'], ['symbol', Symbol()], ['bigint', 1n],
|
||||
['fake public identity', {__lmAbortControllerId: 1, __lmAbortControllerSignal: controller.signal}]];
|
||||
for (const [label, receiver] of invalid) {
|
||||
check(`${peerName} getter rejects ${label} in callee realm`, () =>
|
||||
typeError(() => getter.call(receiver), realm));
|
||||
check(`${peerName} abort rejects ${label} in callee realm`, () =>
|
||||
typeError(() => abort.call(receiver, controller), realm));
|
||||
}
|
||||
check(`${peerName} invalid receivers do not run author traps`, () => traps === 0);
|
||||
check(`${peerName} borrowed getter preserves original signal and realm`, () => {
|
||||
const controller = new peer.AbortController();
|
||||
return getter.call(controller) === controller.signal && controller.signal instanceof peer.AbortSignal;
|
||||
});
|
||||
check(`${peerName} borrowed abort retains reason and synchronously dispatches`, () => {
|
||||
const controller = new peer.AbortController(), signal = controller.signal, reason = {};
|
||||
let count = 0;
|
||||
signal.addEventListener('abort', event => {
|
||||
if (event.target === signal && signal.aborted && signal.reason === reason) count++;
|
||||
});
|
||||
const result = abort.call(controller, reason);
|
||||
return result === undefined && signal.aborted && signal.reason === reason && count === 1;
|
||||
});
|
||||
check(`${peerName} borrowed abort creates default error in callee realm`, () => {
|
||||
const controller = new peer.AbortController();
|
||||
abort.call(controller);
|
||||
return controller.signal.aborted && controller.signal.reason instanceof realm.DOMException &&
|
||||
controller.signal.reason.name === 'AbortError';
|
||||
});
|
||||
check(`${peerName} prototype removal preserves native controller state`, () => {
|
||||
const controller = new peer.AbortController(), signal = getter.call(controller), reason = {};
|
||||
Object.setPrototypeOf(controller, null);
|
||||
abort.call(controller, reason);
|
||||
return getter.call(controller) === signal && signal.aborted && signal.reason === reason;
|
||||
});
|
||||
}
|
||||
for (const [label, reason] of [['null', null], ['false', false], ['zero', 0],
|
||||
['negative zero', -0], ['NaN', NaN], ['string', '\ud800'], ['symbol', Symbol('reason')],
|
||||
['bigint', 9n], ['object', {}], ['array', []], ['function', () => {}]]) {
|
||||
check(`raw reason identity ${label}`, () => {
|
||||
const controller = new C(), signal = controller.signal;
|
||||
const result = abort.call(controller, reason);
|
||||
abort.call(controller, 'replacement');
|
||||
return result === undefined && signal.aborted && Object.is(signal.reason, reason) &&
|
||||
raises(() => signal.throwIfAborted(), reason);
|
||||
});
|
||||
}
|
||||
check('Proxy and revoked Proxy are accepted as raw reasons', () => {
|
||||
let traps = 0;
|
||||
const reason = new Proxy({}, {get() {traps++; throw 1;}, getPrototypeOf() {traps++; throw 2;}});
|
||||
const revoked = Proxy.revocable({}, {}); revoked.revoke();
|
||||
const a = new C(), b = new C();
|
||||
abort.call(a, reason); abort.call(b, revoked.proxy);
|
||||
return a.signal.reason === reason && b.signal.reason === revoked.proxy && traps === 0;
|
||||
});
|
||||
check('undefined reason uses a callee-realm AbortError', () => {
|
||||
const controller = new C(); abort.call(controller, undefined);
|
||||
return controller.signal.reason instanceof realm.DOMException &&
|
||||
controller.signal.reason.name === 'AbortError';
|
||||
});
|
||||
check('frozen controller and signal retain private state', () => {
|
||||
const controller = new C(), signal = controller.signal, reason = {};
|
||||
Object.freeze(controller); Object.freeze(signal);
|
||||
abort.call(controller, reason);
|
||||
return getter.call(controller) === signal && signal.aborted && signal.reason === reason;
|
||||
});
|
||||
check('own signal and abort shadows do not affect native operations', () => {
|
||||
const controller = new C(), signal = controller.signal, reason = {};
|
||||
let calls = 0;
|
||||
for (const key of ['signal', 'abort']) Object.defineProperty(controller, key, {get() {calls++; throw 1;}});
|
||||
abort.call(controller, reason);
|
||||
return getter.call(controller) === signal && signal.aborted && signal.reason === reason && calls === 0;
|
||||
});
|
||||
check('subclasses retain native state', () => {
|
||||
class Derived extends C {}
|
||||
const controller = new Derived(), signal = getter.call(controller), reason = {};
|
||||
abort.call(controller, reason);
|
||||
return controller instanceof Derived && signal.aborted && signal.reason === reason;
|
||||
});
|
||||
check('abort dispatch is once-only even for reentrant abort', () => {
|
||||
const controller = new C(), signal = controller.signal, first = {}, second = {};
|
||||
let count = 0;
|
||||
signal.addEventListener('abort', () => {count++; abort.call(controller, second);});
|
||||
abort.call(controller, first); abort.call(controller, second);
|
||||
return count === 1 && signal.reason === first;
|
||||
});
|
||||
check('author constructor replacement does not affect signal factory', () => {
|
||||
const original = realm.AbortSignal;
|
||||
let calls = 0;
|
||||
try {
|
||||
realm.AbortSignal = function() {calls++; throw 1;};
|
||||
const controller = new C();
|
||||
return controller.signal instanceof original && calls === 0;
|
||||
} finally { realm.AbortSignal = original; }
|
||||
});
|
||||
check('prototype replacement does not affect private initialization or abort', () => {
|
||||
let calls = 0;
|
||||
try {
|
||||
Object.defineProperty(C.prototype, 'signal', {configurable: true, get() {calls++; throw 1;}});
|
||||
Object.defineProperty(C.prototype, 'abort', {configurable: true, value() {calls++; throw 1;}});
|
||||
const controller = new C(), reason = {};
|
||||
abort.call(controller, reason);
|
||||
const signal = getter.call(controller);
|
||||
return signal.aborted && signal.reason === reason && !own(controller, 'signal') && calls === 0;
|
||||
} finally {
|
||||
Object.defineProperty(C.prototype, 'signal', signalDescriptor);
|
||||
Object.defineProperty(C.prototype, 'abort', abortDescriptor);
|
||||
}
|
||||
});
|
||||
return {complete: true, total: rows.length, passed: rows.filter(row => row.passed).length, rows};
|
||||
};
|
||||
const realms = typeof document === 'undefined' ? [['worker', globalThis]] :
|
||||
[['top', globalThis], ['child', document.querySelector('#child').contentWindow]];
|
||||
const rows = realms.flatMap(([name, realm]) => abortControllerReceiverProbe(realm, realms).rows
|
||||
.map(row => ({...row, name: `${name}: ${row.name}`})));
|
||||
globalThis.__uiEventResults = {complete: true, total: rows.length,
|
||||
passed: rows.filter(row => row.passed).length, rows};
|
||||
return rows.every(row => row.passed);
|
||||
})()
|
||||
Reference in New Issue
Block a user