fix(webidl): validate observer Element arguments with native brands

Use native Web API identity for ResizeObserver and IntersectionObserver targets and roots, accepting genuine cross-realm objects and registered native proxies while rejecting forged, inherited and author-Proxy values. Validate root membership during dictionary conversion, before later getters, and resolve native targets without reading public nodeType properties.

A 204-case regression matrix covers both observer interfaces and realms, native proxy acceptance, invalid targets, poisoned properties, option conversion order and original exception identity.

Expose the backing-object lookup from the existing native brand layer rather than unwrapping author Proxies in DOM callbacks. Extend its existing identity tests for cross-realm objects, forged objects, registered native Proxies, outer author Proxies and revocation.
This commit is contained in:
ldm0
2026-10-02 01:10:35 +08:00
committed by Donough Liu
parent f776d539bb
commit 0a87c564fc
12 changed files with 262 additions and 28 deletions
@@ -362,7 +362,6 @@ pub(crate) use self::performance_runtime::{
record_performance_load_event_start, record_resource_performance_entry,
run_resource_timing_buffer_full_task,
};
use self::range::callback_arg_node_object;
use self::range_live::{
update_live_ranges_for_character_data_edit, update_live_ranges_for_character_data_reset,
update_live_ranges_for_child_insertion, update_live_ranges_for_child_removal,
@@ -7,7 +7,7 @@ mod object;
mod static_storage;
mod validation;
pub(super) use arguments::{callback_arg_node_object, webidl_node_arg};
pub(super) use arguments::webidl_node_arg;
pub(super) use exceptions::throw_named_dom_exception;
pub(super) use object::{
RANGE_WRAPPER_INTERNAL_FIELD_COUNT, current_document_object, initialize_range_object,
@@ -366,13 +366,15 @@ fn resize_observer_element_arg<'s>(
index: i32,
message: &'static str,
) -> Result<v8::Local<'s, v8::Object>, webidl::WebIdlError> {
let object = callback_arg_node_object(scope, args, index)
.ok_or_else(|| webidl::WebIdlError::custom_message(message))?;
if object_number_property(scope, object, "nodeType") == Some(1.0) {
Ok(object)
} else {
Err(webidl::WebIdlError::custom_message(message))
}
crate::native_bridge::branded_node_handle(
scope,
args.get(index),
web_api_interfaces::Element::DESCRIPTOR,
)
.ok_or_else(|| webidl::WebIdlError::custom_message(message))?;
// Entries retain the supplied platform object, including native proxies.
Ok(v8::Local::<v8::Object>::try_from(args.get(index))
.expect("branded Element argument must be an object"))
}
fn resize_observer_options_arg<'s>(
@@ -409,7 +411,11 @@ fn build_resize_observer_entries<'s>(
let Some(target) = observed_record_target(scope, record) else {
continue;
};
let handle = crate::native_bridge::callback_value_dom_handle(scope, target);
let handle = crate::native_bridge::branded_node_handle(
scope,
target,
web_api_interfaces::Element::DESCRIPTOR,
);
pending.push(PendingEntry {
record: v8::Local::<v8::Object>::try_from(record).ok(),
target,
@@ -75,6 +75,7 @@ pub(crate) use element::{
compute_mock_intersection_client_rect, compute_mock_intersection_scrollport_client_rect,
};
pub(super) use helpers::*;
pub(crate) use node::branded_node_handle;
pub(crate) use node::{
current_or_live_delegate_node_arg_handle, node_or_foreign_arg_handle_allow_detached,
node_relevant_context, node_runtime_and_handle_from_object,
@@ -1302,6 +1302,23 @@ pub(crate) fn node_runtime_and_handle_from_object_or_detached<'s>(
Ok((runtime_ptr, handle))
}
/// Resolve a DOM interface argument using native identity, without consulting
/// author properties. Only registered native proxies can expose a backing node.
pub(crate) fn branded_node_handle<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: v8::Local<'s, v8::Value>,
interface: moli_webapi_declare::WebApiInterfaceDescriptor,
) -> Option<DomHandle> {
let object = v8::Local::<v8::Object>::try_from(value).ok()?;
if !interface.is_instance(scope, object) {
return None;
}
let target = moli_webapi_declare::web_api_object_target(scope, object)?;
node_runtime_and_handle_from_object_or_detached(scope, target)
.ok()
.map(|(_, handle)| handle)
}
pub(crate) fn current_or_live_delegate_node_arg_handle(
scope: &mut v8::PinScope<'_, '_>,
runtime_ptr: *mut JsContextHost,
+53 -16
View File
@@ -411,8 +411,8 @@ struct MutationObserverConstructorArgs {
#[derive(webidl::WebIdlDictionary)]
#[webidl(prefix = "IntersectionObserverInit")]
struct IntersectionObserverInitMembers<'s> {
#[webidl(legacy_nullish, converter = "raw")]
root: Option<v8::Local<'s, v8::Value>>,
#[webidl(with = intersection_observer_root_member)]
root: Option<NativeNodeId>,
#[webidl(default = "0px")]
root_margin: String,
#[webidl(default = "0px")]
@@ -1214,12 +1214,16 @@ pub(super) fn intersection_observer_constructor_callback<'s>(
rv.set(args.this().into());
}
pub(super) fn intersection_observer_observe_callback(
scope: &mut v8::PinScope<'_, '_>,
args: v8::FunctionCallbackArguments<'_>,
pub(super) fn intersection_observer_observe_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(target) = callback_value_dom_handle(scope, args.get(0)) else {
let Some(target) = crate::native_bridge::branded_node_handle(
scope,
args.get(0),
web_api_interfaces::Element::DESCRIPTOR,
) else {
throw_type_error(
scope,
"Failed to execute 'observe' on 'IntersectionObserver': parameter 1 is not of type 'Element'.",
@@ -1240,12 +1244,16 @@ pub(super) fn intersection_observer_observe_callback(
rv.set_undefined();
}
pub(super) fn intersection_observer_unobserve_callback(
scope: &mut v8::PinScope<'_, '_>,
args: v8::FunctionCallbackArguments<'_>,
pub(super) fn intersection_observer_unobserve_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'_, v8::Value>,
) {
let Some(target) = callback_value_dom_handle(scope, args.get(0)) else {
let Some(target) = crate::native_bridge::branded_node_handle(
scope,
args.get(0),
web_api_interfaces::Element::DESCRIPTOR,
) else {
throw_type_error(
scope,
"Failed to execute 'unobserve' on 'IntersectionObserver': parameter 1 is not of type 'Element'.",
@@ -1584,6 +1592,40 @@ enum IntersectionObserverOptionsError {
Range(&'static str),
}
fn intersection_observer_root_member<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
member: &'static str,
) -> Result<Option<NativeNodeId>, webidl::WebIdlError> {
let Some(value) = webidl::legacy_optional_member::<v8::Local<'s, v8::Value>>(
scope,
object,
member,
webidl::Context::member("IntersectionObserverInit", member),
)?
else {
return Ok(None);
};
let root = crate::native_bridge::branded_node_handle(
scope,
value,
web_api_interfaces::Node::DESCRIPTOR,
)
.ok_or_else(|| {
webidl::WebIdlError::custom_message(
"Failed to construct 'IntersectionObserver': root is not a Node.",
)
})?;
let valid = context_host_ptr_from_global_bridge(scope)
.is_some_and(|host_ptr| dom_access::is_intersection_root(host_ptr, root));
if !valid {
return Err(webidl::WebIdlError::custom_message(
"Failed to construct 'IntersectionObserver': root must be an Element or Document.",
));
}
Ok(Some(root))
}
fn parse_intersection_observer_options<'s>(
scope: &mut v8::PinScope<'s, '_>,
value: Option<v8::Local<'s, v8::Value>>,
@@ -1603,12 +1645,7 @@ fn parse_intersection_observer_options<'s>(
};
let mut options = IntersectionObserverOptions::default();
if let Some(root_value) = init.root {
let Some(root) = callback_value_dom_handle(scope, root_value) else {
return Err(IntersectionObserverOptionsError::Type(
"Failed to construct 'IntersectionObserver': root is not a Node.".to_owned(),
));
};
if let Some(root) = init.root {
if !root_is_valid(root) {
return Err(IntersectionObserverOptionsError::Type(
"Failed to construct 'IntersectionObserver': root must be an Element or Document."
@@ -2180,5 +2180,6 @@ mod navigation_timing_inheritance;
mod response_blob_mime;
mod intersection_target_order;
mod observer_element_arguments;
mod media_device_interfaces;
@@ -0,0 +1,30 @@
use super::*;
#[test]
fn observer_element_arguments_use_native_interface_identity() {
let mut vm = new_parsed_test_vm(
"https://observer-element-arguments.test/",
"<!doctype html><body></body>",
);
let result = vm
.eval(include_str!(
"../../../tests/fixtures/observer-element-arguments.js"
))
.unwrap();
assert_eq!(result, r#"{"total":204,"failures":[]}"#);
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, "__observerNativeElement").unwrap();
let value = global.get(scope, key.into()).unwrap();
assert!(
value.is_proxy(),
"fixture must cover a registered native Proxy"
);
let object = v8::Local::<v8::Object>::try_from(value).unwrap();
assert!(crate::web_api_interfaces::Element::is_instance(
scope, object
));
Ok(())
})
.unwrap();
}
@@ -0,0 +1,121 @@
(() => {
const rows = [];
function check(name, test) {
try {
test();
rows.push({name, passed: true});
} catch (error) {
rows.push({name, passed: false, error: String(error)});
}
}
function assert(condition, message) {
if (!condition) throw new Error(message);
}
const frame = document.createElement('iframe');
document.body.appendChild(frame);
const other = frame.contentWindow;
const detached = document.implementation.createHTMLDocument('');
const element = document.createElement('div');
const foreign = other.document.createElement('div');
const nativeProxy = detached.createElement('select');
globalThis.__observerNativeElement = nativeProxy;
let propertyReads = 0;
const poison = () => { propertyReads++; throw new Error('public node property read'); };
for (const target of [element, foreign, nativeProxy]) {
Object.defineProperty(target, 'nodeType', {get: poison, configurable: true});
}
const text = document.createTextNode('text');
Object.defineProperty(text, 'nodeType', {value: 1});
const revoked = Proxy.revocable(element, {});
revoked.revoke();
const forged = {get nodeType() { return poison(); }};
const authorProxy = new Proxy(element, {
get: poison, getPrototypeOf: poison, has: poison,
});
const valid = [
['element with poisoned nodeType', element],
['cross-realm element with poisoned nodeType', foreign],
['windowless div', detached.createElement('div')],
['windowless select with poisoned nodeType', nativeProxy],
['SVG element', document.createElementNS('http://www.w3.org/2000/svg', 'svg')],
];
const invalid = [
['undefined', undefined], ['null', null], ['number', 1], ['string', 'div'],
['plain object', {nodeType: 1}], ['poisoned plain object', forged],
['inherited instance', Object.create(element)],
['forged prototype', Object.create(Element.prototype)],
['interface prototype', Element.prototype], ['spoofed Text', text],
['Document', document], ['DocumentFragment', document.createDocumentFragment()],
['author Proxy', authorProxy], ['revoked Proxy', revoked.proxy],
['author Proxy around native select', new Proxy(nativeProxy, {get: poison})],
];
for (const [realmName, realm] of [['main', window], ['child', other]]) {
for (const api of ['ResizeObserver', 'IntersectionObserver']) {
for (const operation of ['observe', 'unobserve']) {
for (const [name, target] of valid) {
check(`${realmName} ${api}.${operation}: ${name}`, () => {
const observer = new realm[api](() => {});
let optionsReads = 0;
const before = propertyReads;
try {
observer[operation](target, {get box() { optionsReads++; return 'content-box'; }});
assert(propertyReads === before, 'must not read nodeType or run Proxy traps');
assert(optionsReads === (api === 'ResizeObserver' && operation === 'observe' ? 1 : 0),
'convert options exactly once after Element');
} finally { observer.disconnect(); }
});
}
for (const [name, target] of invalid) {
check(`${realmName} ${api}.${operation}: rejects ${name}`, () => {
const observer = new realm[api](() => {});
const before = propertyReads;
let optionsReads = 0;
let caught;
try {
observer[operation](target, {get box() { optionsReads++; return 'content-box'; }});
} catch (error) { caught = error; }
finally { observer.disconnect(); }
assert(caught instanceof realm.TypeError, 'must throw callee-realm TypeError');
assert(propertyReads === before, 'must not inspect author properties or Proxy traps');
assert(optionsReads === 0, 'reject Element before converting options');
});
}
}
}
for (const [name, root] of [...valid, ['document', document], ['child document', other.document],
['windowless document', detached], ['null', null]]) {
check(`${realmName} IntersectionObserver.root: ${name}`, () => {
const observer = new realm.IntersectionObserver(() => {}, {root});
try { assert(observer.root === root, 'root identity must be preserved'); }
finally { observer.disconnect(); }
});
}
for (const [name, root] of invalid.filter(([name]) => !['Document', 'null', 'undefined'].includes(name))) {
check(`${realmName} IntersectionObserver.root: rejects ${name}`, () => {
const before = propertyReads;
let laterReads = 0;
let caught;
try {
new realm.IntersectionObserver(() => {}, {
root, get rootMargin() { laterReads++; return '0px'; },
});
} catch (error) { caught = error; }
assert(caught instanceof realm.TypeError, 'invalid root must throw callee-realm TypeError');
assert(propertyReads === before && laterReads === 0, 'root conversion precedes later dictionary members');
});
}
check(`${realmName} ResizeObserver: options exception identity`, () => {
const observer = new realm.ResizeObserver(() => {});
const sentinel = {};
let caught;
try { observer.observe(element, {get box() { throw sentinel; }}); }
catch (error) { caught = error; }
finally { observer.disconnect(); }
assert(caught === sentinel, 'valid Element must reach options and propagate its exception');
});
}
for (const target of [element, foreign, nativeProxy]) delete target.nodeType;
frame.remove();
globalThis.__observerElementResults = rows;
return JSON.stringify({total: rows.length, failures: rows.filter(row => !row.passed)});
})();
+12
View File
@@ -174,6 +174,18 @@ pub fn web_api_object_type<'s>(
})
}
/// Returns the native backing object for a branded instance. Only explicitly
/// registered native Proxies share their target; author and revoked Proxies
/// are rejected without invoking traps.
pub fn web_api_object_target<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
) -> Option<v8::Local<'s, v8::Object>> {
let registry = scope.get_slot::<Rc<RefCell<TypeRegistry>>>().cloned()?;
object_type_id(scope, object, &registry)?;
native_identity_target(scope, object, &registry)
}
pub fn implements_interface<'s>(
scope: &mut v8::PinScope<'s, '_>,
object: v8::Local<'s, v8::Object>,
+1 -1
View File
@@ -279,7 +279,7 @@ macro_rules! web_api_constructor {
pub use brand::{
WebApiType, implements_interface, initialize_web_api_object, register_web_api_interfaces,
register_web_api_proxy, web_api_object_type,
register_web_api_proxy, web_api_object_target, web_api_object_type,
};
pub use declaration::{
+11 -1
View File
@@ -3,7 +3,7 @@ use std::pin::pin;
use moli_v8_test_util::ensure_v8;
use moli_webapi_declare::{
WebApiObject, implements_interface, initialize_web_api_object, register_web_api_interfaces,
web_api_object_type,
web_api_object_target, web_api_object_type,
};
#[derive(WebApiObject)]
@@ -77,6 +77,7 @@ fn declarations_brand_instances_and_preserve_derived_identity_across_realms() {
"TestDerived"
);
assert!(implements_interface(scope, object, "TestBase"));
assert_eq!(web_api_object_target(scope, object), Some(object));
}
#[test]
@@ -97,6 +98,7 @@ fn identity_is_own_private_and_never_invokes_author_code() {
eval(scope, "Reflect.ownKeys(real).length").uint32_value(scope),
Some(0)
);
assert_eq!(web_api_object_target(scope, real), Some(real));
for source in [
"({__moliWebApiType: 0})",
"({[Symbol('__moliWebApiType')]: 0})",
@@ -107,11 +109,14 @@ fn identity_is_own_private_and_never_invokes_author_code() {
let object = v8::Local::<v8::Object>::try_from(eval(scope, source)).unwrap();
assert_eq!(web_api_object_type(scope, object), None, "{source}");
assert!(!implements_interface(scope, object, "TestBase"), "{source}");
assert_eq!(web_api_object_target(scope, object), None, "{source}");
}
let plain = PlainObject::new(7).bind(scope).unwrap();
let prototype = PrototypeMembers::new().bind(scope).unwrap();
assert_eq!(web_api_object_type(scope, plain), None);
assert_eq!(web_api_object_type(scope, prototype), None);
assert_eq!(web_api_object_target(scope, plain), None);
assert_eq!(web_api_object_target(scope, prototype), None);
}
#[test]
@@ -206,17 +211,22 @@ fn only_explicitly_registered_native_proxies_share_target_identity() {
let native = v8::Proxy::new(scope, target, handler).unwrap();
let native_object = v8::Local::<v8::Object>::from(native);
assert_eq!(web_api_object_type(scope, native_object), None);
assert_eq!(web_api_object_target(scope, native_object), None);
moli_webapi_declare::register_web_api_proxy(scope, native).unwrap();
assert!(implements_interface(scope, native_object, "TestBase"));
assert_eq!(web_api_object_target(scope, native_object), Some(target));
initialize_web_api_object(scope, native_object, "TestBase").unwrap();
let impostor = v8::Proxy::new(scope, target, handler).unwrap();
assert_eq!(web_api_object_type(scope, impostor.into()), None);
assert_eq!(web_api_object_target(scope, impostor.into()), None);
let outer_handler = v8::Object::new(scope);
let outer = v8::Proxy::new(scope, native_object, outer_handler).unwrap();
assert_eq!(web_api_object_type(scope, outer.into()), None);
assert_eq!(web_api_object_target(scope, outer.into()), None);
assert!(moli_webapi_declare::register_web_api_proxy(scope, outer).is_err());
native.revoke();
assert_eq!(web_api_object_type(scope, native_object), None);
assert_eq!(web_api_object_target(scope, native_object), None);
}
#[derive(moli_webapi_declare::WebApiFunctionTemplate)]