feat(gamepad): expose policy-aware empty gamepad enumeration

This commit is contained in:
ldm0
2026-09-09 04:05:39 +08:00
committed by Donough Liu
parent 5a29c6f2d5
commit ff384a8126
17 changed files with 466 additions and 8 deletions
@@ -1,5 +1,6 @@
mod clipboard;
mod collections;
mod gamepad;
mod geolocation;
mod media_capabilities;
mod media_devices;
@@ -0,0 +1,40 @@
use super::super::context_host_ptr_from_global_bridge;
use super::navigator::navigator_receiver_branded;
use crate::{util::throw_type_error, webidl};
pub(super) fn navigator_get_gamepads_callback<'s>(
scope: &mut v8::PinScope<'s, '_>,
args: v8::FunctionCallbackArguments<'s>,
mut rv: v8::ReturnValue<'s, v8::Value>,
) {
if !navigator_receiver_branded(scope, args.this()) {
throw_type_error(scope, "Illegal invocation");
return;
}
// The Gamepad algorithm uses the current global's document, including
// when a method from one Window is borrowed by another Navigator.
let context = scope.get_current_context();
if let Some(host_ptr) = context_host_ptr_from_global_bridge(scope) {
// SAFETY: the bridge owns the host for the lifetime of this callback.
let host = unsafe { &*host_ptr };
if let Some(identity) =
host.window_execution_context_identity_for_v8_context(scope, context)
&& host.window_execution_context_identity_is_current(identity)
&& host
.document_permissions_policy_for_owner(identity.dispatch_scope())
.is_some_and(|policy| !policy.gamepad_enabled())
{
webidl::throw_dom_exception(
scope,
"SecurityError",
"Access to gamepads is disallowed by permissions policy.",
);
return;
}
}
// No gamepad backend is connected. An inactive document also returns an
// empty sequence before checking policy. Each call produces a new Array.
rv.set(v8::Array::new(scope, 0).into());
}
@@ -23,6 +23,7 @@ use super::clipboard::{build_clipboard_object, install_clipboard_template_bindin
use super::collections::{
build_navigator_plugin_collections, install_navigator_collection_template_bindings,
};
use super::gamepad::navigator_get_gamepads_callback;
use super::geolocation::{build_geolocation_object, install_geolocation_template_bindings};
use super::media_capabilities::{
build_media_capabilities_object, install_media_capabilities_template_bindings,
@@ -211,6 +212,9 @@ struct NavigatorRuntimeDataPrototypeDeclaration {
#[derive(Default, WebApiFunctionTemplate)]
#[webapi(name = "Navigator")]
struct NavigatorPrototypeMethodsDeclaration {
#[webapi(method, enumerable, length = 0, callback = navigator_get_gamepads_callback)]
get_gamepads: (),
#[webapi(method, enumerable, length = 0, callback = navigator_java_enabled_callback)]
java_enabled: (),
+1
View File
@@ -687,6 +687,7 @@ pub(crate) struct DocumentPolicyContainer {
pub(crate) credentialless: bool,
pub(crate) credentialless_storage_nonce: Option<moli_storage_key::OpaqueOriginNonce>,
pub(crate) sandbox: DocumentSandboxPolicy,
pub(crate) permissions_policy: crate::permissions_policy::DocumentPermissionsPolicy,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -162,6 +162,11 @@ impl DocumentPolicyContainer {
headers,
final_url,
),
permissions_policy:
crate::permissions_policy::DocumentPermissionsPolicy::from_navigation_response_headers(
headers,
final_url,
),
..Self::default()
}
}
+1
View File
@@ -79,6 +79,7 @@ mod page_task_queue;
mod parser_module_evaluation;
mod parser_module_pending;
mod parser_script;
mod permissions_policy;
mod queue_microtask;
mod range_boundary;
mod referrer_policy;
@@ -36,6 +36,15 @@ impl JsContextHost {
.map(|entry| entry.document_policy_container_snapshot())
})
.unwrap_or_else(|| self.document_policy_container().clone());
if let Some(parent_document) = self
.dom_host()
.node(handle)
.and_then(crate::dom::native::Node::owner_document)
&& let Some(permissions_policy) =
self.document_permissions_policy_for_document_handle(parent_document)
{
policy_container.permissions_policy = permissions_policy;
}
policy_container.document_referrer =
self.document_url_for_child_context(handle).to_string();
policy_container
@@ -224,6 +224,13 @@ impl ChildBrowsingContextEntry {
self.document_policy_container.clone()
}
pub(super) fn set_document_permissions_policy(
&mut self,
policy: crate::permissions_policy::DocumentPermissionsPolicy,
) {
self.document_policy_container.permissions_policy = policy;
}
pub(super) fn owner_credentialless(&self) -> bool {
self.credentialless
}
@@ -329,6 +336,10 @@ impl ChildBrowsingContextEntry {
.content_security_reporting_endpoints = policy_container
.content_security_reporting_endpoints
.clone();
self.document_policy_container.permissions_policy = self
.document_policy_container
.permissions_policy
.intersect(policy_container.permissions_policy);
self.set_document_credentialless_state(credentialless, credentialless_storage_nonce);
self.set_document_sandbox_policy(sandbox);
}
@@ -367,6 +378,10 @@ impl ChildBrowsingContextEntry {
.policy_container
.content_security_reporting_endpoints
.clone();
self.document_policy_container.permissions_policy = self
.document_policy_container
.permissions_policy
.intersect(snapshot.policy_container.permissions_policy);
self.set_document_credentialless_state(credentialless, credentialless_storage_nonce);
self.set_document_sandbox_policy(sandbox);
}
@@ -436,6 +436,8 @@ impl JsContextHost {
if !self.child_browsing_contexts.contains_key(&handle) {
return None;
};
let permissions_policy =
self.child_browsing_context_permissions_policy_for_navigation(handle, &bootstrap);
let Some(navigation) = self.replace_child_navigation_load(handle) else {
tracing::warn!(
?handle,
@@ -444,6 +446,7 @@ impl JsContextHost {
return None;
};
let entry = self.child_browsing_contexts.get_mut(&handle)?;
entry.set_document_permissions_policy(permissions_policy);
entry.set_pending_navigation(bootstrap, reflects_window_state);
self.note_child_frame_load_started_for_parent(handle);
Some(navigation)
@@ -262,6 +262,20 @@ impl JsContextHost {
&& child_browsing_context_bootstrap_uses_initial_empty_load(
&attribute_bootstrap,
);
let pending_attribute_bootstrap_commit =
ChildBrowsingContextEntry::pending_attribute_bootstrap_commit_for_refresh(
existing.as_ref(),
is_new,
attribute_bootstrap_changed,
initial_about_blank_document_is_complete,
);
let pending_attribute_permissions_policy =
pending_attribute_bootstrap_commit.then(|| {
self.child_browsing_context_permissions_policy_for_navigation(
handle,
&attribute_bootstrap,
)
});
if is_new || attribute_bootstrap_changed || frame_identity_changed {
self.note_child_frame_load_started_for_parent(handle);
}
@@ -324,6 +338,20 @@ impl JsContextHost {
content_security_reporting_endpoints: refresh_policy_source
.map(|policy| policy.content_security_reporting_endpoints.clone())
.unwrap_or_default(),
permissions_policy: if is_new {
// The synchronous initial about:blank Document is
// already subject to the iframe's container policy.
// Use its live inherited origin, not a pending target
// navigation's origin, when applying the allowlist.
self.child_browsing_context_permissions_policy_for_navigation(
handle,
&live_bootstrap,
)
} else {
refresh_policy_source
.map(|policy| policy.permissions_policy)
.unwrap_or_default()
},
};
let initial_empty_document_init: Option<ChildInitialEmptyDocumentInit> = is_new
.then(|| {
@@ -342,13 +370,7 @@ impl JsContextHost {
name,
id: id.filter(|value| !value.is_empty()),
attribute_bootstrap,
pending_attribute_bootstrap_commit:
ChildBrowsingContextEntry::pending_attribute_bootstrap_commit_for_refresh(
existing.as_ref(),
is_new,
attribute_bootstrap_changed,
initial_about_blank_document_is_complete,
),
pending_attribute_bootstrap_commit,
pending_live_navigation: existing.as_ref().and_then(|entry| {
entry.pending_live_navigation_for_refresh(attribute_bootstrap_changed)
}),
@@ -365,7 +387,8 @@ impl JsContextHost {
cached_snapshot,
document_policy_container,
completed_document_network: existing.as_ref().and_then(|entry| {
entry.completed_document_network_for_refresh(attribute_bootstrap_changed)
entry
.completed_document_network_for_refresh(attribute_bootstrap_changed)
}),
completed_frame_owner_resource_timing: existing.as_ref().and_then(
|entry| {
@@ -468,6 +491,11 @@ impl JsContextHost {
self.register_or_update_service_worker_child_client(handle);
}
}
if let Some(policy) = pending_attribute_permissions_policy
&& let Some(entry) = self.child_browsing_contexts.get_mut(&handle)
{
entry.set_document_permissions_policy(policy);
}
if attribute_bootstrap_changed {
self.cancel_child_meta_refresh_navigation(handle);
}
@@ -683,6 +683,34 @@ impl JsContextHost {
credentialless.then(|| self.ensure_top_document_credentialless_storage_nonce())
}
pub(in crate::native_bridge::context_host) fn child_browsing_context_permissions_policy_for_navigation(
&self,
handle: DomHandle,
bootstrap: &ChildBrowsingContextBootstrap,
) -> crate::permissions_policy::DocumentPermissionsPolicy {
let parent_document = self
.dom_host()
.node(handle)
.and_then(crate::dom::native::Node::owner_document)
.unwrap_or_else(|| self.document_handle());
let parent_url = self.document_url_for_handle(parent_document);
let parent_policy = self
.document_permissions_policy_for_document_handle(parent_document)
.unwrap_or_else(|| self.document_policy_container().permissions_policy);
let child_url = Self::child_browsing_context_bootstrap_url(bootstrap)
.unwrap_or_else(|| parent_url.clone());
let is_iframe = self.dom_host().is_html_element_named(handle, "iframe");
let allow = is_iframe
.then(|| self.dom_host().get_attribute(handle, "allow"))
.flatten();
parent_policy.delegated_to_child(
&parent_url,
&child_url,
bootstrap.security_origin_inherited(),
allow.as_deref(),
)
}
pub(crate) fn child_browsing_context_is_same_origin_with_top(&self, handle: DomHandle) -> bool {
self.top_window_can_access_child(handle)
}
@@ -124,6 +124,48 @@ impl JsContextHost {
)
}
pub(crate) fn document_permissions_policy_for_owner(
&self,
owner: OwnerDispatchScope,
) -> Option<crate::permissions_policy::DocumentPermissionsPolicy> {
match owner {
OwnerDispatchScope::Top => Some(self.document_policy_container().permissions_policy),
OwnerDispatchScope::Child(handle) => self
.frame_owner_current_child_snapshot(handle)
.map(|snapshot| {
snapshot
.settings
.document_policy_container
.permissions_policy
}),
OwnerDispatchScope::LightweightPopup(popup_id) => self
.lightweight_popup_policy_container(popup_id)
.map(|policy| policy.permissions_policy),
}
}
pub(crate) fn document_permissions_policy_for_document_handle(
&self,
document: DomHandle,
) -> Option<crate::permissions_policy::DocumentPermissionsPolicy> {
if document == self.document_handle() {
return Some(self.document_policy_container().permissions_policy);
}
if let Some(popup_id) = self.lightweight_popup_id_for_document_handle(document) {
return self
.lightweight_popup_policy_container(popup_id)
.map(|policy| policy.permissions_policy);
}
let child_handle = self.child_browsing_context_host_for_document_handle(document)?;
let snapshot = self.frame_owner_current_child_snapshot(child_handle)?;
(snapshot.document_handle == document).then_some(
snapshot
.settings
.document_policy_container
.permissions_policy,
)
}
pub(crate) fn trusted_types_for_script_requirements_for_owner(
&self,
owner: OwnerDispatchScope,
@@ -3896,6 +3896,13 @@ struct HtmlIFrameElementPrototypeDeclaration {
setter = iframe_srcdoc_setter_function
)]
srcdoc: (),
#[webapi(
accessor_property,
getter = dom_string_reflection_getter_function,
setter = dom_string_reflection_setter_function,
data = DomStringReflection::IframeAllow
)]
allow: (),
#[webapi(
accessor_property,
getter = dom_string_reflection_getter_function,
@@ -188,6 +188,7 @@ pub(super) enum DomStringReflection {
HrWidth,
HtmlTimeDateTime,
HtmlVersion,
IframeAllow,
IframeCsp,
IframeFrameBorder,
IframeHeight,
@@ -412,6 +413,10 @@ const DOM_STRING_REFLECTION_DESCRIPTORS: &[(DomStringReflection, DomStringReflec
DomStringReflection::HtmlVersion,
DomStringReflectionDescriptor::new("HTMLHtmlElement", "version", "version"),
),
(
DomStringReflection::IframeAllow,
DomStringReflectionDescriptor::new("HTMLIFrameElement", "allow", "allow"),
),
(
DomStringReflection::IframeCsp,
DomStringReflectionDescriptor::new_html_element(
+200
View File
@@ -0,0 +1,200 @@
use url::Url;
/// Policy-controlled features that currently have observable renderer behavior.
///
/// This intentionally stores the effective policy for one committed Document,
/// rather than the raw header/container allowlists. Extend the record when a
/// newly implemented feature needs enforcement at its API boundary.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct DocumentPermissionsPolicy {
gamepad: bool,
}
impl Default for DocumentPermissionsPolicy {
fn default() -> Self {
Self { gamepad: true }
}
}
impl DocumentPermissionsPolicy {
pub(crate) const fn gamepad_enabled(self) -> bool {
self.gamepad
}
pub(crate) const fn intersect(self, other: Self) -> Self {
Self {
gamepad: self.gamepad && other.gamepad,
}
}
pub(crate) fn from_navigation_response_headers(
headers: &[(String, String)],
document_url: &Url,
) -> Self {
let mut policy = Self::default();
for (_, value) in headers
.iter()
.filter(|(name, _)| name.eq_ignore_ascii_case("permissions-policy"))
{
for directive in value.split(',') {
let Some((feature, allowlist)) = directive.split_once('=') else {
continue;
};
if feature.trim().eq_ignore_ascii_case("gamepad") {
policy.gamepad = response_allowlist_allows_document(allowlist, document_url);
}
}
}
policy
}
pub(crate) fn delegated_to_child(
self,
parent_url: &Url,
child_url: &Url,
child_inherits_origin: bool,
allow_attribute: Option<&str>,
) -> Self {
let same_origin = child_inherits_origin || moli_url::same_origin(parent_url, child_url);
let gamepad = iframe_allow_feature(
allow_attribute,
"gamepad",
parent_url,
child_url,
same_origin,
)
.unwrap_or(true);
Self {
gamepad: self.gamepad && gamepad,
}
}
}
fn response_allowlist_allows_document(value: &str, document_url: &Url) -> bool {
let value = value.trim();
if value == "*" {
return true;
}
let Some(value) = value
.strip_prefix('(')
.and_then(|value| value.strip_suffix(')'))
else {
return false;
};
value.split_ascii_whitespace().any(|token| {
token.eq_ignore_ascii_case("self")
|| token == "*"
|| token_origin_matches(token, document_url)
})
}
fn iframe_allow_feature(
allow_attribute: Option<&str>,
feature: &str,
parent_url: &Url,
child_url: &Url,
same_origin: bool,
) -> Option<bool> {
let allow_attribute = allow_attribute?;
allow_attribute.split(';').find_map(|directive| {
let mut tokens = directive.split_ascii_whitespace();
let name = tokens.next()?;
if !name.eq_ignore_ascii_case(feature) {
return None;
}
let allowlist = tokens.collect::<Vec<_>>();
if allowlist.is_empty() {
return Some(true);
}
if allowlist
.iter()
.any(|token| token.eq_ignore_ascii_case("'none'") || *token == "()")
{
return Some(false);
}
Some(allowlist.into_iter().any(|token| {
token == "*"
|| token.eq_ignore_ascii_case("'src'")
|| (token.eq_ignore_ascii_case("'self'") && same_origin)
|| token_origin_matches(token, child_url)
|| token_origin_matches(token, parent_url) && same_origin
}))
})
}
fn token_origin_matches(token: &str, url: &Url) -> bool {
let token = token.trim_matches(['\'', '"']);
Url::parse(token)
.ok()
.is_some_and(|origin| moli_url::same_origin(&origin, url))
}
#[cfg(test)]
mod tests {
use super::*;
fn url(value: &str) -> Url {
Url::parse(value).unwrap()
}
#[test]
fn permissions_policy_header_takes_precedence_over_legacy_feature_policy() {
let policy = DocumentPermissionsPolicy::from_navigation_response_headers(
&[
("Permissions-Policy".to_owned(), "gamepad=*".to_owned()),
("Feature-Policy".to_owned(), "gamepad 'none'".to_owned()),
],
&url("https://example.test/document"),
);
assert!(policy.gamepad_enabled());
}
#[test]
fn permissions_policy_response_none_disables_recognized_features() {
let policy = DocumentPermissionsPolicy::from_navigation_response_headers(
&[("permissions-policy".to_owned(), "gamepad=()".to_owned())],
&url("https://example.test/document"),
);
assert!(!policy.gamepad_enabled());
}
#[test]
fn iframe_gamepad_policy_uses_default_wildcard_allowlist_and_explicit_delegation() {
let parent = url("https://parent.test/page");
let same_origin = url("https://parent.test/child");
let cross_origin = url("data:text/html,child");
let policy = DocumentPermissionsPolicy::default();
let same = policy.delegated_to_child(&parent, &same_origin, false, None);
assert!(same.gamepad_enabled());
let cross = policy.delegated_to_child(&parent, &cross_origin, false, None);
assert!(cross.gamepad_enabled());
let delegated =
policy.delegated_to_child(&parent, &cross_origin, false, Some("payment; gamepad *"));
assert!(delegated.gamepad_enabled());
}
#[test]
fn iframe_none_and_parent_policy_cannot_be_overridden() {
let parent = url("https://parent.test/page");
let child = url("https://parent.test/child");
let denied = DocumentPermissionsPolicy::default().delegated_to_child(
&parent,
&child,
false,
Some("gamepad 'none'"),
);
assert!(!denied.gamepad_enabled());
let parent_denied = DocumentPermissionsPolicy { gamepad: false };
let delegated = parent_denied.delegated_to_child(
&parent,
&url("https://other.test/child"),
false,
Some("gamepad *"),
);
assert!(!delegated.gamepad_enabled());
}
}
@@ -0,0 +1,68 @@
use super::*;
#[test]
fn navigator_get_gamepads_returns_fresh_empty_sequences_and_checks_its_receiver() {
let mut vm = new_storage_test_vm("http://gamepad.test/");
let result = vm
.eval(
r#"
(() => {
const descriptor = Object.getOwnPropertyDescriptor(Navigator.prototype, 'getGamepads');
const first = navigator.getGamepads();
const second = navigator.getGamepads();
first.push('local mutation');
const rejects = receiver => {
try { navigator.getGamepads.call(receiver); return false; }
catch (error) { return error instanceof TypeError; }
};
return [
descriptor.value.name === 'getGamepads', descriptor.value.length === 0,
descriptor.writable, descriptor.enumerable, descriptor.configurable,
!Object.hasOwn(navigator, 'getGamepads'), Array.isArray(first),
Array.isArray(second), first !== second, second.length === 0,
navigator.getGamepads().length === 0,
[null, undefined, {}, Navigator.prototype, Object.create(navigator)].every(rejects)
].every(Boolean);
})()
"#,
)
.expect("getGamepads surface should evaluate");
assert_eq!(result, "true");
}
#[test]
fn navigator_get_gamepads_checks_the_current_global_policy_and_activity() {
let mut vm = new_parsed_test_vm(
"https://gamepad-policy.test/",
"<!doctype html><body></body>",
);
let result = vm
.eval(
r#"
(() => {
const frame = document.createElement('iframe');
frame.allow = "gamepad 'none'";
document.body.appendChild(frame);
const child = frame.contentWindow;
const childNavigator = child.navigator;
const childMethod = childNavigator.getGamepads;
const denied = callback => {
try { callback(); return false; }
catch (error) { return error.name === 'SecurityError'; }
};
const results = [
navigator.getGamepads().length === 0,
denied(() => childMethod.call(childNavigator)),
navigator.getGamepads.call(childNavigator).length === 0,
denied(() => childMethod.call(navigator))
];
frame.remove();
const inactive = childMethod.call(childNavigator);
results.push(Array.isArray(inactive), inactive.length === 0);
return results.join('|');
})()
"#,
)
.expect("gamepad policy and activity should evaluate");
assert_eq!(result, "true|true|true|true|true|true");
}
@@ -13,6 +13,7 @@ mod crypto_subtle_x25519;
mod details;
mod event_handlers;
mod events_selection_storage;
mod gamepad;
mod ice_candidate;
mod idle_callbacks;
mod idle_detection;