CVE-2026-14105
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
SpeechRecognitionCrossOriginBrowserTestcontent/browser/speech/speech_recognition_browsertest.cc |
modified | |
ifcontent/browser/speech/speech_recognition_browsertest.cc |
modified |
Files Changed
content/browser/speech/speech_recognition_browsertest.cccontent/browser/speech/speech_recognition_dispatcher_host.cc
Patch
From 5d69e4c79d3b6053b82f536ad89b92d679faca25 Mon Sep 17 00:00:00 2001 From: Evan Liu <[email protected]> Date: Wed, 20 May 2026 17:19:05 -0700 Subject: [PATCH] [Speech] Enforce Permissions-Policy for on-device speech recognition Fixes a bypass where cross-origin iframes could use on-device speech recognition (e.g., via MediaStreamTrack) despite the policy defaulting to EnableForSelf. Adds PermissionsPolicyFeature::kOnDeviceSpeechRecognition checks to: - Renderer: Blocks or falls back to cloud routing before making IPC. - Browser: Adds defense-in-depth gate to StartRequestOnUI to reject unauthorized requests. Includes a hermetic browser test. Fixed: 513528117 Change-Id: I70085f23c7fee171f4b2b0873872215b6ba0439e Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7857354 Commit-Queue: Evan Liu <[email protected]> Reviewed-by: Andy Paicu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1633908} --- diff --git a/content/browser/speech/speech_recognition_browsertest.cc b/content/browser/speech/speech_recognition_browsertest.cc index 8a1d88f1..f2e97ce 100644 --- a/content/browser/speech/speech_recognition_browsertest.cc +++ b/content/browser/speech/speech_recognition_browsertest.cc @@ -19,6 +19,7 @@ #include "base/run_loop.h" #include "base/strings/string_util.h" #include "base/strings/string_view_util.h" +#include "base/strings/stringprintf.h" #include "base/strings/utf_string_conversions.h" #include "base/task/single_thread_task_runner.h" #include "build/build_config.h" @@ -612,6 +613,156 @@ EXPECT_THAT(shell->web_contents()->GetLastCommittedURL().GetRef(), testing::HasSubstr("error_service-not-allowed")); } + +class SpeechRecognitionCrossOriginBrowserTest + : public SpeechRecognitionBrowserTest { + public: + void SetUpCommandLine(base::CommandLine* command_line) override { + SpeechRecognitionBrowserTest::SetUpCommandLine(command_line); + command_line->AppendSwitch("use-fake-device-for-media-stream"); + command_line->AppendSwitch("use-fake-ui-for-media-stream"); + command_line->AppendSwitchASCII("autoplay-policy", + "no-user-gesture-required"); + command_line->AppendSwitchASCII("enable-blink-features", + "MediaStreamTrackWebSpeech"); + } +}; + +IN_PROC_BROWSER_TEST_F(SpeechRecognitionCrossOriginBrowserTest, + OnDeviceWebSpeechCrossOriginIframeBypass) { + if (!speech::IsOnDeviceSpeechRecognitionSupported()) { + return; + } + mock_soda_installer_.NotifySodaInstalledForTesting(); + + ASSERT_TRUE(embedded_test_server()->Start()); + + std::string web_service_base_url = + embedded_test_server()->base_url().spec() + "foo"; + NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests( + web_service_base_url.c_str()); + + GURL main_url = embedded_test_server()->GetURL("127.0.0.1", "/empty.html"); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + + GURL iframe_url = embedded_test_server()->GetURL("localhost", "/empty.html"); + std::string js_add_iframe = + "var iframe = document.createElement('iframe');" + "iframe.id = 'myiframe';" + "iframe.allow = 'microphone';" + "document.body.appendChild(iframe);"; + EXPECT_TRUE( + ExecJs(shell()->web_contents()->GetPrimaryMainFrame(), js_add_iframe)); + EXPECT_TRUE( + NavigateIframeToURL(shell()->web_contents(), "myiframe", iframe_url)); + + RenderFrameHost* iframe_rfh = + ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0); + ASSERT_TRUE(iframe_rfh); + EXPECT_EQ(iframe_url, iframe_rfh->GetLastCommittedURL()); + + const char js_to_execute[] = R"( + new Promise(async resolve => { + try { + let stream = await navigator.mediaDevices.getUserMedia({audio: true}); + let track = stream.getAudioTracks()[0]; + if (!track) { resolve('no-track'); return; } + + let recognition = new webkitSpeechRecognition(); + recognition.onerror = function(event) { + resolve('error_' + event.error); + }; + recognition.onstart = function() { + // do not resolve + }; + recognition.onend = function() { + resolve('ended'); + }; + + setTimeout(() => resolve('timeout_in_js'), 5000); + + recognition.start(track); + } catch (e) { + resolve('exception_' + e.name); + } + }) + )"; + + EXPECT_EQ("error_network", EvalJs(iframe_rfh, js_to_execute)); + + // Remove reference to URL string that's on the stack. + NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests( + nullptr); +} + +IN_PROC_BROWSER_TEST_F(SpeechRecognitionCrossOriginBrowserTest, + OnDeviceWebSpeechCrossOriginIframeBypassProcessLocally) { + if (!speech::IsOnDeviceSpeechRecognitionSupported()) { + return; + } + mock_soda_installer_.NotifySodaInstalledForTesting(); + + ASSERT_TRUE(embedded_test_server()->Start()); + + std::string web_service_base_url = + embedded_test_server()->base_url().spec() + "foo"; + NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests( + web_service_base_url.c_str()); + + GURL main_url = embedded_test_server()->GetURL("127.0.0.1", "/empty.html"); + EXPECT_TRUE(NavigateToURL(shell(), main_url)); + + GURL iframe_url = embedded_test_server()->GetURL("localhost", "/empty.html"); + std::string js_add_iframe = + "var iframe = document.createElement('iframe');" + "iframe.id = 'myiframe';" + "iframe.allow = 'microphone';" + "document.body.appendChild(iframe);"; + EXPECT_TRUE( + ExecJs(shell()->web_contents()->GetPrimaryMainFrame(), js_add_iframe)); + EXPECT_TRUE( + NavigateIframeToURL(shell()->web_contents(), "myiframe", iframe_url)); + + RenderFrameHost* iframe_rfh = + ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0); + ASSERT_TRUE(iframe_rfh); + EXPECT_EQ(iframe_url, iframe_rfh->GetLastCommittedURL()); + + const char js_to_execute[] = R"( + new Promise(async resolve => { + try { + let stream = await navigator.mediaDevices.getUserMedia({audio: true}); + let track = stream.getAudioTracks()[0]; + if (!track) { resolve('no-track'); return; } + + let recognition = new webkitSpeechRecognition(); + recognition.processLocally = true; + recognition.onerror = function(event) { + resolve('error_' + event.error); + }; + recognition.onstart = function() { + // do not resolve + }; + recognition.onend = function() { + resolve('ended'); + }; + + setTimeout(() => resolve('timeout_in_js'), 5000); + + recognition.start(track); + } catch (e) { + resolve('exception_' + e.name); + } + }) + )"; + + EXPECT_EQ("exception_NotAllowedError", EvalJs(iframe_rfh, js_to_execute)); + + // Remove reference to URL string that's on the stack. + NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests( + nullptr); +} + #endif // !BUILDFLAG(IS_FUCHSIA) } // namespace content diff --git a/content/browser/speech/speech_recognition_dispatcher_host.cc b/content/browser/speech/speech_recognition_dispatcher_host.cc index 3cc9651..cbbf6bbe 100644 --- a/content/browser/speech/speech_recognition_dispatcher_host.cc +++ b/content/browser/speech/speech_recognition_dispatcher_host.cc @@ -171,10 +171,14 @@ StoragePartition* storage_partition = browser_context->GetStoragePartition(web_contents->GetSiteInstance());
Regression Test / PoC
diff --git a/content/browser/speech/speech_recognition_browsertest.cc b/content/browser/speech/speech_recognition_browsertest.cc
index 8a1d88f1..f2e97ce 100644
--- a/content/browser/speech/speech_recognition_browsertest.cc
+++ b/content/browser/speech/speech_recognition_browsertest.cc
@@ -19,6 +19,7 @@
#include "base/run_loop.h"
#include "base/strings/string_util.h"
#include "base/strings/string_view_util.h"
+#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/single_thread_task_runner.h"
#include "build/build_config.h"
@@ -612,6 +613,156 @@
EXPECT_THAT(shell->web_contents()->GetLastCommittedURL().GetRef(),
testing::HasSubstr("error_service-not-allowed"));
}
+
+class SpeechRecognitionCrossOriginBrowserTest
+ : public SpeechRecognitionBrowserTest {
+ public:
+ void SetUpCommandLine(base::CommandLine* command_line) override {
+ SpeechRecognitionBrowserTest::SetUpCommandLine(command_line);
+ command_line->AppendSwitch("use-fake-device-for-media-stream");
+ command_line->AppendSwitch("use-fake-ui-for-media-stream");
+ command_line->AppendSwitchASCII("autoplay-policy",
+ "no-user-gesture-required");
+ command_line->AppendSwitchASCII("enable-blink-features",
+ "MediaStreamTrackWebSpeech");
+ }
+};
+
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionCrossOriginBrowserTest,
+ OnDeviceWebSpeechCrossOriginIframeBypass) {
+ if (!speech::IsOnDeviceSpeechRecognitionSupported()) {
+ return;
+ }
+ mock_soda_installer_.NotifySodaInstalledForTesting();
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ std::string web_service_base_url =
+ embedded_test_server()->base_url().spec() + "foo";
+ NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests(
+ web_service_base_url.c_str());
+
+ GURL main_url = embedded_test_server()->GetURL("127.0.0.1", "/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ GURL iframe_url = embedded_test_server()->GetURL("localhost", "/empty.html");
+ std::string js_add_iframe =
+ "var iframe = document.createElement('iframe');"
+ "iframe.id = 'myiframe';"
+ "iframe.allow = 'microphone';"
+ "document.body.appendChild(iframe);";
+ EXPECT_TRUE(
+ ExecJs(shell()->web_contents()->GetPrimaryMainFrame(), js_add_iframe));
+ EXPECT_TRUE(
+ NavigateIframeToURL(shell()->web_contents(), "myiframe", iframe_url));
+
+ RenderFrameHost* iframe_rfh =
+ ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0);
+ ASSERT_TRUE(iframe_rfh);
+ EXPECT_EQ(iframe_url, iframe_rfh->GetLastCommittedURL());
+
+ const char js_to_execute[] = R"(
+ new Promise(async resolve => {
+ try {
+ let stream = await navigator.mediaDevices.getUserMedia({audio: true});
+ let track = stream.getAudioTracks()[0];
+ if (!track) { resolve('no-track'); return; }
+
+ let recognition = new webkitSpeechRecognition();
+ recognition.onerror = function(event) {
+ resolve('error_' + event.error);
+ };
+ recognition.onstart = function() {
+ // do not resolve
+ };
+ recognition.onend = function() {
+ resolve('ended');
+ };
+
+ setTimeout(() => resolve('timeout_in_js'), 5000);
+
+ recognition.start(track);
+ } catch (e) {
+ resolve('exception_' + e.name);
+ }
+ })
+ )";
+
+ EXPECT_EQ("error_network", EvalJs(iframe_rfh, js_to_execute));
+
+ // Remove reference to URL string that's on the stack.
+ NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests(
+ nullptr);
+}
+
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionCrossOriginBrowserTest,
+ OnDeviceWebSpeechCrossOriginIframeBypassProcessLocally) {
+ if (!speech::IsOnDeviceSpeechRecognitionSupported()) {
+ return;
+ }
+ mock_soda_installer_.NotifySodaInstalledForTesting();
+
+ ASSERT_TRUE(embedded_test_server()->Start());
+
+ std::string web_service_base_url =
+ embedded_test_server()->base_url().spec() + "foo";
+ NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests(
+ web_service_base_url.c_str());
+
+ GURL main_url = embedded_test_server()->GetURL("127.0.0.1", "/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), main_url));
+
+ GURL iframe_url = embedded_test_server()->GetURL("localhost", "/empty.html");
+ std::string js_add_iframe =
+ "var iframe = document.createElement('iframe');"
+ "iframe.id = 'myiframe';"
+ "iframe.allow = 'microphone';"
+ "document.body.appendChild(iframe);";
+ EXPECT_TRUE(
+ ExecJs(shell()->web_contents()->GetPrimaryMainFrame(), js_add_iframe));
+ EXPECT_TRUE(
+ NavigateIframeToURL(shell()->web_contents(), "myiframe", iframe_url));
+
+ RenderFrameHost* iframe_rfh =
+ ChildFrameAt(shell()->web_contents()->GetPrimaryMainFrame(), 0);
+ ASSERT_TRUE(iframe_rfh);
+ EXPECT_EQ(iframe_url, iframe_rfh->GetLastCommittedURL());
+
+ const char js_to_execute[] = R"(
+ new Promise(async resolve => {
+ try {
+ let stream = await navigator.mediaDevices.getUserMedia({audio: true});
+ let track = stream.getAudioTracks()[0];
+ if (!track) { resolve('no-track'); return; }
+
+ let recognition = new webkitSpeechRecognition();
+ recognition.processLocally = true;
+ recognition.onerror = function(event) {
+ resolve('error_' + event.error);
+ };
+ recognition.onstart = function() {
+ // do not resolve
+ };
+ recognition.onend = function() {
+ resolve('ended');
+ };
+
+ setTimeout(() => resolve('timeout_in_js'), 5000);
+
+ recognition.start(track);
+ } catch (e) {
+ resolve('exception_' + e.name);
+ }
+ })
+ )";
+
+ EXPECT_EQ("exception_NotAllowedError", EvalJs(iframe_rfh, js_to_execute));
+
+ // Remove reference to URL string that's on the stack.
+ NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests(
+ nullptr);
+}
+
#endif // !BUILDFLAG(IS_FUCHSIA)
} // namespace content
Original Bug Report
Permissions-Policy bypass for on-device speech recognition in SpeechRecognition.start()
Project Fortify, an experimental security project, has identified the following potential security issue. If you’re a feature owner CC-ed on this bug, please do your best to review these reports. Please see https://chromium.googlesource.com/chromium/src/+/main/docs/security/ai-generated-security-bugs-faq.md for more information.
Overview: The SpeechRecognition.start() method fails to enforce the on-device-speech-recognition Permissions-Policy and cross-origin iframe restrictions. This allows unauthorized cross-origin iframes to utilize on-device speech recognition engines, bypassing the gate intended for this capability.
Affected files:
third_party/blink/renderer/modules/speech/speech_recognition.cccontent/browser/speech/speech_recognition_dispatcher_host.cccontent/browser/speech/speech_recognition_manager_impl.ccchrome/browser/speech/on_device_speech_recognition_impl.ccchrome/browser/speech/on_device_speech_recognition_util.cc
Estimated timestamp from git blame: 2025-05-13
Root Cause Analysis
A potential policy enforcement gap has been identified in the Web Speech API implementation for on-device recognition. While the SpeechRecognition::available() and SpeechRecognition::install() methods correctly verify the on-device-speech-recognition Permissions-Policy, the start() method (especially when used with a MediaStreamTrack) fails to perform these checks.
In the renderer process (third_party/blink/renderer/modules/speech/speech_recognition.cc), the start() path invokes CheckAvailabilityAndStart(), which lacks the IsFeatureEnabled() checks present in other methods. Furthermore, StartController() hardcodes on_device=true when requesting recognition, effectively forcing an on-device path if supported by the system, regardless of the frame’s policy state.
The browser process similarly lacks compensating enforcement. In content/browser/speech/speech_recognition_dispatcher_host.cc, the StartRequestOnUI method determines if a frame can use on-device recognition based on storage partitioning and URL schemes, but it does not consult the frame’s Permissions-Policy.
Potential Impact
- Permissions-Policy Bypass: A site’s
on-device-speech-recognition=()policy directive can be ignored by subframes. Since this policy defaults toEnableForSelf, cross-origin iframes are denied by default but can bypass this viastart()if they provide an audio track (e.g., viaMediaStreamTrack). - Information Leak: An attacker can infer the global installation state of SODA language packs by observing the success or failure of
start()in a restricted iframe. This information is intended to be protected by the Permissions-Policy. - Attack Surface Expansion: This allows unauthorized frames to interact with the sandboxed SODA/Gemini Nano utility process, increasing the reachability of these processes from untrusted content.
Suggested/Potential Reproduction Steps
- Ensure a SODA language pack (e.g., English) is installed in the Chrome profile.
- Host a page (Origin A) that embeds a cross-origin iframe (Origin B) without an
allow="on-device-speech-recognition"attribute. - In the Origin B iframe, capture an audio track from a media element using
captureStream(). - Execute the following JavaScript in the Origin B iframe:
const r = new webkitSpeechRecognition(); r.onresult = (e) => console.log('Transcription:', e.results[0][0].transcript); r.start(track); // Start with the captured MediaStreamTrack - Verify if transcriptions are received despite the policy being denied.
Suggested Fix
- Renderer: In
third_party/blink/renderer/modules/speech/speech_recognition.cc, add a check for theon-device-speech-recognitionPermissions-Policy and cross-origin iframe status withinCheckAvailabilityAndStart()before proceeding with an on-device request. - Browser: In
content/browser/speech/speech_recognition_dispatcher_host.cc, withinStartRequestOnUI, verify the frame’s Permissions-Policy usingrfh->IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::kOnDeviceSpeechRecognition)before allowing theon_device_availableflag to be set.
Evaluated with Chrome root at commit: 1a8d40fc44df2088d5945c0bf53584038aa1614a
Results so far have been promising, but there can be wrong deductions. Feel free to adjust as follows:
- If you are familiar with the severity guidelines, you may adjust the severity.
- If this is a false positive, and there’s no work to be done, please close as WAI.
- If there is work to do here but not a vulnerability, please change the issue type to Task/Bug/FR.
Data from false positives will be used to improve accuracy over time. And please feel free to reach out to me directly if you have concerns or feedback on the project.