diff --git a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs index f9b3ea120..eab8a47e9 100644 --- a/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs +++ b/moli-renderer-v8/src/context_bootstrap/assets/constructor_templates.rs @@ -55,7 +55,7 @@ use super::super::{ build_audio_context_constructor_template, build_audio_worklet_node_constructor_template, offline_audio_context_constructor_callback, }, - webrtc::rtc_peer_connection_constructor_callback, + webrtc::{rtc_ice_candidate_constructor_callback, rtc_peer_connection_constructor_callback}, websocket::{ websocket_constructor_callback, websocket_error_constructor_callback, websocket_stream_constructor_callback, @@ -424,6 +424,11 @@ pub(in crate::context_bootstrap) fn build_constructor_template<'s>( .length(0) .build(scope) } + ConstructorKind::RtcIceCandidate => { + v8::FunctionTemplate::builder(rtc_ice_candidate_constructor_callback) + .length(0) + .build(scope) + } ConstructorKind::Navigator | ConstructorKind::WorkerNavigator | ConstructorKind::Permissions diff --git a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs index 202c30a2f..94f7fd9ea 100644 --- a/moli-renderer-v8/src/context_bootstrap/runtime_state.rs +++ b/moli-renderer-v8/src/context_bootstrap/runtime_state.rs @@ -1826,6 +1826,7 @@ pub(crate) fn finish_context_bootstrap( ("WebSocketError", "WebSocketError"), ("WebSocketStream", "WebSocketStream"), ("RTCPeerConnection", "RTCPeerConnection"), + ("RTCIceCandidate", "RTCIceCandidate"), ("RTCRtpReceiver", "RTCRtpReceiver"), ("RTCDataChannel", "RTCDataChannel"), ("Blob", "Blob"), diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index f250f9b37..0051955b1 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -1026,6 +1026,11 @@ const CONSTRUCTOR_SPECS_AFTER_STREAMS: &[ConstructorSpec] = &[ parent: Some("EventTarget"), kind: ConstructorKind::RtcPeerConnection, }, + ConstructorSpec { + name: "RTCIceCandidate", + parent: None, + kind: ConstructorKind::RtcIceCandidate, + }, ConstructorSpec { name: "RTCRtpReceiver", parent: None, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/types.rs b/moli-renderer-v8/src/context_bootstrap/specs/types.rs index 01adf63e6..ce60b1ec0 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/types.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/types.rs @@ -84,6 +84,7 @@ pub(in crate::context_bootstrap) enum ConstructorKind { SharedWorker, WebSocket, RtcPeerConnection, + RtcIceCandidate, Navigator, WorkerNavigator, Permissions, diff --git a/moli-renderer-v8/src/context_bootstrap/webrtc.rs b/moli-renderer-v8/src/context_bootstrap/webrtc.rs index a36b470ba..7ef0a632c 100644 --- a/moli-renderer-v8/src/context_bootstrap/webrtc.rs +++ b/moli-renderer-v8/src/context_bootstrap/webrtc.rs @@ -4,6 +4,10 @@ use crate::util::{ }; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; +mod ice_candidate; +mod ice_candidate_parser; +pub(in crate::context_bootstrap) use ice_candidate::rtc_ice_candidate_constructor_callback; + const RTC_PEER_CONNECTION_BRAND_SLOT: &str = "__moliRtcPeerConnectionBrand"; const RTC_PEER_CONNECTION_CONFIGURATION_SLOT: &str = "__moliRtcPeerConnectionConfiguration"; const RTC_PEER_CONNECTION_SIGNALING_STATE_SLOT: &str = "__moliRtcPeerConnectionSignalingState"; @@ -202,6 +206,9 @@ pub(in crate::context_bootstrap) fn install_webrtc_template_bindings<'s>( ) { let prototype = template.prototype_template(scope); match interface_name { + "RTCIceCandidate" => { + ice_candidate::install_ice_candidate_template_bindings(scope, template) + } "RTCPeerConnection" => { RtcPeerConnectionPrototypeDeclaration::initialize_prototype_template(scope, prototype); } diff --git a/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate.rs b/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate.rs new file mode 100644 index 000000000..d4d545e9a --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate.rs @@ -0,0 +1,256 @@ +use super::ice_candidate_parser::parse_ice_candidate; +use crate::{ + util::{ + apply_webidl_constructor_prototype_fallback, callback_data_index_value, get_private_value, + throw_type_error, v8str, + }, + webidl::{self, WebIdlConverter}, +}; +use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; + +const CANDIDATE_VALUES_SLOT: &str = "__moliRtcIceCandidateValues"; + +#[derive(WebApiObject)] +#[webapi(interface = "RTCIceCandidate")] +struct IceCandidateObjectDeclaration<'scope> { + #[webapi(slot = CANDIDATE_VALUES_SLOT)] + values: v8::Local<'scope, v8::Array>, +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(name = "RTCIceCandidate", enumerable)] +struct IceCandidatePrototypeDeclaration { + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 0))] + candidate: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 1))] + sdp_mid: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 2))] + sdp_m_line_index: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 3))] + foundation: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 4))] + component: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 5))] + priority: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 6))] + address: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 7))] + protocol: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 8))] + port: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 9))] + r#type: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 10))] + tcp_type: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 11))] + related_address: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 12))] + related_port: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 13))] + username_fragment: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 14))] + relay_protocol: (), + #[webapi(accessor_property, getter = candidate_attribute_getter, data = callback_data_index_value(scope, 15))] + url: (), + #[webapi(method = "toJSON", length = 0, callback = candidate_to_json_callback)] + to_json: (), +} + +#[derive(WebApiObject)] +#[webapi(interface = "Object", data_properties, enumerable)] +struct IceCandidateJsonDeclaration<'scope> { + candidate: v8::Local<'scope, v8::Value>, + sdp_mid: v8::Local<'scope, v8::Value>, + sdp_m_line_index: v8::Local<'scope, v8::Value>, + username_fragment: v8::Local<'scope, v8::Value>, +} + +pub(super) fn install_ice_candidate_template_bindings<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, +) { + IceCandidatePrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); +} + +pub(in crate::context_bootstrap) fn rtc_ice_candidate_constructor_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + if !args.is_construct_call() { + throw_type_error(scope, "RTCIceCandidate constructor requires 'new'."); + return; + } + let dictionary = match webidl::dictionary_value( + args.get(0), + webidl::Context::argument("RTCIceCandidate", 1), + ) { + Ok(dictionary) => dictionary, + Err(error) => { + webidl::throw_error(scope, &error); + return; + } + }; + let Some(mut values) = parse_candidate_init(scope, dictionary) else { + return; + }; + if values[1].is_null() && values[2].is_null() { + throw_type_error(scope, "RTCIceCandidate requires sdpMid or sdpMLineIndex."); + return; + } + let candidate = + v8::Local::::try_from(values[0]).expect("converted candidate string"); + let candidate = candidate.to_rust_string_lossy(scope); + if let Some(parsed) = parse_ice_candidate(&candidate) { + values[3] = v8::String::new(scope, parsed.foundation).unwrap().into(); + values[4] = v8str(scope, parsed.component).into(); + values[5] = v8::Integer::new_from_unsigned(scope, parsed.priority).into(); + values[6] = v8::String::new(scope, parsed.address).unwrap().into(); + values[7] = v8str(scope, parsed.protocol).into(); + values[8] = v8::Integer::new_from_unsigned(scope, parsed.port.into()).into(); + values[9] = v8str(scope, parsed.kind).into(); + if let Some(tcp_type) = parsed.tcp_type { + values[10] = v8str(scope, tcp_type).into(); + } + if let Some(address) = parsed.related_address { + values[11] = v8::String::new(scope, address).unwrap().into(); + } + if let Some(port) = parsed.related_port { + values[12] = v8::Integer::new_from_unsigned(scope, port.into()).into(); + } + } + let values = v8::Array::new_with_elements(scope, &values); + if IceCandidateObjectDeclaration::new(values) + .initialize(scope, args.this()) + .is_err() + { + return; + } + apply_webidl_constructor_prototype_fallback( + scope, + args.this(), + args.new_target(), + "RTCIceCandidate", + ); + rv.set(args.this().into()); +} + +fn parse_candidate_init<'s>( + scope: &mut v8::PinScope<'s, '_>, + dictionary: Option>, +) -> Option<[v8::Local<'s, v8::Value>; 16]> { + let mut values = [v8::null(scope).into(); 16]; + values[0] = v8str(scope, "").into(); + let Some(dictionary) = dictionary else { + return Some(values); + }; + // Convert base dictionary members in lexical order before derived members. + // Keep DOMStrings in V8, preserving lone UTF-16 surrogates; only `url` is a USVString. + for (name, index) in [ + ("candidate", 0), + ("sdpMLineIndex", 2), + ("sdpMid", 1), + ("usernameFragment", 13), + ("relayProtocol", 14), + ("url", 15), + ] { + let raw = dictionary.get(scope, v8str(scope, name).into())?; + if raw.is_undefined() || (index != 0 && raw.is_null()) { + continue; + } + let context = webidl::Context::member("RTCLocalIceCandidateInit", name); + if index == 2 { + let value = match webidl::UnsignedShort::convert(scope, raw, context, &()) { + Ok(value) => value.0, + Err(error) => { + webidl::throw_error(scope, &error); + return None; + } + }; + values[index] = v8::Integer::new_from_unsigned(scope, value.into()).into(); + } else if index == 15 { + let value = match webidl::UsvString::convert(scope, raw, context, &Default::default()) { + Ok(value) => value.0, + Err(error) => { + webidl::throw_error(scope, &error); + return None; + } + }; + values[index] = v8::String::new(scope, &value)?.into(); + } else { + if raw.is_symbol() { + throw_type_error(scope, "Cannot convert a Symbol to a DOMString."); + return None; + } + let value = raw.to_string(scope)?; + if index == 14 + && !matches!( + value.to_rust_string_lossy(scope).as_str(), + "udp" | "tcp" | "tls" + ) + { + throw_type_error(scope, "Invalid RTCIceServerTransportProtocol."); + return None; + } + values[index] = value.into(); + } + } + Some(values) +} + +fn candidate_values<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, +) -> Option> { + let values = get_private_value(scope, receiver, CANDIDATE_VALUES_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()); + if values.is_none() { + throw_type_error(scope, "Illegal invocation"); + } + values +} + +fn candidate_attribute_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(values) = candidate_values(scope, args.this()) else { + return; + }; + let Some(index) = args.data().uint32_value(scope) else { + return; + }; + if let Some(value) = values.get_index(scope, index) { + rv.set(value); + } +} + +fn candidate_to_json_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let Some(values) = candidate_values(scope, args.this()) else { + return; + }; + let Some(candidate) = values.get_index(scope, 0) else { + return; + }; + let Some(mid) = values.get_index(scope, 1) else { + return; + }; + let Some(line) = values.get_index(scope, 2) else { + return; + }; + let Some(fragment) = values.get_index(scope, 13) else { + return; + }; + if let Ok(result) = IceCandidateJsonDeclaration::new(candidate, mid, line, fragment).bind(scope) + { + rv.set(result.into()); + } +} diff --git a/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate_parser.rs b/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate_parser.rs new file mode 100644 index 000000000..fe68ce9f3 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/webrtc/ice_candidate_parser.rs @@ -0,0 +1,181 @@ +use std::net::IpAddr; + +#[derive(Debug, PartialEq)] +pub(super) struct ParsedIceCandidate<'a> { + pub(super) foundation: &'a str, + pub(super) component: &'static str, + pub(super) priority: u32, + pub(super) address: &'a str, + pub(super) protocol: &'static str, + pub(super) port: u16, + pub(super) kind: &'static str, + pub(super) tcp_type: Option<&'static str>, + pub(super) related_address: Option<&'a str>, + pub(super) related_port: Option, +} + +// RFC 5245 section 15.1 and the TCP extension in RFC 6544 section 4.5. +// Return no derived fields if parsing or an attribute's value is invalid; +// RTCIceCandidate itself still retains the original candidate string. +pub(super) fn parse_ice_candidate(input: &str) -> Option> { + let fields: Vec<_> = input.strip_prefix("candidate:")?.split(' ').collect(); + if fields.len() < 8 || fields.iter().any(|field| field.is_empty()) { + return None; + } + let foundation = fields[0]; + if foundation.len() > 32 + || !foundation + .bytes() + .all(|ch| ch.is_ascii_alphanumeric() || matches!(ch, b'+' | b'/')) + { + return None; + } + let component = match decimal(fields[1], 5)? { + 1 => "rtp", + 2 => "rtcp", + _ => return None, + }; + let protocol = if fields[2].eq_ignore_ascii_case("udp") { + "udp" + } else if fields[2].eq_ignore_ascii_case("tcp") { + "tcp" + } else { + return None; + }; + let priority = decimal(fields[3], 10)?; + if !(1..=i32::MAX as u32).contains(&priority) || !address_is_valid(fields[4]) { + return None; + } + let port = u16::try_from(decimal(fields[5], usize::MAX)?).ok()?; + if !fields[6].eq_ignore_ascii_case("typ") { + return None; + } + let kind = ["host", "srflx", "prflx", "relay"] + .into_iter() + .find(|kind| fields[7].eq_ignore_ascii_case(kind))?; + let mut candidate = ParsedIceCandidate { + foundation, + component, + priority, + address: fields[4], + protocol, + port, + kind, + tcp_type: None, + related_address: None, + related_port: None, + }; + let extensions = fields[8..].chunks_exact(2); + if !extensions.remainder().is_empty() { + return None; + } + for pair in extensions { + if pair + .iter() + .any(|field| field.bytes().any(|ch| ch.is_ascii_control())) + { + return None; + } + if pair[0].eq_ignore_ascii_case("raddr") { + if !address_is_valid(pair[1]) || candidate.related_address.replace(pair[1]).is_some() { + return None; + } + } else if pair[0].eq_ignore_ascii_case("rport") { + let port = u16::try_from(decimal(pair[1], usize::MAX)?).ok()?; + if candidate.related_port.replace(port).is_some() { + return None; + } + } else if pair[0].eq_ignore_ascii_case("tcptype") && protocol == "tcp" { + let tcp_type = ["active", "passive", "so"] + .into_iter() + .find(|kind| pair[1].eq_ignore_ascii_case(kind))?; + if candidate.tcp_type.replace(tcp_type).is_some() { + return None; + } + } + } + if protocol == "tcp" && candidate.tcp_type.is_none() { + return None; + } + Some(candidate) +} + +fn decimal(input: &str, max_digits: usize) -> Option { + if input.is_empty() || input.len() > max_digits || !input.bytes().all(|ch| ch.is_ascii_digit()) + { + return None; + } + input.parse().ok() +} + +fn address_is_valid(input: &str) -> bool { + if input.parse::().is_ok() { + return true; + } + let hostname = input.strip_suffix('.').unwrap_or(input); + !hostname.is_empty() + && hostname.len() <= 253 + && hostname.split('.').all(|label| { + !label.is_empty() + && label.len() <= 63 + && label.as_bytes()[0].is_ascii_alphanumeric() + && label.as_bytes()[label.len() - 1].is_ascii_alphanumeric() + && label + .bytes() + .all(|ch| ch.is_ascii_alphanumeric() || ch == b'-') + }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ice_candidate_parser_handles_udp_tcp_ipv6_and_mdns() { + let udp = parse_ice_candidate( + "candidate:ab+/12 1 UDP 2113937151 2001:db8::1 65535 typ host generation 0 ufrag test", + ) + .unwrap(); + assert_eq!(udp.foundation, "ab+/12"); + assert_eq!(udp.component, "rtp"); + assert_eq!(udp.protocol, "udp"); + assert_eq!(udp.priority, 2113937151); + assert_eq!(udp.address, "2001:db8::1"); + assert_eq!(udp.port, 65535); + assert_eq!(udp.tcp_type, None); + let tcp = parse_ice_candidate("candidate:123 2 tcp 123456 foo.local 9 typ srflx raddr www.example.com rport 22222 tcptype active").unwrap(); + assert_eq!(tcp.component, "rtcp"); + assert_eq!(tcp.tcp_type, Some("active")); + assert_eq!(tcp.related_address, Some("www.example.com")); + assert_eq!(tcp.related_port, Some(22222)); + } + + #[test] + fn ice_candidate_parser_rejects_invalid_fields_without_partial_results() { + for invalid in [ + "", + "arbitrary candidate", + "candidate:", + "candidate:a 1 udp 0 127.0.0.1 9 typ host", + "candidate:a 1 udp 2147483648 127.0.0.1 9 typ host", + "candidate:a 1 udp 1 127.0.0.1 65536 typ host", + "candidate:a 1 udp 1 127.0.0.1 -1 typ host", + "candidate:a 3 udp 1 127.0.0.1 9 typ host", + "candidate:a 1 sctp 1 127.0.0.1 9 typ host", + "candidate:a 1 udp 1 127.0.0.1 9 typ unknown", + "candidate:a 1 udp 1 [::1] 9 typ host", + "candidate:a 1 udp 1 -invalid.local 9 typ host", + "candidate:a 1 tcp 1 127.0.0.1 9 typ host", + "candidate:a 1 tcp 1 127.0.0.1 9 typ host tcptype unknown", + "candidate:a 1 udp 1 127.0.0.1 9 typ host rport 65536", + "candidate:a 1 udp 1 127.0.0.1 9 typ host generation", + "candidate:a 1 udp 1 127.0.0.1 9 typ host\r\n", + "candidate:a 1 udp 1 127.0.0.1 9 typ host x y\n", + ] { + assert!( + parse_ice_candidate(invalid).is_none(), + "accepted {invalid:?}" + ); + } + } +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/ice_candidate.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/ice_candidate.rs new file mode 100644 index 000000000..558816423 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/ice_candidate.rs @@ -0,0 +1,131 @@ +use super::*; + +#[test] +fn rtc_ice_candidate_has_branded_readonly_attributes_and_four_json_members() { + let mut vm = new_storage_test_vm("https://ice-candidate.test/"); + let result = vm.eval(r#" + (() => { + const candidate = new RTCIceCandidate({sdpMid: 'audio', relayProtocol: 'tls', url: 'turn:example.com'}); + const names = ['candidate', 'sdpMid', 'sdpMLineIndex', 'foundation', 'component', + 'priority', 'address', 'protocol', 'port', 'type', 'tcpType', 'relatedAddress', + 'relatedPort', 'usernameFragment', 'relayProtocol', 'url']; + const rejects = callback => { + try { callback(); return false; } catch (error) { return error instanceof TypeError; } + }; + const checks = [ + RTCIceCandidate.length === 0, candidate instanceof RTCIceCandidate, + Object.prototype.toString.call(candidate) === '[object RTCIceCandidate]', + Object.getOwnPropertyNames(candidate).length === 0, + candidate.toJSON.length === 0, + rejects(() => RTCIceCandidate({sdpMid: 'audio'})), + rejects(() => new RTCIceCandidate()), rejects(() => new RTCIceCandidate(null)), + rejects(() => new RTCIceCandidate({})), rejects(() => new RTCIceCandidate(true)), + rejects(() => candidate.toJSON.call(Object.create(candidate))) + ]; + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(RTCIceCandidate.prototype, name); + if (!descriptor) return 'missing:' + name; + checks.push(descriptor.get.name === 'get ' + name, descriptor.get.length === 0, + descriptor.set === undefined, descriptor.enumerable, descriptor.configurable); + const value = candidate[name]; + candidate[name] = 'changed'; + checks.push(candidate[name] === value, rejects(() => { 'use strict'; candidate[name] = 'changed'; })); + for (const fake of [{}, RTCIceCandidate.prototype, Object.create(candidate)]) { + checks.push(rejects(() => descriptor.get.call(fake))); + } + } + const first = candidate.toJSON(); + const second = candidate.toJSON(); + checks.push(first !== second, JSON.stringify(first) === + '{"candidate":"","sdpMid":"audio","sdpMLineIndex":null,"usernameFragment":null}'); + const cloned = new RTCIceCandidate(candidate); + const signaled = new RTCIceCandidate(first); + checks.push(cloned.relayProtocol === 'tls', cloned.url === 'turn:example.com', + signaled.relayProtocol === null, signaled.url === null); + return checks.every(Boolean); + })() + "#).expect("RTCIceCandidate should expose the WebIDL surface without mutable own attributes"); + assert_eq!(result, "true"); +} + +#[test] +fn rtc_ice_candidate_converts_dictionary_members_in_order_and_preserves_dom_strings() { + let mut vm = new_storage_test_vm("https://ice-candidate-conversion.test/"); + let result = vm.eval(r#" + (() => { + const reads = []; + const raw = { + candidate: '\ud800', sdpMid: '\udc00', sdpMLineIndex: -1.9, + usernameFragment: '\ud800', relayProtocol: 'udp', url: 'turn:\ud800' + }; + const candidate = new RTCIceCandidate(new Proxy(raw, { + get(target, name) { reads.push(name); return target[name]; } + })); + const checks = [ + reads.join(',') === 'candidate,sdpMLineIndex,sdpMid,usernameFragment,relayProtocol,url', + candidate.candidate === raw.candidate, candidate.sdpMid === raw.sdpMid, + candidate.usernameFragment === raw.usernameFragment, + candidate.sdpMLineIndex === 65535, candidate.url === 'turn:\ufffd', + new RTCIceCandidate({sdpMLineIndex: 65536}).sdpMLineIndex === 0, + new RTCIceCandidate({sdpMLineIndex: Infinity}).sdpMLineIndex === 0, + new RTCIceCandidate({candidate: null, sdpMid: false}).candidate === 'null', + new RTCIceCandidate({sdpMid: false}).sdpMid === 'false' + ]; + for (const bad of [{candidate: Symbol()}, {sdpMid: Symbol()}, {sdpMLineIndex: 1n}, + {sdpMLineIndex: Symbol()}, {usernameFragment: Symbol()}, {relayProtocol: 'UDP'}, {url: Symbol()}]) { + try { new RTCIceCandidate({sdpMid: 'audio', ...bad}); checks.push(false); } + catch (error) { checks.push(error instanceof TypeError); } + } + const error = new RangeError('conversion'); + try { new RTCIceCandidate({candidate: {toString() { throw error; }}, sdpMid: 'audio'}); checks.push(false); } + catch (caught) { checks.push(caught === error); } + return checks.every(Boolean); + })() + "#).expect("ICE dictionary conversions should preserve WebIDL ordering and original exceptions"); + assert_eq!(result, "true"); +} + +#[test] +fn rtc_ice_candidate_invalid_strings_preserve_raw_input_without_partial_parsing() { + let mut vm = new_storage_test_vm("https://ice-candidate-parsing.test/"); + let result = vm.eval(r#" + (() => { + const derived = ['foundation', 'component', 'priority', 'address', 'protocol', + 'port', 'type', 'tcpType', 'relatedAddress', 'relatedPort']; + return ['', 'arbitrary string', 'candidate:x 1 udp 1 127.0.0.1 65536 typ host', + 'candidate:x 1 udp 1 127.0.0.1 9 typ host rport 65536'].every(raw => { + const candidate = new RTCIceCandidate({candidate: raw, sdpMid: 'video', usernameFragment: 'keep'}); + return candidate.candidate === raw && candidate.usernameFragment === 'keep' && + derived.every(name => candidate[name] === null); + }); + })() + "#).expect("invalid candidate text should not throw or expose partial derived attributes"); + assert_eq!(result, "true"); +} + +#[test] +fn rtc_ice_candidate_respects_new_target_and_accepts_genuine_foreign_receivers() { + let mut vm = new_parsed_test_vm( + "https://ice-candidate-realms.test/", + "", + ); + let result = vm.eval(r#" + (() => { + const child = document.querySelector('iframe').contentWindow; + class Derived extends RTCIceCandidate {} + const derived = new Derived({sdpMid: 'audio'}); + const foreign = new child.RTCIceCandidate({sdpMid: 'foreign'}); + const newTarget = child.Function(''); + newTarget.prototype = 1; + const fallback = Reflect.construct(RTCIceCandidate, [{sdpMid: 'fallback'}], newTarget); + return [ + derived instanceof Derived, derived instanceof RTCIceCandidate, + Object.getPrototypeOf(foreign) === child.RTCIceCandidate.prototype, + Object.getPrototypeOf(fallback) === child.RTCIceCandidate.prototype, + Object.getOwnPropertyDescriptor(RTCIceCandidate.prototype, 'sdpMid').get.call(foreign) === 'foreign', + RTCIceCandidate.prototype.toJSON.call(foreign).sdpMid === 'foreign' + ].every(Boolean); + })() + "#).expect("ICE constructors should retain subclass and relevant NewTarget prototypes"); + assert_eq!(result, "true"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs index 406769769..7497604d6 100644 --- a/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/mod.rs @@ -13,6 +13,7 @@ mod crypto_subtle_x25519; mod details; mod event_handlers; mod events_selection_storage; +mod ice_candidate; mod idle_callbacks; mod idle_detection; mod images; diff --git a/moli-renderer-v8/src/util.rs b/moli-renderer-v8/src/util.rs index 63b7cf7de..64428f391 100644 --- a/moli-renderer-v8/src/util.rs +++ b/moli-renderer-v8/src/util.rs @@ -116,6 +116,58 @@ pub(crate) fn callable_relevant_context<'s>( .get_creation_context(scope) } +pub(crate) fn new_target_realm_constructor_prototype<'s>( + scope: &mut v8::PinScope<'s, '_>, + new_target: v8::Local<'s, v8::Value>, + constructor_name: &str, +) -> Option> { + let context = callable_relevant_context(scope, new_target)?; + let prototype = { + let context_scope = &mut v8::ContextScope::new(scope, context); + let prototype = global_constructor_prototype(context_scope, constructor_name)?; + v8::Global::new(context_scope, prototype) + }; + Some(v8::Local::new(scope, &prototype)) +} + +pub(crate) fn receiver_uses_new_target_realm_object_fallback<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, + new_target: v8::Local<'s, v8::Value>, +) -> bool { + let Some(receiver_prototype) = receiver.get_prototype(scope) else { + return false; + }; + let Some(object_prototype) = + new_target_realm_constructor_prototype(scope, new_target, "Object") + else { + return false; + }; + receiver_prototype.strict_equals(object_prototype.into()) +} + +pub(crate) fn apply_webidl_constructor_prototype_fallback<'s>( + scope: &mut v8::PinScope<'s, '_>, + receiver: v8::Local<'s, v8::Object>, + new_target: v8::Local<'s, v8::Value>, + default_constructor_name: &str, +) { + // V8 has already read `NewTarget.prototype` while preparing the native + // constructor receiver. Do not read it again: a Proxy getter must run only + // once. V8 represents a non-object result with NewTarget's realm-local + // Object prototype, which lets us replace precisely that fallback with + // the WebIDL interface's default prototype from the same realm. + if !receiver_uses_new_target_realm_object_fallback(scope, receiver, new_target) { + return; + } + let Some(prototype) = + new_target_realm_constructor_prototype(scope, new_target, default_constructor_name) + else { + return; + }; + let _ = receiver.set_prototype(scope, prototype.into()); +} + pub(crate) fn define_v8_array_data_properties<'s, I, T>( scope: &mut v8::PinScope<'s, '_>, array: v8::Local<'s, v8::Array>,