fix(dom): validate AbortController receivers with native brands

This commit is contained in:
ldm0
2026-10-22 14:27:11 +08:00
committed by Donough Liu
parent bd8e0b27a9
commit b869e4451c
9 changed files with 285 additions and 7 deletions
+3 -2
View File
@@ -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;
+5 -2
View File
@@ -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);
})()