fix(navigator): validate protocol handler registrations

This commit is contained in:
ldm0
2026-10-11 18:06:46 +08:00
parent 789b82102b
commit fc191afd91
10 changed files with 382 additions and 6 deletions
@@ -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;
@@ -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<v8::Local<'scope, v8::Object>>,
#[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<v8::Local<'s, v8::Object>> {
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)
@@ -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::<ProtocolHandlerArgs>(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::<v8::Object>::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::<v8::String>::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();
}
@@ -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,
};
@@ -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,
@@ -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,
@@ -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;
@@ -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));
})()
@@ -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 = '<head></head><body></body>'; 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");
}
@@ -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));
})()