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:

committed by
Chromium LUCI CQ

parent
b821964bf4
commit
381704b983
services/network/public
cpp
permissions_policy
mojom
permissions_policy
third_party/blink
public
devtools_protocol
renderer
modules
speech
web_tests
external
wpt
webexposed
wpt_internal
isolated-permissions-policy
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(
|
||||
|
140
third_party/blink/web_tests/external/wpt/speech-api/SpeechRecognition-availableOnDevice.https.html
vendored
140
third_party/blink/web_tests/external/wpt/speech-api/SpeechRecognition-availableOnDevice.https.html
vendored
@ -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>
|
||||
|
114
third_party/blink/web_tests/external/wpt/speech-api/SpeechRecognition-installOnDevice.https.html
vendored
114
third_party/blink/web_tests/external/wpt/speech-api/SpeechRecognition-installOnDevice.https.html
vendored
@ -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
|
||||
|
1
third_party/blink/web_tests/wpt_internal/isolated-permissions-policy/permissions_policy.https.html
vendored
1
third_party/blink/web_tests/wpt_internal/isolated-permissions-policy/permissions_policy.https.html
vendored
@ -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">
|
||||
|
Reference in New Issue
Block a user