Overview

Low
Severity
CVSS
No
Exploited ITW
Fixed
Fix Status
ImpactInsufficient policy enforcement in Speech
DescriptionInsufficient policy enforcement in Speech
ComponentSpeech
Bug ClassLogic Error
Tracker513528117
Fix commit5d69e4c79d3b (chromium/src) +178/-4
CISA KEVNot listed
CreditedGoogle
Disclosed2026-06-30

Changed Functions

FunctionChangeNotes
SpeechRecognitionCrossOriginBrowserTest
content/browser/speech/speech_recognition_browsertest.cc
modified
if
content/browser/speech/speech_recognition_browsertest.cc
modified

Files Changed

  • content/browser/speech/speech_recognition_browsertest.cc
  • content/browser/speech/speech_recognition_dispatcher_host.cc
From 5d69e4c79d3b6053b82f536ad89b92d679faca25 Mon Sep 17 00:00:00 2001
From: Evan Liu <evliu@google.com>
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 <evliu@google.com>
Reviewed-by: Andy Paicu <andypaicu@chromium.org>
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());
Loading diff…

Regression Test / PoC

shipped with the fix
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
Loading diff…

Original Bug Report

reported by vm...@google.com

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.cc
  • content/browser/speech/speech_recognition_dispatcher_host.cc
  • content/browser/speech/speech_recognition_manager_impl.cc
  • chrome/browser/speech/on_device_speech_recognition_impl.cc
  • chrome/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

  1. Permissions-Policy Bypass: A site’s on-device-speech-recognition=() policy directive can be ignored by subframes. Since this policy defaults to EnableForSelf, cross-origin iframes are denied by default but can bypass this via start() if they provide an audio track (e.g., via MediaStreamTrack).
  2. 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.
  3. 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

  1. Ensure a SODA language pack (e.g., English) is installed in the Chrome profile.
  2. Host a page (Origin A) that embeds a cross-origin iframe (Origin B) without an allow="on-device-speech-recognition" attribute.
  3. In the Origin B iframe, capture an audio track from a media element using captureStream().
  4. 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
    
  5. Verify if transcriptions are received despite the policy being denied.

Suggested Fix

  1. Renderer: In third_party/blink/renderer/modules/speech/speech_recognition.cc, add a check for the on-device-speech-recognition Permissions-Policy and cross-origin iframe status within CheckAvailabilityAndStart() before proceeding with an on-device request.
  2. Browser: In content/browser/speech/speech_recognition_dispatcher_host.cc, within StartRequestOnUI, verify the frame’s Permissions-Policy using rfh->IsFeatureEnabled(network::mojom::PermissionsPolicyFeature::kOnDeviceSpeechRecognition) before allowing the on_device_available flag 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.

View on issue tracker