From fc191afd91bbdda50dfea997449279866dcd06cd Mon Sep 17 00:00:00 2001 From: ldm0 Date: Sat, 3 Oct 2026 09:27:10 +0800 Subject: [PATCH] fix(navigator): validate protocol handler registrations --- .../context_bootstrap/navigator_runtime.rs | 1 + .../navigator_runtime/navigator.rs | 38 ++++- .../navigator_runtime/protocol_handlers.rs | 135 ++++++++++++++++++ moli-renderer-v8/src/native_bridge/element.rs | 5 +- .../native_bridge/element/url_attributes.rs | 2 +- .../element/url_attributes/helpers.rs | 2 +- .../src/script_vm/tests/browser_api/mod.rs | 1 + .../tests/browser_api/protocol_handlers.js | 107 ++++++++++++++ .../tests/browser_api/protocol_handlers.rs | 74 ++++++++++ .../tests/browser_api/protocol_worker.js | 23 +++ 10 files changed, 382 insertions(+), 6 deletions(-) create mode 100644 moli-renderer-v8/src/context_bootstrap/navigator_runtime/protocol_handlers.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.js create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.rs create mode 100644 moli-renderer-v8/src/script_vm/tests/browser_api/protocol_worker.js diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs index fc69dc15b4..d04f69bf87 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime.rs @@ -7,6 +7,7 @@ mod media_capabilities; mod media_devices; mod navigator; mod navigator_subobjects; +mod protocol_handlers; mod screen; mod user_activation; mod visual_viewport; 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 1690869cba..d6a34d3491 100644 --- a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/navigator.rs @@ -124,6 +124,12 @@ struct PermissionsPrototypeMethodsDeclaration { struct NavigatorObjectDeclaration<'scope> { #[webapi(slot = NAVIGATOR_RUNTIME_DATA_SLOT)] runtime_data: v8::Local<'scope, v8::Object>, + + #[webapi(slot = super::protocol_handlers::DOCUMENT_SLOT)] + document: Option>, + + #[webapi(slot = super::protocol_handlers::ORIGIN_SLOT)] + origin: String, } #[derive(Default, WebApiFunctionTemplate)] @@ -900,6 +906,7 @@ pub(in crate::context_bootstrap) fn install_navigator_template_bindings<'s>( scope, prototype, ); NavigatorPrototypeMethodsDeclaration::initialize_prototype_template(scope, prototype); + super::protocol_handlers::install(scope, prototype); } "WorkerNavigator" => { WorkerNavigatorRuntimeDataPrototypeDeclaration::initialize_prototype_template( @@ -934,6 +941,8 @@ pub(in crate::context_bootstrap) fn finalize_navigator_realm_bindings<'s>( let secure_context = super::super::runtime_state::window_realm_secure_context_available(scope, window); if !secure_context { + delete_object_property(scope, prototype, "registerProtocolHandler")?; + delete_object_property(scope, prototype, "unregisterProtocolHandler")?; delete_object_property(scope, prototype, "clipboard")?; delete_object_property(scope, prototype, "mediaDevices")?; delete_object_property(scope, prototype, "wakeLock")?; @@ -1312,7 +1321,34 @@ fn bind_window_navigator<'s>( backing: v8::Local<'s, v8::Object>, ) -> Result> { let _ = ensure_intrinsic_interface_prototype(scope, "Navigator")?; - let navigator = NavigatorObjectDeclaration::new(backing) + let (child, popup) = navigator_backing_owner(scope, backing); + let owner = child + .map(OwnerDispatchScope::Child) + .or_else(|| popup.map(OwnerDispatchScope::LightweightPopup)) + .unwrap_or(OwnerDispatchScope::Top); + let (document, origin) = context_host_ptr_from_global_bridge(scope) + .map(|host_ptr| { + let host = unsafe { &mut *host_ptr }; + let handle = match owner { + OwnerDispatchScope::Top => Some(host.document_handle()), + OwnerDispatchScope::Child(handle) => { + host.child_browsing_context_document_handle(handle) + } + OwnerDispatchScope::LightweightPopup(id) => { + host.lightweight_popup_document_handle(id) + } + }; + let origin = host + .window_document_origin(owner) + .unwrap_or_else(|| "null".to_owned()); + let document = handle.and_then(|handle| { + host.native_bridge_mut() + .wrap_handle(scope, host_ptr, handle) + }); + (document, origin) + }) + .unwrap_or_else(|| (None, "null".to_owned())); + let navigator = NavigatorObjectDeclaration::new(backing, document, origin) .bind(scope) .map_err(|error| anyhow!("failed to bind Navigator object: {error}"))?; Ok(navigator) diff --git a/moli-renderer-v8/src/context_bootstrap/navigator_runtime/protocol_handlers.rs b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/protocol_handlers.rs new file mode 100644 index 0000000000..fbefae251a --- /dev/null +++ b/moli-renderer-v8/src/context_bootstrap/navigator_runtime/protocol_handlers.rs @@ -0,0 +1,135 @@ +//! Protocol-handler validation. The browser currently declines registration +//! requests; no external application or handler navigation is installed. + +use crate::{ + native_bridge::{ + element::parse_url_with_document_query_encoding, + node_runtime_and_handle_from_object_or_detached, + }, + util::get_private_value, + web_api_interfaces, webidl, +}; +use moli_webapi_declare::WebApiFunctionTemplate; + +pub(super) const DOCUMENT_SLOT: &str = "__moliNavigatorDocument"; +pub(super) const ORIGIN_SLOT: &str = "__moliNavigatorOrigin"; + +#[derive(WebApiFunctionTemplate)] +#[webapi(interface = web_api_interfaces::Navigator, receiver)] +struct NavigatorProtocolHandlersDeclaration { + #[webapi(method, enumerable, length = 2, callback = protocol_handler_callback)] + register_protocol_handler: (), + #[webapi(method, enumerable, length = 2, callback = protocol_handler_callback)] + unregister_protocol_handler: (), +} + +#[derive(webidl::WebIdlArgs)] +#[webidl(prefix = "Navigator protocol handler")] +struct ProtocolHandlerArgs { + #[webidl(required)] + scheme: String, + #[webidl(required, converter = "usv_string")] + url: String, +} + +pub(super) fn install<'s>( + scope: &mut v8::PinScope<'s, '_, ()>, + prototype: v8::Local<'s, v8::ObjectTemplate>, +) { + NavigatorProtocolHandlersDeclaration::initialize_prototype_template(scope, prototype); +} + +fn allowed_scheme(scheme: &str) -> bool { + matches!( + scheme, + "bitcoin" + | "ftp" + | "ftps" + | "geo" + | "im" + | "irc" + | "ircs" + | "magnet" + | "mailto" + | "matrix" + | "mms" + | "news" + | "nntp" + | "openpgp4fpr" + | "sftp" + | "sip" + | "sms" + | "smsto" + | "ssh" + | "tel" + | "urn" + | "webcal" + | "wtai" + | "xmpp" + ) || scheme.strip_prefix("web+").is_some_and(|suffix| { + !suffix.is_empty() && suffix.bytes().all(|byte| byte.is_ascii_lowercase()) + }) +} + +fn protocol_handler_callback<'s>( + scope: &mut v8::PinScope<'s, '_>, + args: v8::FunctionCallbackArguments<'s>, + mut rv: v8::ReturnValue<'s>, +) { + // The generated binding checks the native Navigator brand before conversion. + let Some(parsed) = webidl::parse_args::(scope, &args) else { + return; + }; + if !allowed_scheme(&parsed.scheme.to_ascii_lowercase()) { + webidl::throw_dom_exception(scope, "SecurityError", "The protocol cannot be registered."); + return; + } + if !parsed.url.contains("%s") { + webidl::throw_dom_exception(scope, "SyntaxError", "The handler URL must contain %s."); + return; + } + let Some(document) = get_private_value(scope, args.this(), DOCUMENT_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + else { + webidl::throw_dom_exception( + scope, + "SecurityError", + "The Navigator has no associated Document.", + ); + return; + }; + let Ok((host_ptr, handle)) = node_runtime_and_handle_from_object_or_detached(scope, document) + else { + webidl::throw_dom_exception( + scope, + "SecurityError", + "The Navigator Document is unavailable.", + ); + return; + }; + // Retain the receiver's Document and origin, including when its browsing + // context navigates. Public baseURI/origin getters and the callee realm do + // not supply the relevant environment settings. + let host = unsafe { &*host_ptr }; + let base = host.document_base_url_for_handle(handle); + let Ok(url) = parse_url_with_document_query_encoding(host, handle, &base, &parsed.url) else { + webidl::throw_dom_exception(scope, "SyntaxError", "The handler URL cannot be parsed."); + return; + }; + let origin = get_private_value(scope, args.this(), ORIGIN_SLOT) + .and_then(|value| v8::Local::::try_from(value).ok()) + .map(|value| value.to_rust_string_lossy(scope)); + if !matches!(url.scheme(), "http" | "https") + || origin.as_deref() != Some(url.origin().ascii_serialization().as_str()) + { + webidl::throw_dom_exception( + scope, + "SecurityError", + "The handler URL must have the Navigator's origin.", + ); + return; + } + // HTML permits the user agent to cancel a registration request. With no + // registration UI, there are no accepted handlers for unregister to remove. + rv.set_undefined(); +} diff --git a/moli-renderer-v8/src/native_bridge/element.rs b/moli-renderer-v8/src/native_bridge/element.rs index f6cd2ba460..1e82e46e94 100644 --- a/moli-renderer-v8/src/native_bridge/element.rs +++ b/moli-renderer-v8/src/native_bridge/element.rs @@ -686,15 +686,14 @@ pub(super) use tree_mutation::{ node_insert_adjacent_element_callback, node_insert_adjacent_html_callback, node_insert_adjacent_node_callback, node_insert_adjacent_text_callback, }; +pub(in crate::native_bridge) use url_attributes::iframe_uses_detached_content_cache; +pub(crate) use url_attributes::parse_url_with_document_query_encoding; use url_attributes::{ disconnected_iframe_can_materialize_detached_content, iframe_has_inactive_child_context, iframe_is_in_own_child_document, iframe_is_inside_its_own_child_context_document, parsed_url_like_attribute, resolve_url_like_attribute, set_resolved_url_attribute, should_block_dangling_markup_subresource, }; -pub(in crate::native_bridge) use url_attributes::{ - iframe_uses_detached_content_cache, parse_url_with_document_query_encoding, -}; pub(super) use url_attributes::{ live_frame_owner_content_window_for_handle, update_iframe_snapshot_navigation, }; diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes.rs index 215ab6ff39..1fb3b623c8 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes.rs @@ -1,7 +1,7 @@ mod helpers; mod iframe; -pub(in crate::native_bridge) use self::helpers::parse_url_with_document_query_encoding; +pub(crate) use self::helpers::parse_url_with_document_query_encoding; pub(super) use self::helpers::{ parsed_url_like_attribute, resolve_url_like_attribute, set_resolved_url_attribute, should_block_dangling_markup_subresource, diff --git a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs index f0361a4d0f..430b1a8385 100644 --- a/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs +++ b/moli-renderer-v8/src/native_bridge/element/url_attributes/helpers.rs @@ -61,7 +61,7 @@ pub(in crate::native_bridge::element) fn should_block_dangling_markup_subresourc .any(|byte| matches!(byte, b'\r' | b'\n' | b'\t')) } -pub(in crate::native_bridge) fn parse_url_with_document_query_encoding( +pub(crate) fn parse_url_with_document_query_encoding( runtime: &JsContextHost, handle: DomHandle, base: &Url, 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 019b9f9cd7..bf9dd8e9fb 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 @@ -67,6 +67,7 @@ mod popup_document_stream; mod popup_hyperlinks; mod popup_live_document_stream; mod promise_rejection; +mod protocol_handlers; mod rtc_error; mod security_policy; mod security_policy_event_init; diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.js b/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.js new file mode 100644 index 0000000000..f92c2ce7d8 --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.js @@ -0,0 +1,107 @@ +(() => { + const rows = [], errors = []; + const record = (label, checks, observed = null) => rows.push({label, checks, observed}); + const names = ['registerProtocolHandler', 'unregisterProtocolHandler']; + try { + const child = document.getElementById('child').contentWindow; + const realms = [window, child]; + const cases = [ + ['relative', ['web+sample', '%s'], null], + ['case-fold', ['WeB+SaMpLe', './path/%s'], null], + ['safe-case-fold', ['MAILTO', '?url=%s'], null], + ['surrogate-url', ['web+sample', 'path\uD800?url=%s'], null], + ['two-placeholders', ['tel', '%s/foo/%s'], null], + ['credentials', ['tel', location.origin.replace('://', '://user:pass@') + '/%s'], null], + ['unknown-scheme', ['http', '%s'], 'SecurityError'], + ['scheme-colon', ['mailto:', '%s'], 'SecurityError'], + ['empty-custom', ['web+', '%s'], 'SecurityError'], + ['custom-digit', ['web+abc1', '%s'], 'SecurityError'], + ['unicode-case', ['web+\u212Aelvin', '%s'], 'SecurityError'], + ['custom-surrogate', ['web+\uD800', '%s'], 'SecurityError'], + ['null-scheme', [null, '%s'], 'SecurityError'], + ['missing-token', ['tel', 'path'], 'SyntaxError'], + ['upper-token', ['tel', '%S'], 'SyntaxError'], + ['null-url', ['tel', null], 'SyntaxError'], + ['undefined-url', ['tel', undefined], 'SyntaxError'], + ['host-token', ['tel', 'https://%s.invalid/'], 'SyntaxError'], + ['port-token', ['tel', 'https://example.test:%s/'], 'SyntaxError'], + ['other-origin', ['tel', 'https://elsewhere.test/%s'], 'SecurityError'], + ['non-http-url', ['tel', 'mailto:%s'], 'SecurityError'], + ['scheme-before-url-syntax', ['http', 'https://test:test/'], 'SecurityError'], + ['scheme-before-url-origin', ['http', 'https://elsewhere.test/%s'], 'SecurityError'], + ['scheme-symbol', [Symbol('scheme'), '%s'], 'TypeError'], + ['url-symbol-before-scheme-validation', ['http', Symbol('url')], 'TypeError'], + ['missing-arguments', [], 'TypeError'], + ['missing-url', ['tel'], 'TypeError'], + ]; + function invoke(fn, receiver, args, realm, expected) { + if (typeof fn !== 'function') return {checks: {present: false, result: false, realm: false}, observed: 'missing'}; + try { + const result = fn.call(receiver, ...args); + return {checks: {present: true, result: expected === null && result === undefined, realm: expected === null}, observed: 'returned'}; + } catch (error) { + return {checks: {present: true, result: error.name === expected, + realm: expected === 'TypeError' ? error instanceof realm.TypeError : error instanceof realm.DOMException}, observed: error.name}; + } + } + for (const [r, realm] of realms.entries()) { + for (const name of names) { + const descriptor = Object.getOwnPropertyDescriptor(realm.Navigator.prototype, name); + record(`${r}/${name}/exposure`, realm.isSecureContext ? { + present: !!descriptor, ownPrototype: typeof descriptor?.value === 'function', + metadata: descriptor?.value.name === name && descriptor?.value.length === 2, + flags: descriptor?.writable === true && descriptor?.enumerable === true && descriptor?.configurable === true, + inherited: !Object.hasOwn(realm.navigator, name), + } : {absentPrototype: !descriptor, absentInstance: !(name in realm.navigator)}); + } + } + if (isSecureContext) { + for (const [f, realm] of realms.entries()) for (const [r, receiverRealm] of realms.entries()) { + for (const name of names) for (const [label, args, expected] of cases) { + const result = invoke(realm.navigator[name], receiverRealm.navigator, args, realm, expected); + record(`${f}/${r}/${name}/${label}`, result.checks, result.observed); + } + } + for (const [f, realm] of realms.entries()) for (const name of names) { + const fn = realm.navigator[name]; + for (const [r, real] of realms.entries()) { + const revoked = Proxy.revocable(real.navigator, {}); revoked.revoke(); + const receivers = [null, undefined, {}, real.Navigator.prototype, Object.create(real.navigator), new Proxy(real.navigator, {}), revoked.proxy]; + for (const [i, receiver] of receivers.entries()) { + let conversions = 0, traps = 0; + const tracked = i === 5 ? new Proxy(real.navigator, {get() {traps++; throw new Error('author trap');}}) : receiver; + const value = {toString() {conversions++; return '%s';}}; + const result = invoke(fn, tracked, [value, value], realm, 'TypeError'); + record(`${f}/${r}/${name}/receiver-${i}`, {...result.checks, noConversions: conversions === 0, noTraps: traps === 0}, result.observed); + } + } + for (const behavior of ['scheme-throws', 'url-throws', 'invalid-scheme-converts-url', 'missing-url-no-conversion', 'extra-argument-ignored']) { + const order = [], sentinel = {}; + const scheme = {toString() {order.push('scheme'); if (behavior === 'scheme-throws') throw sentinel; return behavior === 'invalid-scheme-converts-url' ? 'http' : 'tel';}}; + const url = {toString() {order.push('url'); if (behavior === 'url-throws') throw sentinel; return '%s';}}; + const ignored = {toString() {order.push('extra'); throw sentinel;}}; + const args = behavior === 'missing-url-no-conversion' ? [scheme] : [scheme, url, ignored]; + let caught, returned = false; + if (typeof fn === 'function') {try {returned = fn.call(realm.navigator, ...args) === undefined;} catch (error) {caught = error;}} + const expectedOrder = behavior === 'scheme-throws' ? ['scheme'] : behavior === 'missing-url-no-conversion' ? [] : ['scheme', 'url']; + const result = behavior.endsWith('-throws') ? caught === sentinel : behavior === 'missing-url-no-conversion' ? caught instanceof realm.TypeError : behavior === 'invalid-scheme-converts-url' ? caught?.name === 'SecurityError' && caught instanceof realm.DOMException : returned; + record(`${f}/${name}/${behavior}`, {present: typeof fn === 'function', result, order: JSON.stringify(order) === JSON.stringify(expectedOrder)}, order); + } + } + // A foreign base belongs to the receiver Document, independently of the callee realm. + const base = child.document.createElement('base'); base.href = 'https://elsewhere.test/'; child.document.head.appendChild(base); + const descriptor = Object.getOwnPropertyDescriptor(child.document, 'baseURI'); + Object.defineProperty(child.document, 'baseURI', {configurable: true, get() {throw new Error('public baseURI');}}); + try { + for (const [f, realm] of realms.entries()) for (const [r, receiverRealm] of realms.entries()) for (const name of names) { + const result = invoke(realm.navigator[name], receiverRealm.navigator, ['web+sample', '%s'], realm, r ? 'SecurityError' : null); + record(`${f}/${r}/${name}/receiver-base`, result.checks, result.observed); + } + } finally { + base.remove(); if (descriptor) Object.defineProperty(child.document, 'baseURI', descriptor); else delete child.document.baseURI; + } + } + } catch (error) {errors.push(String(error.stack || error));} + globalThis.__uiEventResults = {rows, errors}; + return errors.length === 0 && rows.every(row => Object.values(row.checks).every(value => value === true)); +})() diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.rs b/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.rs new file mode 100644 index 0000000000..4f65d0143a --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_handlers.rs @@ -0,0 +1,74 @@ +use super::service_worker_drain::drain_service_worker_test_turn; +use super::*; + +#[tokio::test] +async fn protocol_handlers_validate_parameters_and_the_receivers_document() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let mut vm = new_storage_page_task_executor_test_vm_with_loader( + "https://protocol-handlers.test/", + &loader, + ); + vm.eval( + r#" + if (!document.documentElement) document.appendChild(document.createElement('html')); + if (!document.body) document.documentElement.appendChild(document.createElement('body')); + const frame = document.body.appendChild(document.createElement('iframe')); + frame.id = 'child'; frame.srcdoc = ''; void frame.contentWindow; + 'ready' + "#, + ) + .unwrap(); + assert!( + vm.run_one_child_frame_task_executor_turn( + ChildFrameSemanticTurnKind::RealmMaterialization, + &loader + ) + .await + .unwrap() + ); + vm.drain_ready_page_task_executor_turns_for_setup(&loader, 128) + .await + .unwrap(); + assert_eq!(vm.eval(include_str!("protocol_handlers.js")).unwrap(), "true", "{}", + vm.eval("JSON.stringify({errors:__uiEventResults.errors,failures:__uiEventResults.rows.filter(row=>Object.values(row.checks).some(value=>value!==true))})").unwrap()); +} + +#[test] +fn protocol_handlers_are_absent_in_insecure_contexts() { + let mut vm = new_storage_test_vm("http://protocol-handlers.test/"); + assert_eq!( + vm.eval( + r#" + !isSecureContext && + ['registerProtocolHandler', 'unregisterProtocolHandler'].every(name => + !(name in navigator) && !Object.hasOwn(Navigator.prototype, name)) + "# + ) + .unwrap(), + "true" + ); +} + +#[tokio::test] +async fn protocol_handlers_are_absent_from_worker_navigators() { + let loader = ResourceRequestClient::new(&moli_fetch::FetchConfig::default()).unwrap(); + let (mut vm, browser_context_runtime) = + new_service_worker_page_test_vm_with_loader_and_browser_context_runtime( + "https://protocol-worker.test/", + &loader, + ); + let source = include_str!("protocol_worker.js"); + vm.eval(&format!("({source}).then(value => globalThis.__protocolWorker = value, error => globalThis.__protocolWorker = String(error));")).unwrap(); + tokio::time::timeout(std::time::Duration::from_secs(10), async { + while vm + .eval("globalThis.__protocolWorker !== undefined") + .unwrap() + != "true" + { + drain_service_worker_test_turn(&mut vm, &browser_context_runtime, &loader).await; + } + }) + .await + .expect("protocol worker probe should settle"); + assert_eq!(vm.eval("globalThis.__protocolWorker").unwrap(), "true"); +} diff --git a/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_worker.js b/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_worker.js new file mode 100644 index 0000000000..8b893f21cb --- /dev/null +++ b/moli-renderer-v8/src/script_vm/tests/browser_api/protocol_worker.js @@ -0,0 +1,23 @@ +(async () => { + const rows = [], errors = []; + const source = `postMessage({secure: isSecureContext, + register: 'registerProtocolHandler' in navigator, + unregister: 'unregisterProtocolHandler' in navigator, + registerPrototype: Object.hasOwn(WorkerNavigator.prototype, 'registerProtocolHandler'), + unregisterPrototype: Object.hasOwn(WorkerNavigator.prototype, 'unregisterProtocolHandler')});`; + const url = URL.createObjectURL(new Blob([source], {type: 'text/javascript'})); + const worker = new Worker(url); + try { + const facts = await new Promise((resolve, reject) => { + worker.onmessage = event => resolve(event.data); + worker.onerror = event => reject(new Error(event.message)); + }); + rows.push({label: 'worker-navigator', checks: { + absent: !facts.register && !facts.unregister, + absentPrototype: !facts.registerPrototype && !facts.unregisterPrototype, + }, observed: facts}); + } catch (error) {errors.push(String(error.stack || error));} + finally {worker.terminate(); URL.revokeObjectURL(url);} + globalThis.__uiEventResults = {rows, errors}; + return errors.length === 0 && rows.every(row => Object.values(row.checks).every(value => value === true)); +})()