mirror of
https://github.com/lexmount/moli.git
synced 2026-10-03 08:00:49 +00:00
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:
@@ -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,
|
||||
|
||||
@@ -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)});
|
||||
})();
|
||||
@@ -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, ®istry)?;
|
||||
native_identity_target(scope, object, ®istry)
|
||||
}
|
||||
|
||||
pub fn implements_interface<'s>(
|
||||
scope: &mut v8::PinScope<'s, '_>,
|
||||
object: v8::Local<'s, v8::Object>,
|
||||
|
||||
@@ -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::{
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user