diff --git a/moli-renderer-v8/src/context_bootstrap.rs b/moli-renderer-v8/src/context_bootstrap.rs index 7ddeb9a0d5..29f5ba3c06 100644 --- a/moli-renderer-v8/src/context_bootstrap.rs +++ b/moli-renderer-v8/src/context_bootstrap.rs @@ -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, diff --git a/moli-renderer-v8/src/context_bootstrap/range.rs b/moli-renderer-v8/src/context_bootstrap/range.rs index 01c7a74680..715b9327dd 100644 --- a/moli-renderer-v8/src/context_bootstrap/range.rs +++ b/moli-renderer-v8/src/context_bootstrap/range.rs @@ -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, diff --git a/moli-renderer-v8/src/context_bootstrap/resize_observer_runtime.rs b/moli-renderer-v8/src/context_bootstrap/resize_observer_runtime.rs index 256335e282..735b9e14ee 100644 --- a/moli-renderer-v8/src/context_bootstrap/resize_observer_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/resize_observer_runtime.rs @@ -366,13 +366,15 @@ fn resize_observer_element_arg<'s>( index: i32, message: &'static str, ) -> Result, 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::::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::::try_from(record).ok(), target, diff --git a/moli-renderer-v8/src/native_bridge/mod.rs b/moli-renderer-v8/src/native_bridge/mod.rs index d858708d20..7e677c128d 100644 --- a/moli-renderer-v8/src/native_bridge/mod.rs +++ b/moli-renderer-v8/src/native_bridge/mod.rs @@ -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, diff --git a/moli-renderer-v8/src/native_bridge/node.rs b/moli-renderer-v8/src/native_bridge/node.rs index 000134c175..f0a7ba0efd 100644 --- a/moli-renderer-v8/src/native_bridge/node.rs +++ b/moli-renderer-v8/src/native_bridge/node.rs @@ -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 { + let object = v8::Local::::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, diff --git a/moli-renderer-v8/src/observer_runtime/mod.rs b/moli-renderer-v8/src/observer_runtime/mod.rs index eabc581fc6..5113aed074 100644 --- a/moli-renderer-v8/src/observer_runtime/mod.rs +++ b/moli-renderer-v8/src/observer_runtime/mod.rs @@ -411,8 +411,8 @@ struct MutationObserverConstructorArgs { #[derive(webidl::WebIdlDictionary)] #[webidl(prefix = "IntersectionObserverInit")] struct IntersectionObserverInitMembers<'s> { - #[webidl(legacy_nullish, converter = "raw")] - root: Option>, + #[webidl(with = intersection_observer_root_member)] + root: Option, #[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, webidl::WebIdlError> { + let Some(value) = webidl::legacy_optional_member::>( + 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>, @@ -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." diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 3dffa95122..c6babb3828 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2180,5 +2180,6 @@ mod navigation_timing_inheritance; mod response_blob_mime; mod intersection_target_order; +mod observer_element_arguments; mod media_device_interfaces; diff --git a/moli-renderer-v8/src/script_vm/tests/observer_element_arguments.rs b/moli-renderer-v8/src/script_vm/tests/observer_element_arguments.rs new file mode 100644 index 0000000000..b1c5f5c6bc --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/observer_element_arguments.rs @@ -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/", + "", + ); + 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::::try_from(value).unwrap(); + assert!(crate::web_api_interfaces::Element::is_instance( + scope, object + )); + Ok(()) + }) + .unwrap(); +} diff --git a/moli-renderer-v8/tests/fixtures/observer-element-arguments.js b/moli-renderer-v8/tests/fixtures/observer-element-arguments.js new file mode 100644 index 0000000000..4cd6087c9c --- /dev/null +++ b/moli-renderer-v8/tests/fixtures/observer-element-arguments.js @@ -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)}); +})(); diff --git a/moli-webapi-declare/src/brand.rs b/moli-webapi-declare/src/brand.rs index e971bf5d58..f70636358f 100644 --- a/moli-webapi-declare/src/brand.rs +++ b/moli-webapi-declare/src/brand.rs @@ -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> { + let registry = scope.get_slot::>>().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>, diff --git a/moli-webapi-declare/src/lib.rs b/moli-webapi-declare/src/lib.rs index 62d10709be..a1c8a09c31 100644 --- a/moli-webapi-declare/src/lib.rs +++ b/moli-webapi-declare/src/lib.rs @@ -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::{ diff --git a/moli-webapi-declare/tests/brand.rs b/moli-webapi-declare/tests/brand.rs index 9d8ef98c3c..ce7bf8a0ad 100644 --- a/moli-webapi-declare/tests/brand.rs +++ b/moli-webapi-declare/tests/brand.rs @@ -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::::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::::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)]