diff --git a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs index b425c75eec..9e0b5f9279 100644 --- a/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs +++ b/moli-renderer-v8/src/context_bootstrap/exposed_interfaces/metadata.rs @@ -81,6 +81,8 @@ pub(in crate::context_bootstrap) const WORKER_SHARED_INTERFACE_NAMES: &[&str] = ]; const SECURE_CONTEXT_ONLY_INTERFACE_NAMES: &[&str] = &[ + "MediaDeviceInfo", + "InputDeviceInfo", "MediaDevices", "SubtleCrypto", "CryptoKey", diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs index ce2b444165..bdab11f975 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices.rs @@ -4,6 +4,8 @@ use crate::util::{get_private_value, set_private_value, throw_type_error}; use crate::web_api_interfaces; use moli_webapi_declare::{WebApiFunctionTemplate, WebApiObject}; +mod info; + const MEDIA_DEVICES_LISTENERS_SLOT: &str = "__moliMediaDevicesListeners"; const MEDIA_DEVICES_ONDEVICECHANGE_SLOT: &str = "__moliMediaDevicesOndevicechange"; @@ -42,9 +44,16 @@ pub(super) fn build_media_devices_object<'s>( pub(super) fn install_media_devices_template_bindings<'s>( scope: &mut v8::PinScope<'s, '_, ()>, template: v8::Local<'s, v8::FunctionTemplate>, + name: &str, ) { - let prototype = template.prototype_template(scope); - MediaDevicesPrototypeDeclaration::initialize_prototype_template(scope, prototype); + if name == "MediaDevices" { + MediaDevicesPrototypeDeclaration::initialize_prototype_template( + scope, + template.prototype_template(scope), + ); + } else { + info::install(scope, template, name); + } } fn receiver_is_media_devices<'s>( diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs new file mode 100644 index 0000000000..9003e1cee2 --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/media_devices/info.rs @@ -0,0 +1,87 @@ +//! Device info interface shims. The current media backend enumerates no devices; +//! exposing these types does not manufacture a camera, microphone or permission. + +use super::*; +use moli_webapi_declare::ObjectLiteralDeclaration; + +const DEVICE_ID: &str = "__moliMediaDeviceId"; +const KIND: &str = "__moliMediaDeviceKind"; +const LABEL: &str = "__moliMediaDeviceLabel"; +const GROUP_ID: &str = "__moliMediaDeviceGroupId"; + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::MediaDeviceInfo, enumerable, receiver)] +struct DeviceInfoAttributes { + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, DEVICE_ID))] + device_id: (), + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, KIND))] + kind: (), + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, LABEL))] + label: (), + #[webapi(accessor_property, getter = info_getter, data = v8str(scope, GROUP_ID))] + group_id: (), + #[webapi(method = "toJSON", length = 0, callback = to_json)] + to_json: (), +} + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::InputDeviceInfo, enumerable, receiver)] +struct InputDeviceInfoMethods { + #[webapi(method, length = 0, callback = get_capabilities)] + get_capabilities: (), +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + template: v8::Local<'s, v8::FunctionTemplate>, + name: &str, +) { + let prototype = template.prototype_template(scope); + match name { + "MediaDeviceInfo" => DeviceInfoAttributes::initialize_prototype_template(scope, prototype), + "InputDeviceInfo" => { + InputDeviceInfoMethods::initialize_prototype_template(scope, prototype) + } + _ => {} + } +} + +fn info_getter<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let slot = args.data().to_rust_string_lossy(scope); + if let Some(value) = get_private_value(scope, args.this(), &slot) { + rv.set(value); + } +} + +fn to_json<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + let object = ObjectLiteralDeclaration::bind(scope); + for (name, slot) in [ + ("deviceId", DEVICE_ID), + ("kind", KIND), + ("label", LABEL), + ("groupId", GROUP_ID), + ] { + let value = get_private_value(scope, args.this(), slot) + .unwrap_or_else(|| v8::undefined(scope).into()); + object.set_string_property(scope, name, value); + } + rv.set(object.into_value()); +} + +fn get_capabilities<'s>( + scope: &mut v8::PinScope<'s, '_>, + _args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s, v8::Value>, +) { + // There is no capture backend or device permission grant yet. An empty + // capabilities dictionary also represents privacy-filtered input devices. + rv.set(ObjectLiteralDeclaration::bind(scope).into_value()); +} diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs index c28a02738d..43348dce65 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -896,7 +896,9 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( install_media_capabilities_template_bindings(scope, template, interface_name); let prototype = template.prototype_template(scope); match interface_name { - "MediaDevices" => install_media_devices_template_bindings(scope, template), + "MediaDevices" | "MediaDeviceInfo" | "InputDeviceInfo" => { + install_media_devices_template_bindings(scope, template, interface_name) + } "Navigator" => { NavigatorRuntimeDataPrototypeDeclaration::initialize_prototype_template( scope, prototype, diff --git a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs index 1822a1e6f3..ddd10dad42 100644 --- a/moli-renderer-v8/src/context_bootstrap/specs/registry.rs +++ b/moli-renderer-v8/src/context_bootstrap/specs/registry.rs @@ -524,6 +524,14 @@ const CONSTRUCTOR_SPECS_BEFORE_STREAMS: &[ConstructorSpec] = &[ interface: web_api_interfaces::FileSystemSyncAccessHandle::DESCRIPTOR, kind: ConstructorKind::Illegal, }, + ConstructorSpec { + interface: web_api_interfaces::MediaDeviceInfo::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, + ConstructorSpec { + interface: web_api_interfaces::InputDeviceInfo::DESCRIPTOR, + kind: ConstructorKind::Illegal, + }, ConstructorSpec { interface: web_api_interfaces::MediaDevices::DESCRIPTOR, kind: ConstructorKind::Illegal, diff --git a/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js new file mode 100644 index 0000000000..aa16370f95 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.js @@ -0,0 +1,54 @@ +(async () => { + const rows = [], assert = (ok, message) => { if (!ok) throw Error(message); }; + const check = async (name, run) => { try { await run(); rows.push({name, pass:true}); } catch(error) {rows.push({name, pass:false, message:String(error)});} }; + const popup = open(), realms = [['main', window], ['child', document.getElementById('child').contentWindow], ['popup', popup]]; + try { + for (const [label, w] of realms) { + if (!w.isSecureContext) { + await check(label + '/insecure', () => assert(!('MediaDeviceInfo' in w) && !('InputDeviceInfo' in w), 'secure globals hidden')); + continue; + } + for (const name of ['MediaDeviceInfo', 'InputDeviceInfo']) { + await check(label + '/' + name, () => { + const C=w[name], parent=name==='InputDeviceInfo'?w.MediaDeviceInfo:w.Object; + const d=Object.getOwnPropertyDescriptor(w,name); + assert(typeof C==='function' && C.name===name && C.length===0,'constructor metadata'); + assert(d.writable && d.configurable && !d.enumerable,'global descriptor'); + assert(Object.getPrototypeOf(C.prototype)===parent.prototype && Object.getPrototypeOf(C)===(parent===w.Object?w.Function.prototype:parent),'native inheritance'); + assert(C.prototype.constructor===C,'prototype constructor'); + const tag=Object.getOwnPropertyDescriptor(C.prototype,Symbol.toStringTag); + assert(tag.value===name && tag.configurable && !tag.writable && !tag.enumerable,'prototype tag'); + for(const call of [()=>C(),()=>new C()]){let error;try{call();}catch(e){error=e;}assert(error instanceof w.TypeError,'illegal constructor realm');} + }); + } + const entries=[['MediaDeviceInfo','deviceId',true],['MediaDeviceInfo','kind',true],['MediaDeviceInfo','label',true],['MediaDeviceInfo','groupId',true],['MediaDeviceInfo','toJSON',false],['InputDeviceInfo','getCapabilities',false]]; + for(const [owner,name,attribute] of entries) { + await check(label+'/'+name,()=>{ + const C=w[owner],d=Object.getOwnPropertyDescriptor(C.prototype,name),fn=attribute?d.get:d.value; + assert(typeof fn==='function' && fn.length===0 && fn.name===(attribute?'get ':'')+name,'member metadata'); + assert(d.enumerable && d.configurable && (attribute?d.set===undefined:d.writable),'member descriptor'); + let traps=0, conversions=0;const trap=()=>{traps++;throw Error('author trap');}; + const revoked=Proxy.revocable({},{});revoked.revoke(); + const ignored={toString(){conversions++;throw Error('ignored argument');}}; + for(const receiver of [null,{},C.prototype,Object.create(C.prototype),new Proxy({}, {get:trap,getPrototypeOf:trap}),revoked.proxy]){ + let error;try{fn.call(receiver,ignored);}catch(e){error=e;} + assert(error instanceof w.TypeError,'callee TypeError for unbranded receiver'); + } + assert(traps===0 && conversions===0,'native brand validation ignores author hooks'); + }); + } + await check(label+'/enumeration',async()=>{ + const first=await w.navigator.mediaDevices.enumerateDevices(),second=await w.navigator.mediaDevices.enumerateDevices(); + assert(first instanceof w.Array && first!==second,'fresh native device list'); + for(const device of first){ + const parent=device.kind==='audiooutput'?w.MediaDeviceInfo:w.InputDeviceInfo; + assert(device instanceof parent,'returned device brand'); + const json=device.toJSON(); + assert(Object.keys(json).join()==='deviceId,kind,label,groupId','default JSON fields'); + for(const key of Object.keys(json))assert(json[key]===device[key],'JSON device values'); + } + }); + } + } finally {popup.close();} + globalThis.__nodeReplacementResults={rows,failures:rows.filter(r=>!r.pass),passed:rows.filter(r=>r.pass).length,total:rows.length};return rows.every(r=>r.pass); +})() diff --git a/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs new file mode 100644 index 0000000000..921c394f25 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/media_device_interfaces.rs @@ -0,0 +1,89 @@ +use super::*; + +#[test] +fn media_device_interfaces_preserve_inheritance_brands_and_secure_exposure() { + for url in [ + "https://media-device-interfaces.test/", + "http://media-device-interfaces.test/", + ] { + let mut vm = new_storage_page_task_executor_test_vm(url); + vm.eval("document.body.innerHTML = ''") + .unwrap(); + vm.eval(&format!("({}).then(value => globalThis.__mediaDeviceDone = value, error => globalThis.__mediaDeviceDone = String(error));", include_str!("media_device_interfaces.js"))).unwrap(); + assert_eq!( + vm.eval_after_selected_page_tasks("JSON.stringify(__nodeReplacementResults.failures)") + .unwrap(), + "[]", + "{url}" + ); + assert_eq!(vm.eval("__mediaDeviceDone").unwrap(), "true", "{url}"); + } +} + +#[test] +fn media_device_serialization_reads_native_fields_and_preserves_utf16() { + let mut vm = new_storage_page_task_executor_test_vm("https://media-device-values.test/"); + let context_ptr: *const v8::Global = &vm.page_default_context as *const _; + vm.renderer_document_isolate + .with_entered_renderer_document_isolate(move |isolate| { + let scope = std::pin::pin!(v8::HandleScope::new(isolate)); + let scope = &mut scope.init(); + let context = unsafe { v8::Local::new(scope, &*context_ptr) }; + let scope = &mut v8::ContextScope::new(scope, context); + let prototype = crate::context_bootstrap::ensure_intrinsic_interface_prototype( + scope, + "InputDeviceInfo", + )?; + // A native test fixture, not a fake device published by enumerateDevices. + let object = v8::Object::new(scope); + assert_eq!(object.set_prototype(scope, prototype.into()), Some(true)); + moli_webapi_declare::initialize_web_api_object(scope, object, "InputDeviceInfo") + .unwrap(); + for (slot, value) in [ + ("__moliMediaDeviceId", "native-id"), + ("__moliMediaDeviceKind", "audioinput"), + ("__moliMediaDeviceGroupId", "native-group"), + ] { + let value = crate::util::v8str(scope, value); + crate::util::set_private_value(scope, object, slot, value.into()); + } + let label = + v8::String::new_from_two_byte(scope, &[0xd800], v8::NewStringType::Normal).unwrap(); + crate::util::set_private_value(scope, object, "__moliMediaDeviceLabel", label.into()); + assert_eq!( + context.global(scope).create_data_property( + scope, + crate::util::v8str(scope, "nativeDevice").into(), + object.into() + ), + Some(true) + ); + Ok(()) + }) + .unwrap(); + assert_eq!(vm.eval(r#"(() => { + const assert = (ok, message) => {if (!ok) throw Error(message);}; + const device = nativeDevice, prototype = MediaDeviceInfo.prototype; + assert(device instanceof InputDeviceInfo && device instanceof MediaDeviceInfo, 'native inherited brand'); + assert(device.deviceId === 'native-id' && device.kind === 'audioinput' && device.groupId === 'native-group' && device.label.charCodeAt(0) === 0xd800, 'native values'); + const expected = device.toJSON(); + let reads = 0, writes = 0; + for (const name of ['deviceId','kind','label','groupId']) { + Object.defineProperty(device,name,{configurable:true,get(){reads++;throw Error('author getter');}}); + Object.defineProperty(Object.prototype,name,{configurable:true,set(){writes++;throw Error('inherited setter');}}); + } + let json; + try {json = prototype.toJSON.call(device);} finally {for (const name of ['deviceId','kind','label','groupId']) delete Object.prototype[name];} + assert(reads === 0 && writes === 0 && JSON.stringify(json) === JSON.stringify(expected), 'private native serialization and data properties'); + assert(json !== expected && Object.getPrototypeOf(json) === Object.prototype, 'fresh ordinary JSON object'); + const first = device.getCapabilities(), second = device.getCapabilities(); + assert(first !== second && Object.keys(first).length === 0, 'fresh empty capabilities shim'); + Object.setPrototypeOf(device,null); + assert(prototype.toJSON.call(device).deviceId === 'native-id', 'brand independent of public prototype'); + for (const receiver of [Object.create(device), new Proxy(device,{})]) { + let error;try {prototype.toJSON.call(receiver);} catch(e){error=e;} + assert(error instanceof TypeError, 'author objects do not acquire native brand'); + } + return true; + })()"#).unwrap(), "true"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/mod.rs b/moli-renderer-v8/src/script_vm/tests/mod.rs index 2ade78dee5..9e9f31148f 100644 --- a/moli-renderer-v8/src/script_vm/tests/mod.rs +++ b/moli-renderer-v8/src/script_vm/tests/mod.rs @@ -2164,3 +2164,5 @@ mod navigation_timing_inheritance; mod response_blob_mime; mod intersection_target_order; + +mod media_device_interfaces; diff --git a/moli-renderer-v8/src/web_api_interfaces.rs b/moli-renderer-v8/src/web_api_interfaces.rs index e8181b3372..6b2aab0717 100644 --- a/moli-renderer-v8/src/web_api_interfaces.rs +++ b/moli-renderer-v8/src/web_api_interfaces.rs @@ -282,6 +282,8 @@ interfaces! { Location; MathMLElement: Element; MediaCapabilities; + MediaDeviceInfo; + InputDeviceInfo: MediaDeviceInfo; MediaDevices: EventTarget; MediaError; MediaList; diff --git a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs index bdb53b404a..a1f3879499 100644 --- a/moli-renderer-v8/src/worker/thread/tests/postmessage.rs +++ b/moli-renderer-v8/src/worker/thread/tests/postmessage.rs @@ -2959,3 +2959,17 @@ async fn worker_fallback_message_events_are_native_platform_objects() { r#"{"data":"ping","cloneResult":"DataCloneError"}"# ); } + +#[tokio::test] +async fn worker_does_not_expose_window_media_device_interfaces() { + ensure_v8(); + let mut handle = spawn_worker("postMessage({MediaDeviceInfo:'MediaDeviceInfo' in self,InputDeviceInfo:'InputDeviceInfo' in self});close();".to_owned(), "https://media-device-worker.test/worker.js".into()); + let message = timeout(TIMEOUT, handle.recv()) + .await + .expect("timed out") + .expect("channel closed"); + assert_eq!( + expect_post_json(message), + r#"{"MediaDeviceInfo":false,"InputDeviceInfo":false}"# + ); +}