Skip to content

Commit 4e5e5b9

Browse files
evliu-googlelutien
authored andcommitted
Bug 1966186 [wpt PR 52503] - Implement Permission Policy & cross-origin check for on-device Web Speech,
Automatic update from web-platform-tests 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 <[email protected]> Commit-Queue: Evan Liu <[email protected]> Reviewed-by: Ari Chivukula <[email protected]> Reviewed-by: Andrey Kosyakov <[email protected]> Reviewed-by: Daniel Cheng <[email protected]> Cr-Commit-Position: refs/heads/main@{#1459582} -- wpt-commits: 215823256aed1bb2b80402cb08b829e0b81636d0 wpt-pr: 52503 Differential Revision: https://phabricator.services.mozilla.com/D250150
1 parent 23984ef commit 4e5e5b9

File tree

2 files changed

+250
-4
lines changed

2 files changed

+250
-4
lines changed

testing/web-platform/tests/speech-api/SpeechRecognition-availableOnDevice.https.html

Lines changed: 138 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,8 @@
55
<script>
66
promise_test(async (t) => {
77
const lang = "en-US";
8-
window.SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
8+
window.SpeechRecognition = window.SpeechRecognition ||
9+
window.webkitSpeechRecognition;
910

1011
// Test that it returns a promise.
1112
const resultPromise = SpeechRecognition.availableOnDevice(lang);
@@ -24,7 +25,8 @@
2425
assert_true(
2526
result === "unavailable" || result === "downloadable" ||
2627
result === "downloading" || result === "available",
27-
"The resolved value of the availableOnDevice promise should be a valid value."
28+
"The resolved value of the availableOnDevice promise should be a " +
29+
"valid value."
2830
);
2931
}, "SpeechRecognition.availableOnDevice resolves with a string value.");
3032

@@ -44,4 +46,138 @@
4446
frameSpeechRecognition.availableOnDevice("en-US"),
4547
);
4648
}, "SpeechRecognition.availableOnDevice rejects in a detached context.");
49+
50+
promise_test(async (t) => {
51+
const iframe = document.createElement("iframe");
52+
// This policy should make the on-device speech recognition
53+
// feature unavailable.
54+
iframe.setAttribute("allow", "on-device-speech-recognition 'none'");
55+
document.body.appendChild(iframe);
56+
t.add_cleanup(() => iframe.remove());
57+
58+
await new Promise(resolve => {
59+
if (iframe.contentWindow &&
60+
iframe.contentWindow.document.readyState === 'complete') {
61+
resolve();
62+
} else {
63+
iframe.onload = resolve;
64+
}
65+
});
66+
67+
const frameWindow = iframe.contentWindow;
68+
const frameSpeechRecognition = frameWindow.SpeechRecognition ||
69+
frameWindow.webkitSpeechRecognition;
70+
71+
assert_true(!!frameSpeechRecognition,
72+
"SpeechRecognition should exist in iframe.");
73+
assert_true(!!frameSpeechRecognition.availableOnDevice,
74+
"availableOnDevice method should exist on SpeechRecognition in iframe.");
75+
76+
// Call availableOnDevice and expect it to resolve to "unavailable".
77+
const availabilityStatus =
78+
await frameSpeechRecognition.availableOnDevice("en-US");
79+
assert_equals(availabilityStatus, "unavailable",
80+
"availableOnDevice should resolve to 'unavailable' if " +
81+
"'on-device-speech-recognition' Permission Policy is 'none'."
82+
);
83+
}, "SpeechRecognition.availableOnDevice resolves to 'unavailable' if " +
84+
"'on-device-speech-recognition' Permission Policy is 'none'.");
85+
86+
promise_test(async (t) => {
87+
const html = `
88+
<!DOCTYPE html>
89+
<script>
90+
window.addEventListener('message', async (event) => {
91+
// Ensure we only process the message intended to trigger the test.
92+
if (event.data !== "runTestCallAvailableOnDevice") return;
93+
94+
try {
95+
const SpeechRecognition = window.SpeechRecognition ||
96+
window.webkitSpeechRecognition;
97+
if (!SpeechRecognition || !SpeechRecognition.availableOnDevice) {
98+
parent.postMessage({
99+
type: "error", // Use "error" for API not found or other issues.
100+
name: "NotSupportedError",
101+
message: "SpeechRecognition.availableOnDevice API not " +
102+
"available in iframe"
103+
}, "*");
104+
return;
105+
}
106+
107+
// Call availableOnDevice and post its resolution.
108+
const availabilityStatus =
109+
await SpeechRecognition.availableOnDevice("en-US");
110+
parent.postMessage(
111+
{ type: "resolution", result: availabilityStatus },
112+
"*"
113+
); // Post the string status
114+
} catch (err) {
115+
// Catch any unexpected errors during the API call or message post.
116+
parent.postMessage({
117+
type: "error",
118+
name: err.name,
119+
message: err.message
120+
}, "*");
121+
}
122+
});
123+
<\/script>
124+
`;
125+
126+
const blob = new Blob([html], { type: "text/html" });
127+
const blobUrl = URL.createObjectURL(blob);
128+
// Important: Revoke the blob URL after the test to free up resources.
129+
t.add_cleanup(() => URL.revokeObjectURL(blobUrl));
130+
131+
const iframe = document.createElement("iframe");
132+
iframe.src = blobUrl;
133+
// Sandboxing with "allow-scripts" is needed for the script inside
134+
// the iframe to run.
135+
// The cross-origin nature is primarily due to the blob URL's origin being
136+
// treated as distinct from the parent page's origin for security
137+
// purposes.
138+
iframe.setAttribute("sandbox", "allow-scripts");
139+
document.body.appendChild(iframe);
140+
t.add_cleanup(() => iframe.remove());
141+
142+
await new Promise(resolve => iframe.onload = resolve);
143+
144+
const testResult = await new Promise((resolve, reject) => {
145+
const timeoutId = t.step_timeout(() => {
146+
reject(new Error("Test timed out waiting for message from iframe. " +
147+
"Ensure iframe script is correctly posting a message."));
148+
}, 6000); // 6-second timeout
149+
150+
window.addEventListener("message", t.step_func((event) => {
151+
// Basic check to ensure the message is from our iframe.
152+
if (event.source !== iframe.contentWindow) return;
153+
clearTimeout(timeoutId);
154+
resolve(event.data);
155+
}));
156+
157+
// Send a distinct message to the iframe to trigger its test logic.
158+
iframe.contentWindow.postMessage("runTestCallAvailableOnDevice", "*");
159+
});
160+
161+
// Check if the iframe's script reported an error (e.g., API not found).
162+
if (testResult.type === "error") {
163+
const errorMessage =
164+
`Iframe reported an error: ${testResult.name} - ` +
165+
testResult.message;
166+
assert_unreached(errorMessage);
167+
}
168+
169+
assert_equals(
170+
testResult.type,
171+
"resolution",
172+
"The call from the iframe should resolve and post a 'resolution' " +
173+
"message."
174+
);
175+
assert_equals(
176+
testResult.result, // Expecting the string "unavailable".
177+
"unavailable",
178+
"availableOnDevice should resolve to 'unavailable' in a cross-origin " +
179+
"iframe."
180+
);
181+
}, "SpeechRecognition.availableOnDevice should resolve to 'unavailable' " +
182+
"in a cross-origin iframe.");
47183
</script>

testing/web-platform/tests/speech-api/SpeechRecognition-installOnDevice.https.html

Lines changed: 112 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -154,6 +154,116 @@
154154
}
155155
)
156156
);
157-
}, "SpeechRecognition.installOnDevice rejects in a detached context " +
158-
"(with user gesture).");
157+
}, "SpeechRecognition.installOnDevice rejects in a detached context.");
158+
159+
promise_test(async (t) => {
160+
const iframe = document.createElement("iframe");
161+
iframe.setAttribute("allow",
162+
"on-device-speech-recognition 'none'");
163+
document.body.appendChild(iframe);
164+
t.add_cleanup(() => iframe.remove());
165+
166+
await new Promise(resolve => {
167+
if (iframe.contentWindow &&
168+
iframe.contentWindow.document.readyState === 'complete') {
169+
resolve();
170+
} else {
171+
iframe.onload = resolve;
172+
}
173+
});
174+
175+
const frameWindow = iframe.contentWindow;
176+
const frameSpeechRecognition = frameWindow.SpeechRecognition ||
177+
frameWindow.webkitSpeechRecognition;
178+
const frameDOMException = frameWindow.DOMException;
179+
180+
assert_true(!!frameSpeechRecognition,
181+
"SpeechRecognition should exist in iframe.");
182+
assert_true(!!frameSpeechRecognition.installOnDevice,
183+
"installOnDevice method should exist on SpeechRecognition in iframe.");
184+
185+
await promise_rejects_dom(
186+
t,
187+
"NotAllowedError",
188+
frameDOMException,
189+
frameSpeechRecognition.installOnDevice("en-US"),
190+
"installOnDevice should reject with NotAllowedError if " +
191+
"'install-on-device-speech-recognition' Permission Policy is " +
192+
"disabled."
193+
);
194+
}, "SpeechRecognition.installOnDevice rejects if " +
195+
"'install-on-device-speech-recognition' Permission Policy is disabled.");
196+
197+
promise_test(async (t) => {
198+
const html = `
199+
<!DOCTYPE html>
200+
<script>
201+
window.addEventListener('message', async (event) => {
202+
try {
203+
const SpeechRecognition = window.SpeechRecognition ||
204+
window.webkitSpeechRecognition;
205+
if (!SpeechRecognition || !SpeechRecognition.installOnDevice) {
206+
parent.postMessage({
207+
type: "rejection",
208+
name: "NotSupportedError",
209+
message: "API not available"
210+
}, "*");
211+
return;
212+
}
213+
214+
await SpeechRecognition.installOnDevice("en-US");
215+
parent.postMessage({ type: "resolution", result: "success" }, "*");
216+
} catch (err) {
217+
parent.postMessage({
218+
type: "rejection",
219+
name: err.name,
220+
message: err.message
221+
}, "*");
222+
}
223+
});
224+
<\/script>
225+
`;
226+
227+
// Create a cross-origin Blob URL by fetching from remote origin
228+
const blob = new Blob([html], { type: "text/html" });
229+
const blobUrl = URL.createObjectURL(blob);
230+
231+
const iframe = document.createElement("iframe");
232+
iframe.src = blobUrl;
233+
iframe.setAttribute("sandbox", "allow-scripts");
234+
document.body.appendChild(iframe);
235+
t.add_cleanup(() => iframe.remove());
236+
237+
await new Promise(resolve => iframe.onload = resolve);
238+
239+
const testResult = await new Promise((resolve, reject) => {
240+
const timeoutId = t.step_timeout(() => {
241+
reject(new Error("Timed out waiting for message from iframe"));
242+
}, 6000);
243+
244+
window.addEventListener("message", t.step_func((event) => {
245+
if (event.source !== iframe.contentWindow) return;
246+
clearTimeout(timeoutId);
247+
resolve(event.data);
248+
}));
249+
250+
iframe.contentWindow.postMessage("runTest", "*");
251+
});
252+
253+
assert_equals(
254+
testResult.type,
255+
"rejection",
256+
"Should reject due to cross-origin restriction"
257+
);
258+
assert_equals(
259+
testResult.name,
260+
"NotAllowedError",
261+
"Should reject with NotAllowedError"
262+
);
263+
assert_true(
264+
testResult.message.includes("cross-origin iframe") ||
265+
testResult.message.includes("cross-site subframe"),
266+
`Error message should reference cross-origin. Got: "${testResult.message}"`
267+
);
268+
}, "SpeechRecognition.installOnDevice should reject in a cross-origin iframe.");
159269
</script>

0 commit comments

Comments
 (0)