0

Implement Permission Policy & cross-origin check for on-device Web Speech

This CL implements a Permission Policy and cross-origin check for the
availableOnDevice() and installOnDevice() of the Web Speech API. These
are some of the anti-fingerprinting countermeasures described in
go/on-device-web-speech-fingerprinting-mitigations.

Bug: 40286514
Change-Id: I766e35e4a38b7602cf037e17607662791abcad1a
Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/6533837
Reviewed-by: Fred Shih <ffred@chromium.org>
Commit-Queue: Evan Liu <evliu@google.com>
Reviewed-by: Ari Chivukula <arichiv@chromium.org>
Reviewed-by: Andrey Kosyakov <caseq@chromium.org>
Reviewed-by: Daniel Cheng <dcheng@chromium.org>
Cr-Commit-Position: refs/heads/main@{#1459582}
This commit is contained in:
Evan Liu
2025-05-13 11:18:56 -07:00
committed by Chromium LUCI CQ
parent b821964bf4
commit 381704b983
9 changed files with 296 additions and 8 deletions
services/network/public
cpp
mojom
third_party/blink
tools/metrics/histograms/metadata/blink

@ -417,6 +417,11 @@
name: "MidiFeature",
permissions_policy_name: "midi",
},
{
name: "OnDeviceSpeechRecognition",
permissions_policy_name: "on-device-speech-recognition",
depends_on: ["OnDeviceWebSpeechAvailable"],
},
{
name: "OTPCredentials",
permissions_policy_name: "otp-credentials",

@ -331,6 +331,10 @@ enum PermissionsPolicyFeature {
// See https://github.com/WICG/turtledove/blob/main/FLEDGE.md#37-view-and-click-data
kRecordAdAuctionEvents = 137,
// Allows sites to delegate access to install on-device speech via the Web
// Speech API to cross-origin iframes.
kOnDeviceSpeechRecognition = 138,
// Don't change assigned numbers of any item, and don't reuse removed slots.
// Add new features at the end of the enum.
// Also, run update_permissions_policy_enum.py in

@ -8607,6 +8607,7 @@ domain Page
media-playback-while-not-visible
microphone
midi
on-device-speech-recognition
otp-credentials
payment
picture-in-picture

@ -60,6 +60,11 @@
#include "third_party/blink/renderer/platform/runtime_enabled_features.h"
namespace {
const char kExceptionMessageCrossOriginAccess[] =
"Access denied from cross-origin iframes.";
const char kExceptionMessagePermissionPolicy[] =
"Access denied because the Permission Policy is not enabled.";
blink::V8AvailabilityStatus AvailabilityStatusToV8(
media::mojom::blink::AvailabilityStatus status) {
switch (status) {
@ -205,7 +210,6 @@ ScriptPromise<V8AvailabilityStatus> SpeechRecognition::availableOnDevice(
ExceptionState& exception_state) {
LocalDOMWindow& window = *LocalDOMWindow::From(script_state);
auto* controller = SpeechRecognitionController::From(window);
if (!controller || !script_state->ContextIsValid()) {
exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
"Execution context is detached.");
@ -216,6 +220,18 @@ ScriptPromise<V8AvailabilityStatus> SpeechRecognition::availableOnDevice(
MakeGarbageCollected<ScriptPromiseResolver<V8AvailabilityStatus>>(
script_state, exception_state.GetContext());
auto result = resolver->Promise();
bool is_cross_origin_iframe = window.IsCrossSiteSubframeIncludingScheme();
// Return unavailable if the Permission Policy is not enabled, or if the API
// is accessed from a cross-origin iframe.
if (!window.IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::
kOnDeviceSpeechRecognition) ||
is_cross_origin_iframe) {
resolver->Resolve(AvailabilityStatusToV8(
media::mojom::blink::AvailabilityStatus::kUnavailable));
return result;
}
controller->OnDeviceWebSpeechAvailable(
lang, WTF::BindOnce(
[](ScriptPromiseResolver<V8AvailabilityStatus>* resolver,
@ -236,7 +252,6 @@ ScriptPromise<IDLBoolean> SpeechRecognition::installOnDevice(
ExceptionState& exception_state) {
LocalDOMWindow& window = *LocalDOMWindow::From(script_state);
auto* controller = SpeechRecognitionController::From(window);
if (!controller || !script_state->ContextIsValid()) {
exception_state.ThrowDOMException(DOMExceptionCode::kInvalidStateError,
"Execution context is detached.");
@ -245,6 +260,21 @@ ScriptPromise<IDLBoolean> SpeechRecognition::installOnDevice(
auto* resolver = MakeGarbageCollected<ScriptPromiseResolver<IDLBoolean>>(
script_state, exception_state.GetContext());
// Block access for cross-origin iframes.
if (window.IsCrossSiteSubframeIncludingScheme()) {
resolver->Reject(
MakeGarbageCollected<DOMException>(DOMExceptionCode::kNotAllowedError,
kExceptionMessageCrossOriginAccess));
return resolver->Promise();
}
// Block access if the Permission Policy is not enabled.
if (!window.IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::
kOnDeviceSpeechRecognition)) {
resolver->Reject(MakeGarbageCollected<DOMException>(
DOMExceptionCode::kNotAllowedError, kExceptionMessagePermissionPolicy));
return resolver->Promise();
}
auto result = resolver->Promise();
controller->OnDeviceWebSpeechAvailable(
@ -255,8 +285,7 @@ ScriptPromise<IDLBoolean> SpeechRecognition::installOnDevice(
media::mojom::blink::AvailabilityStatus status) {
LocalDOMWindow& window = *LocalDOMWindow::From(script_state);
auto* controller = SpeechRecognitionController::From(window);
if (!ExecutionContext::From(script_state)
->IsServiceWorkerGlobalScope() &&
if (!window.IsServiceWorkerGlobalScope() &&
status ==
media::mojom::blink::AvailabilityStatus::kDownloadable &&
!LocalFrame::ConsumeTransientUserActivation(

@ -5,7 +5,8 @@
<script>
promise_test(async (t) => {
const lang = "en-US";
window.SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
window.SpeechRecognition = window.SpeechRecognition ||
window.webkitSpeechRecognition;
// Test that it returns a promise.
const resultPromise = SpeechRecognition.availableOnDevice(lang);
@ -24,7 +25,8 @@ promise_test(async (t) => {
assert_true(
result === "unavailable" || result === "downloadable" ||
result === "downloading" || result === "available",
"The resolved value of the availableOnDevice promise should be a valid value."
"The resolved value of the availableOnDevice promise should be a " +
"valid value."
);
}, "SpeechRecognition.availableOnDevice resolves with a string value.");
@ -44,4 +46,138 @@ promise_test(async (t) => {
frameSpeechRecognition.availableOnDevice("en-US"),
);
}, "SpeechRecognition.availableOnDevice rejects in a detached context.");
promise_test(async (t) => {
const iframe = document.createElement("iframe");
// This policy should make the on-device speech recognition
// feature unavailable.
iframe.setAttribute("allow", "on-device-speech-recognition 'none'");
document.body.appendChild(iframe);
t.add_cleanup(() => iframe.remove());
await new Promise(resolve => {
if (iframe.contentWindow &&
iframe.contentWindow.document.readyState === 'complete') {
resolve();
} else {
iframe.onload = resolve;
}
});
const frameWindow = iframe.contentWindow;
const frameSpeechRecognition = frameWindow.SpeechRecognition ||
frameWindow.webkitSpeechRecognition;
assert_true(!!frameSpeechRecognition,
"SpeechRecognition should exist in iframe.");
assert_true(!!frameSpeechRecognition.availableOnDevice,
"availableOnDevice method should exist on SpeechRecognition in iframe.");
// Call availableOnDevice and expect it to resolve to "unavailable".
const availabilityStatus =
await frameSpeechRecognition.availableOnDevice("en-US");
assert_equals(availabilityStatus, "unavailable",
"availableOnDevice should resolve to 'unavailable' if " +
"'on-device-speech-recognition' Permission Policy is 'none'."
);
}, "SpeechRecognition.availableOnDevice resolves to 'unavailable' if " +
"'on-device-speech-recognition' Permission Policy is 'none'.");
promise_test(async (t) => {
const html = `
<!DOCTYPE html>
<script>
window.addEventListener('message', async (event) => {
// Ensure we only process the message intended to trigger the test.
if (event.data !== "runTestCallAvailableOnDevice") return;
try {
const SpeechRecognition = window.SpeechRecognition ||
window.webkitSpeechRecognition;
if (!SpeechRecognition || !SpeechRecognition.availableOnDevice) {
parent.postMessage({
type: "error", // Use "error" for API not found or other issues.
name: "NotSupportedError",
message: "SpeechRecognition.availableOnDevice API not " +
"available in iframe"
}, "*");
return;
}
// Call availableOnDevice and post its resolution.
const availabilityStatus =
await SpeechRecognition.availableOnDevice("en-US");
parent.postMessage(
{ type: "resolution", result: availabilityStatus },
"*"
); // Post the string status
} catch (err) {
// Catch any unexpected errors during the API call or message post.
parent.postMessage({
type: "error",
name: err.name,
message: err.message
}, "*");
}
});
<\/script>
`;
const blob = new Blob([html], { type: "text/html" });
const blobUrl = URL.createObjectURL(blob);
// Important: Revoke the blob URL after the test to free up resources.
t.add_cleanup(() => URL.revokeObjectURL(blobUrl));
const iframe = document.createElement("iframe");
iframe.src = blobUrl;
// Sandboxing with "allow-scripts" is needed for the script inside
// the iframe to run.
// The cross-origin nature is primarily due to the blob URL's origin being
// treated as distinct from the parent page's origin for security
// purposes.
iframe.setAttribute("sandbox", "allow-scripts");
document.body.appendChild(iframe);
t.add_cleanup(() => iframe.remove());
await new Promise(resolve => iframe.onload = resolve);
const testResult = await new Promise((resolve, reject) => {
const timeoutId = t.step_timeout(() => {
reject(new Error("Test timed out waiting for message from iframe. " +
"Ensure iframe script is correctly posting a message."));
}, 6000); // 6-second timeout
window.addEventListener("message", t.step_func((event) => {
// Basic check to ensure the message is from our iframe.
if (event.source !== iframe.contentWindow) return;
clearTimeout(timeoutId);
resolve(event.data);
}));
// Send a distinct message to the iframe to trigger its test logic.
iframe.contentWindow.postMessage("runTestCallAvailableOnDevice", "*");
});
// Check if the iframe's script reported an error (e.g., API not found).
if (testResult.type === "error") {
const errorMessage =
`Iframe reported an error: ${testResult.name} - ` +
testResult.message;
assert_unreached(errorMessage);
}
assert_equals(
testResult.type,
"resolution",
"The call from the iframe should resolve and post a 'resolution' " +
"message."
);
assert_equals(
testResult.result, // Expecting the string "unavailable".
"unavailable",
"availableOnDevice should resolve to 'unavailable' in a cross-origin " +
"iframe."
);
}, "SpeechRecognition.availableOnDevice should resolve to 'unavailable' " +
"in a cross-origin iframe.");
</script>

@ -154,6 +154,116 @@ promise_test(async (t) => {
}
)
);
}, "SpeechRecognition.installOnDevice rejects in a detached context " +
"(with user gesture).");
}, "SpeechRecognition.installOnDevice rejects in a detached context.");
promise_test(async (t) => {
const iframe = document.createElement("iframe");
iframe.setAttribute("allow",
"on-device-speech-recognition 'none'");
document.body.appendChild(iframe);
t.add_cleanup(() => iframe.remove());
await new Promise(resolve => {
if (iframe.contentWindow &&
iframe.contentWindow.document.readyState === 'complete') {
resolve();
} else {
iframe.onload = resolve;
}
});
const frameWindow = iframe.contentWindow;
const frameSpeechRecognition = frameWindow.SpeechRecognition ||
frameWindow.webkitSpeechRecognition;
const frameDOMException = frameWindow.DOMException;
assert_true(!!frameSpeechRecognition,
"SpeechRecognition should exist in iframe.");
assert_true(!!frameSpeechRecognition.installOnDevice,
"installOnDevice method should exist on SpeechRecognition in iframe.");
await promise_rejects_dom(
t,
"NotAllowedError",
frameDOMException,
frameSpeechRecognition.installOnDevice("en-US"),
"installOnDevice should reject with NotAllowedError if " +
"'install-on-device-speech-recognition' Permission Policy is " +
"disabled."
);
}, "SpeechRecognition.installOnDevice rejects if " +
"'install-on-device-speech-recognition' Permission Policy is disabled.");
promise_test(async (t) => {
const html = `
<!DOCTYPE html>
<script>
window.addEventListener('message', async (event) => {
try {
const SpeechRecognition = window.SpeechRecognition ||
window.webkitSpeechRecognition;
if (!SpeechRecognition || !SpeechRecognition.installOnDevice) {
parent.postMessage({
type: "rejection",
name: "NotSupportedError",
message: "API not available"
}, "*");
return;
}
await SpeechRecognition.installOnDevice("en-US");
parent.postMessage({ type: "resolution", result: "success" }, "*");
} catch (err) {
parent.postMessage({
type: "rejection",
name: err.name,
message: err.message
}, "*");
}
});
<\/script>
`;
// Create a cross-origin Blob URL by fetching from remote origin
const blob = new Blob([html], { type: "text/html" });
const blobUrl = URL.createObjectURL(blob);
const iframe = document.createElement("iframe");
iframe.src = blobUrl;
iframe.setAttribute("sandbox", "allow-scripts");
document.body.appendChild(iframe);
t.add_cleanup(() => iframe.remove());
await new Promise(resolve => iframe.onload = resolve);
const testResult = await new Promise((resolve, reject) => {
const timeoutId = t.step_timeout(() => {
reject(new Error("Timed out waiting for message from iframe"));
}, 6000);
window.addEventListener("message", t.step_func((event) => {
if (event.source !== iframe.contentWindow) return;
clearTimeout(timeoutId);
resolve(event.data);
}));
iframe.contentWindow.postMessage("runTest", "*");
});
assert_equals(
testResult.type,
"rejection",
"Should reject due to cross-origin restriction"
);
assert_equals(
testResult.name,
"NotAllowedError",
"Should reject with NotAllowedError"
);
assert_true(
testResult.message.includes("cross-origin iframe") ||
testResult.message.includes("cross-site subframe"),
`Error message should reference cross-origin. Got: "${testResult.message}"`
);
}, "SpeechRecognition.installOnDevice should reject in a cross-origin iframe.");
</script>

@ -62,6 +62,7 @@ magnetometer
media-playback-while-not-visible
microphone
midi
on-device-speech-recognition
otp-credentials
payment
picture-in-picture

@ -73,6 +73,7 @@ const non_isolated_policies = [
'media-playback-while-not-visible',
'microphone',
'midi',
'on-device-speech-recognition',
'otp-credentials',
'payment',
'picture-in-picture',

@ -6355,6 +6355,7 @@ Called by update_permissions_policy_enum.py.-->
<int value="135" label="DeviceAttributes"/>
<int value="136" label="LocalNetworkAccess"/>
<int value="137" label="RecordAdAuctionEvents"/>
<int value="138" label="OnDeviceSpeechRecognition"/>
</enum>
<enum name="FedCmAccountChooserResult">