CVE-2026-79103
Overview
Background
- On-device Web Speech
- the path where a page’s Web Speech API recognition session is served by the local speech recognition service (SODA) rather than a network engine, bound through a
media::mojom::SpeechRecognitionContextpipe that the browser obtains from the embedder delegate. - `SpeechRecognitionManagerDelegate::BindSpeechRecognitionContext`
- the content-layer embedder hook, called on the IO thread, that asks Chrome to bind a
SpeechRecognitionContextreceiver to the speech service owned by a particular profile. - Profile-keyed services
SpeechRecognitionServiceFactory(andCrosSpeechRecognitionServiceFactoryon ChromeOS) return a separateSpeechRecognitionServiceperProfile, so which profile is passed decides which service instance (regular, Incognito, or another user’s) handles the session.- `GlobalRenderFrameHostId`
- a process-plus-routing identifier for a frame that the browser can resolve back to a live
RenderFrameHost(and therefore itsBrowserContext) viaRenderFrameHost::FromID.
Root Cause Analysis
Before the patch, ChromeSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext took only a language string and, in its UI-thread task, chose the profile from global state: ProfileManager::GetLastUsedProfileIfLoaded() in ENABLE_BROWSER_SPEECH_SERVICE builds and ProfileManager::GetPrimaryUserProfile() on ChromeOS, never the profile of the frame that started recognition. In addition, the on-device branch in content/browser/speech/speech_recognition_manager_impl.cc cached a single browser-wide speech_recognition_context_ remote in SpeechRecognitionManagerImpl and only bound it when !speech_recognition_context_.is_bound(), so every later session from any profile reused whichever profile’s context had been bound first. Together these broke the invariant that a recognition session is handled by the speech service of the requesting frame’s own BrowserContext, which let Incognito or cross-profile sessions be routed to another profile’s service (as the commit message states).
The fix adds a GlobalRenderFrameHostId parameter to the interface, passes config.initial_context.global_id from both callers, resolves the RenderFrameHost on the UI thread and derives the Profile from rfh->GetBrowserContext(), and drops the request entirely if the id is null, the frame is gone, or no profile results. It also removes the cached speech_recognition_context_ member and binds a fresh local mojo::Remote<media::mojom::SpeechRecognitionContext> for each session. The enclosing function names for the speech_recognition_manager_impl.cc and soda_speech_recognition_engine_impl.cc hunks are not shown in the diff.
GlobalRenderFrameHostId, and fails closed when that frame cannot be resolved.Attack Path
- Two profiles active
The user has more than one profile context open, for example a regular window and an Incognito window, in a build with
ENABLE_SPEECH_SERVICEon a non-Android platform where on-device Web Speech is used. - First session pins the context
A page in one profile starts an on-device Web Speech session; pre-patch the delegate bound the context to the last-used or primary profile, and
SpeechRecognitionManagerImplcached that remote for all later sessions. - Session from another profile
A page in a different profile (such as Incognito) starts recognition through the normal Web Speech API; its
BindWebSpeechRecognizercall went through the cached context and was served by the other profile’sSpeechRecognitionService. - Cross-profile handling Audio and recognition state for the Incognito or second-profile session were processed in the wrong profile’s service, breaking profile isolation (privacy consequences beyond misrouting are (inferred)).
Impact Assessment
Changed Functions
| Function | Change | Notes |
|---|---|---|
ifchrome/browser/speech/chrome_speech_recognition_manager_delegate.cc |
modified | |
GetInstanceForTestchrome/browser/speech/speech_recognition_service_factory.cc |
modified | |
ifcontent/browser/speech/fake_speech_recognition_manager_delegate.cc |
modified | |
FakeSpeechRecognitionManagerDelegatecontent/browser/speech/fake_speech_recognition_manager_delegate.h |
modified | |
SpeechRecognitionEventListenercontent/public/browser/speech_recognition_manager_delegate.h |
modified | |
SpeechRecognitionManagerDelegatecontent/public/browser/speech_recognition_manager_delegate.h |
modified | |
ChromeSpeechRecognitionTestchrome/browser/speech/speech_recognition_browsertest.cc |
modified | |
ChromeSpeechRecognitionTestchrome/browser/speech/speech_recognition_browsertest.cc |
modified | |
SpeechWebContentsObserverchrome/browser/speech/speech_recognition_browsertest.cc |
modified | |
ifchrome/browser/speech/speech_recognition_browsertest.cc |
modified |
Files Changed
chrome/browser/speech/chrome_speech_recognition_manager_delegate.hchrome/browser/speech/speech_recognition_browsertest.cccontent/shell/browser/shell_speech_recognition_manager_delegate.ccchrome/browser/speech/speech_recognition_service_factory.hcontent/public/browser/speech_recognition_manager_delegate.hcontent/browser/speech/soda_speech_recognition_engine_impl.cccontent/browser/speech/fake_speech_recognition_manager_delegate.hcontent/browser/speech/fake_speech_recognition_manager_delegate.ccchrome/browser/speech/chrome_speech_recognition_manager_delegate.cccontent/shell/browser/shell_speech_recognition_manager_delegate.hcontent/browser/speech/speech_recognition_manager_impl.ccchrome/browser/speech/speech_recognition_service_factory.cccontent/browser/speech/speech_recognition_manager_impl.h
Audit Directions
- Ambient profile selectionLook for browser code that reaches a
ProfileKeyedServiceFactorythroughProfileManager::GetLastUsedProfile*,GetPrimaryUserProfile, or similar globals while serving a request that came from a specific frame or worker; the profile should come from the requester’sBrowserContext. - Process-wide cached Mojo remotesCheck singletons such as manager-level
mojo::Remotemembers that are bound once and then shared across requests from different profiles, origins, or frames, since the first binder’s context leaks to every later caller. - IO-to-UI thread hops losing contextWhen a request is posted from the IO thread to the UI thread, check that the requester identity (
GlobalRenderFrameHostId, process id) travels with it and that the UI-side code fails closed when the frame no longer exists.
Patch
From fca5f84ab5c990a64af33fa9c295776f98ef3d8e Mon Sep 17 00:00:00 2001 From: Evan Liu <[email protected]> Date: Wed, 08 Jul 2026 00:51:32 -0700 Subject: [PATCH] Route on-device Web Speech to the requesting frame's profile On-device Web Speech previously resolved the SpeechRecognitionService using the last-used profile instead of the initiating frame's profile, and cached a single browser-wide SpeechRecognitionContext. This allowed Incognito or cross-profile sessions to be misrouted. This CL: - Plumbs the initiating frame's GlobalRenderFrameHostId to BindSpeechRecognitionContext to resolve the correct profile. - Binds a fresh SpeechRecognitionContext per session instead of caching a singleton remote. Fixed: 520481800 Change-Id: Ice1f15335752179eeb762a085dced5879974036d Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8021607 Reviewed-by: Nasko Oskov <[email protected]> Commit-Queue: Evan Liu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1658558} --- diff --git a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc index 30df13cf..460ea89 100644 --- a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc +++ b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc @@ -127,21 +127,39 @@ void ChromeSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> recognition_receiver, - const std::string& language) { + const std::string& language, + const content::GlobalRenderFrameHostId& render_frame_host_id) { #if BUILDFLAG(ENABLE_SPEECH_SERVICE) DCHECK_CURRENTLY_ON(BrowserThread::IO); content::GetUIThreadTaskRunner({})->PostTask( FROM_HERE, base::BindOnce( - [](const std::string& language, + [](const content::GlobalRenderFrameHostId& render_frame_host_id, + const std::string& language, mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver) { + if (!render_frame_host_id) { + return; + } + + content::RenderFrameHost* rfh = + content::RenderFrameHost::FromID(render_frame_host_id); + if (!rfh) { + // The frame was destroyed before we could bind. Drop the + // request to avoid routing to an incorrect profile. + return; + } + + Profile* profile = + Profile::FromBrowserContext(rfh->GetBrowserContext()); + if (!profile) { + return; + } + #if BUILDFLAG(ENABLE_BROWSER_SPEECH_SERVICE) - auto* profile = ProfileManager::GetLastUsedProfileIfLoaded(); auto* factory = SpeechRecognitionServiceFactory::GetForProfile(profile); #elif BUILDFLAG(IS_CHROMEOS) - auto* profile = ProfileManager::GetPrimaryUserProfile(); auto* factory = CrosSpeechRecognitionServiceFactory::GetForProfile(profile); #else @@ -150,13 +168,12 @@ if (factory) { factory->BindSpeechRecognitionContext(std::move(receiver)); } - // Reset the SODA uninstall timer when used by the Web Speech API. - if (profile) { - SodaInstaller::GetInstance()->SetUninstallTimer( - g_browser_process->local_state(), language); - } + // Reset the SODA uninstall timer when used by the Web Speech + // API. + SodaInstaller::GetInstance()->SetUninstallTimer( + g_browser_process->local_state(), language); }, - language, std::move(recognition_receiver))); + render_frame_host_id, language, std::move(recognition_receiver))); #endif // BUILDFLAG(ENABLE_SPEECH_SERVICE) } #endif // !BUILDFLAG(IS_ANDROID) diff --git a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h index 5aec171..3b4f9b1 100644 --- a/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h +++ b/chrome/browser/speech/chrome_speech_recognition_manager_delegate.h @@ -61,7 +61,8 @@ // This will bind to the Speech Recognition Service if available. void BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver, - const std::string& language) override; + const std::string& language, + const content::GlobalRenderFrameHostId& render_frame_host_id) override; #endif // !BUILDFLAG(IS_ANDROID) private: diff --git a/chrome/browser/speech/speech_recognition_service_factory.cc b/chrome/browser/speech/speech_recognition_service_factory.cc index 11b9e1f5..b837b43c 100644 --- a/chrome/browser/speech/speech_recognition_service_factory.cc +++ b/chrome/browser/speech/speech_recognition_service_factory.cc @@ -23,6 +23,12 @@ return instance.get(); } +// static +SpeechRecognitionServiceFactory* +SpeechRecognitionServiceFactory::GetInstanceForTest() { + return GetInstance(); +} + SpeechRecognitionServiceFactory::SpeechRecognitionServiceFactory() : ProfileKeyedServiceFactory( "SpeechRecognitionService", diff --git a/chrome/browser/speech/speech_recognition_service_factory.h b/chrome/browser/speech/speech_recognition_service_factory.h index e73fa12..bbc0846 100644 --- a/chrome/browser/speech/speech_recognition_service_factory.h +++ b/chrome/browser/speech/speech_recognition_service_factory.h @@ -20,6 +20,8 @@ public: static speech::SpeechRecognitionService* GetForProfile(Profile* profile); + static SpeechRecognitionServiceFactory* GetInstanceForTest(); + static void EnsureFactoryBuilt(); private: diff --git a/content/browser/speech/fake_speech_recognition_manager_delegate.cc b/content/browser/speech/fake_speech_recognition_manager_delegate.cc index 91f80e7..bacf6e1 100644 --- a/content/browser/speech/fake_speech_recognition_manager_delegate.cc +++ b/content/browser/speech/fake_speech_recognition_manager_delegate.cc @@ -106,7 +106,8 @@ void FakeSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver, - const std::string& language) { + const std::string& language, + const GlobalRenderFrameHostId& render_frame_host_id) { if (speech_service_) { speech_service_->BindSpeechRecognitionContext(std::move(receiver)); } diff --git a/content/browser/speech/fake_speech_recognition_manager_delegate.h b/content/browser/speech/fake_speech_recognition_manager_delegate.h index 34948e967..0213c86 100644 --- a/content/browser/speech/fake_speech_recognition_manager_delegate.h +++ b/content/browser/speech/fake_speech_recognition_manager_delegate.h @@ -98,6 +98,8 @@ this}; }; +struct GlobalRenderFrameHostId; + class FakeSpeechRecognitionManagerDelegate : public SpeechRecognitionManagerDelegate { public: @@ -115,7 +117,8 @@ SpeechRecognitionEventListener* GetEventListener() override; void BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver, - const std::string& language) override; + const std::string& language, + const GlobalRenderFrameHostId& render_frame_host_id) override; void Reset(MockOnDeviceWebSpeechRecognitionService* service); diff --git a/content/browser/speech/soda_speech_recognition_engine_impl.cc b/content/browser/speech/soda_speech_recognition_engine_impl.cc index 01560e8..0b7cf22 100644 --- a/content/browser/speech/soda_speech_recognition_engine_impl.cc +++ b/content/browser/speech/soda_speech_recognition_engine_impl.cc @@ -97,7 +97,8 @@ weak_factory_.GetWeakPtr()))); speech_recognition_mgr_delegate->BindSpeechRecognitionContext( - std::move(speech_recognition_context_receiver), config_.language); + std::move(speech_recognition_context_receiver), config_.language, + config_.initial_context.global_id); speech_recognition_context_.set_disconnect_handler( base::BindPostTaskToCurrentDefault(base::BindOnce( diff --git a/content/browser/speech/speech_recognition_manager_impl.cc b/content/browser/speech/speech_recognition_manager_impl.cc index f8497cc..6de11cc 100644 --- a/content/browser/speech/speech_recognition_manager_impl.cc +++ b/content/browser/speech/speech_recognition_manager_impl.cc @@ -713,20 +713,17 @@ // directly to the speech recognition process and speech recognition events // will be returned directly to the renderer, bypassing the browser // entirely. - if (!speech_recognition_context_.is_bound()) { - raw_ptr<SpeechRecognitionManagerDelegate> - speech_recognition_mgr_delegate = - SpeechRecognitionManagerImpl::GetInstance() - ? SpeechRecognitionManagerImpl::GetInstance()->delegate() - : nullptr; + raw_ptr<SpeechRecognitionManagerDelegate> speech_recognition_mgr_delegate = + SpeechRecognitionManagerImpl::GetInstance() + ? SpeechRecognitionManagerImpl::GetInstance()->delegate() + : nullptr; - CHECK(speech_recognition_mgr_delegate); - mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> - speech_recognition_context_receiver = - speech_recognition_context_.BindNewPipeAndPassReceiver(); - speech_recognition_mgr_delegate->BindSpeechRecognitionContext( - std::move(speech_recognition_context_receiver), config.language); - } + CHECK(speech_recognition_mgr_delegate); + mojo::Remote<media::mojom::SpeechRecognitionContext> + speech_recognition_context; + speech_recognition_mgr_delegate->BindSpeechRecognitionContext( + speech_recognition_context.BindNewPipeAndPassReceiver(), + config.language, config.initial_context.global_id); media::mojom::SpeechRecognitionOptionsPtr options = media::mojom::SpeechRecognitionOptions::New(); @@ -739,7 +736,7 @@ options->recognition_context = config.recognition_context; options->allow_multi_language = false; - speech_recognition_context_->BindWebSpeechRecognizer( + speech_recognition_context->BindWebSpeechRecognizer( std::move(session_receiver), std::move(client_remote), std::move(audio_forwarder_config.value().audio_forwarder), audio_forwarder_config.value().channel_count, diff --git a/content/browser/speech/speech_recognition_manager_impl.h b/content/browser/speech/speech_recognition_manager_impl.h index b8ff69e6..50161dc 100644 --- a/content/browser/speech/speech_recognition_manager_impl.h +++ b/content/browser/speech/speech_recognition_manager_impl.h @@ -229,9 +229,6 @@ std::unique_ptr<SpeechRecognitionManagerDelegate> delegate_; const int requester_id_; - mojo::Remote<media::mojom::SpeechRecognitionContext> - speech_recognition_context_; - // Used for posting asynchronous tasks (on the IO thread) without worrying // about this class being destroyed in the meanwhile (due to browser shutdown) // since tasks pending on a destroyed WeakPtr are automatically discarded. diff --git a/content/public/browser/speech_recognition_manager_delegate.h b/content/public/browser/speech_recognition_manager_delegate.h index f17629d..415e284 100644 --- a/content/public/browser/speech_recognition_manager_delegate.h +++ b/content/public/browser/speech_recognition_manager_delegate.h @@ -17,6 +17,8 @@ class SpeechRecognitionEventListener; +struct GlobalRenderFrameHostId; + // Allows embedders to display the current state of recognition, for getting the // user's permission and for fetching optional request information. class SpeechRecognitionManagerDelegate { @@ -40,7 +42,8 @@ // This is called on the IO thread. virtual void BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver, - const std::string& language) = 0; + const std::string& language, + const GlobalRenderFrameHostId& render_frame_host_id) = 0; #endif // !BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID) }; diff --git a/content/shell/browser/shell_speech_recognition_manager_delegate.cc b/content/shell/browser/shell_speech_recognition_manager_delegate.cc index f2515503..30bee9e5 100644 --- a/content/shell/browser/shell_speech_recognition_manager_delegate.cc +++ b/content/shell/browser/shell_speech_recognition_manager_delegate.cc @@ -30,7 +30,8 @@ #if !BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID) void ShellSpeechRecognitionManagerDelegate::BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver, - const std::string& language) {} + const std::string& language, + const GlobalRenderFrameHostId& render_frame_host_id) {} #endif // !BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID) } // namespace content diff --git a/content/shell/browser/shell_speech_recognition_manager_delegate.h b/content/shell/browser/shell_speech_recognition_manager_delegate.h index 549327e..cf75a13 100644 --- a/content/shell/browser/shell_speech_recognition_manager_delegate.h +++ b/content/shell/browser/shell_speech_recognition_manager_delegate.h @@ -11,6 +11,8 @@ namespace content { +struct GlobalRenderFrameHostId; + // This is content_shell's delegate used by the speech recognition manager to // check for permission to record audio. For content_shell, we always authorize // speech recognition (see crbug.com/237119). @@ -36,7 +38,8 @@ // It is empty in this delegate. void BindSpeechRecognitionContext( mojo::PendingReceiver<media::mojom::SpeechRecognitionContext> receiver, - const std::string& language) override; + const std::string& language, + const GlobalRenderFrameHostId& render_frame_host_id) override; #endif //! BUILDFLAG(IS_FUCHSIA) && !BUILDFLAG(IS_ANDROID) }; diff --git a/chrome/browser/speech/speech_recognition_browsertest.cc b/chrome/browser/speech/speech_recognition_browsertest.cc index 2284ce9..2204468 100644 --- a/chrome/browser/speech/speech_recognition_browsertest.cc +++ b/chrome/browser/speech/speech_recognition_browsertest.cc @@ -6,9 +6,17 @@ #include "base/strings/utf_string_conversions.h" #include "base/task/bind_post_task.h" +#include "base/test/scoped_feature_list.h" #include "base/test/test_future.h" #include "chrome/browser/speech/chrome_speech_recognition_manager_delegate.h" +#include "chrome/browser/speech/fake_speech_recognition_service.h" +#include "chrome/browser/speech/speech_recognition_service_factory.h" #include "chrome/browser/ui/browser.h" + +#if BUILDFLAG(IS_CHROMEOS) +#include "ash/constants/ash_features.h" +#include "chrome/browser/speech/cros_speech_recognition_service_factory.h" +#endif #include "chrome/browser/ui/browser_commands.h" #include "chrome/browser/ui/tabs/tab_strip_model.h" #include "chrome/test/base/in_process_browser_test.h" @@ -21,6 +29,7 @@ #include "content/public/test/browser_test.h" #include "content/public/test/browser_test_utils.h" #include "content/public/test/fake_speech_recognition_manager.h" +#include "media/mojo/mojom/speech_recognition.mojom.h" #include "net/test/embedded_test_server/embedded_test_server.h" #if BUILDFLAG(ENABLE_EXTENSIONS) @@ -36,7 +45,12 @@ class ChromeSpeechRecognitionTest : public InProcessBrowserTest { public: - ChromeSpeechRecognitionTest() = default; + ChromeSpeechRecognitionTest() { +#if BUILDFLAG(IS_CHROMEOS) + scoped_feature_list_.InitAndEnableFeature( + ash::features::kOnDeviceSpeechRecognition); +#endif + } ChromeSpeechRecognitionTest(const ChromeSpeechRecognitionTest&) = delete; ChromeSpeechRecognitionTest& operator=(const ChromeSpeechRecognitionTest&) = @@ -71,6 +85,10 @@ protected: ChromeSpeechRecognitionManagerDelegate delegate_; content::FakeSpeechRecognitionManager fake_speech_recognition_manager_; + +#if BUILDFLAG(IS_CHROMEOS) + base::test::ScopedFeatureList scoped_feature_list_; +#endif }; class SpeechWebContentsObserver : public content::WebContentsObserver { @@ -217,4 +235,58 @@ } #endif +#if !BUILDFLAG(IS_ANDROID) +IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest, + IncognitoRoutesToIncognitoService) { + Browser* incognito_browser = CreateIncognitoBrowser(); + ASSERT_TRUE(incognito_browser); + + struct TestContext { + base::OnceClosure quit_closure; + bool created = false; + }; + auto context = std::make_unique<TestContext>(); + base::RunLoop run_loop; + context->quit_closure = run_loop.QuitClosure(); + TestContext* context_ptr = context.get(); + + auto testing_factory = base::BindRepeating( + [](TestContext* ctx, + content::BrowserContext* context) -> std::unique_ptr<KeyedService> { + ctx->created = true; + if (ctx->quit_closure) { + std::move(ctx->quit_closure).Run(); + } + return std::make_unique<FakeSpeechRecognitionService>(); + }, + base::Unretained(context_ptr)); + +#if BUILDFLAG(IS_CHROMEOS) + CrosSpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory( + incognito_browser->profile(), std::move(testing_factory)); +#else + SpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory( + incognito_browser->profile(), std::move(testing_factory)); +#endif + + WebContents* web_contents = + incognito_browser->tab_strip_model()->GetActiveWebContents(); + ASSERT_TRUE(web_contents); + content::RenderFrameHost* rfh = web_contents->GetPrimaryMainFrame(); ... (file diff truncated)
Regression Test / PoC
diff --git a/chrome/browser/speech/speech_recognition_browsertest.cc b/chrome/browser/speech/speech_recognition_browsertest.cc
index 2284ce9..2204468 100644
--- a/chrome/browser/speech/speech_recognition_browsertest.cc
+++ b/chrome/browser/speech/speech_recognition_browsertest.cc
@@ -6,9 +6,17 @@
#include "base/strings/utf_string_conversions.h"
#include "base/task/bind_post_task.h"
+#include "base/test/scoped_feature_list.h"
#include "base/test/test_future.h"
#include "chrome/browser/speech/chrome_speech_recognition_manager_delegate.h"
+#include "chrome/browser/speech/fake_speech_recognition_service.h"
+#include "chrome/browser/speech/speech_recognition_service_factory.h"
#include "chrome/browser/ui/browser.h"
+
+#if BUILDFLAG(IS_CHROMEOS)
+#include "ash/constants/ash_features.h"
+#include "chrome/browser/speech/cros_speech_recognition_service_factory.h"
+#endif
#include "chrome/browser/ui/browser_commands.h"
#include "chrome/browser/ui/tabs/tab_strip_model.h"
#include "chrome/test/base/in_process_browser_test.h"
@@ -21,6 +29,7 @@
#include "content/public/test/browser_test.h"
#include "content/public/test/browser_test_utils.h"
#include "content/public/test/fake_speech_recognition_manager.h"
+#include "media/mojo/mojom/speech_recognition.mojom.h"
#include "net/test/embedded_test_server/embedded_test_server.h"
#if BUILDFLAG(ENABLE_EXTENSIONS)
@@ -36,7 +45,12 @@
class ChromeSpeechRecognitionTest : public InProcessBrowserTest {
public:
- ChromeSpeechRecognitionTest() = default;
+ ChromeSpeechRecognitionTest() {
+#if BUILDFLAG(IS_CHROMEOS)
+ scoped_feature_list_.InitAndEnableFeature(
+ ash::features::kOnDeviceSpeechRecognition);
+#endif
+ }
ChromeSpeechRecognitionTest(const ChromeSpeechRecognitionTest&) = delete;
ChromeSpeechRecognitionTest& operator=(const ChromeSpeechRecognitionTest&) =
@@ -71,6 +85,10 @@
protected:
ChromeSpeechRecognitionManagerDelegate delegate_;
content::FakeSpeechRecognitionManager fake_speech_recognition_manager_;
+
+#if BUILDFLAG(IS_CHROMEOS)
+ base::test::ScopedFeatureList scoped_feature_list_;
+#endif
};
class SpeechWebContentsObserver : public content::WebContentsObserver {
@@ -217,4 +235,58 @@
}
#endif
+#if !BUILDFLAG(IS_ANDROID)
+IN_PROC_BROWSER_TEST_F(ChromeSpeechRecognitionTest,
+ IncognitoRoutesToIncognitoService) {
+ Browser* incognito_browser = CreateIncognitoBrowser();
+ ASSERT_TRUE(incognito_browser);
+
+ struct TestContext {
+ base::OnceClosure quit_closure;
+ bool created = false;
+ };
+ auto context = std::make_unique<TestContext>();
+ base::RunLoop run_loop;
+ context->quit_closure = run_loop.QuitClosure();
+ TestContext* context_ptr = context.get();
+
+ auto testing_factory = base::BindRepeating(
+ [](TestContext* ctx,
+ content::BrowserContext* context) -> std::unique_ptr<KeyedService> {
+ ctx->created = true;
+ if (ctx->quit_closure) {
+ std::move(ctx->quit_closure).Run();
+ }
+ return std::make_unique<FakeSpeechRecognitionService>();
+ },
+ base::Unretained(context_ptr));
+
+#if BUILDFLAG(IS_CHROMEOS)
+ CrosSpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory(
+ incognito_browser->profile(), std::move(testing_factory));
+#else
+ SpeechRecognitionServiceFactory::GetInstanceForTest()->SetTestingFactory(
+ incognito_browser->profile(), std::move(testing_factory));
+#endif
+
+ WebContents* web_contents =
+ incognito_browser->tab_strip_model()->GetActiveWebContents();
+ ASSERT_TRUE(web_contents);
+ content::RenderFrameHost* rfh = web_contents->GetPrimaryMainFrame();
+ content::GlobalRenderFrameHostId rfh_id = rfh->GetGlobalId();
+
+ mojo::Remote<media::mojom::SpeechRecognitionContext> remote;
+ content::GetIOThreadTaskRunner({})->PostTask(
+ FROM_HERE,
+ base::BindOnce(&content::SpeechRecognitionManagerDelegate::
+ BindSpeechRecognitionContext,
+ base::Unretained(&delegate_),
+ remote.BindNewPipeAndPassReceiver(), "en-US", rfh_id));
+
+ run_loop.Run();
+
+ EXPECT_TRUE(context_ptr->created);
+}
+#endif // !BUILDFLAG(IS_ANDROID)
+
} // namespace speech
Original Bug Report
Cross-profile/incognito isolation bypass in on-device Web Speech recognition
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: A potential vulnerability exists where on-device Web Speech recognition can route Mojo pipes from one profile to the SODA utility process of a different profile. This is caused by the browser-process singleton caching a single per-profile remote and the delegate resolving the wrong profile using a fallback method. If successfully exploited, this could allow a compromised renderer to bypass incognito or cross-profile isolation.
Affected files:
content/browser/speech/speech_recognition_manager_impl.ccchrome/browser/speech/chrome_speech_recognition_manager_delegate.cccontent/browser/speech/speech_recognition_manager_impl.h
Estimated timestamp from git blame: 2024-08-01
Summary
A potential cross-profile and incognito isolation bypass exists in the on-device Web Speech recognition pathway. Two compounding defects can cause renderer-owned Mojo pipes from one profile’s browser context to be misrouted to the kSpeechRecognition utility process of a different profile.
Root Causes
-
Process-wide singleton caches a per-profile Mojo Remote: The
SpeechRecognitionManagerImplclass is a browser-process singleton. It contains a membermojo::Remote<media::mojom::SpeechRecognitionContext> speech_recognition_context_(content/browser/speech/speech_recognition_manager_impl.h), which is bound once behind anis_bound()guard and never reset:// content/browser/speech/speech_recognition_manager_impl.cc if (!speech_recognition_context_.is_bound()) { ... speech_recognition_mgr_delegate->BindSpeechRecognitionContext( std::move(speech_recognition_context_receiver), config.language); }Because it is bound only on the first request and subsequently reused, all future on-device Web Speech requests browser-wide from any profile will reuse this single cached Remote.
-
Delegate resolves the wrong profile context: Even on the initial bind, the delegate does not receive or use the originating frame’s
BrowserContext. Instead, it resolves the profile using a fallback method:// chrome/browser/speech/chrome_speech_recognition_manager_delegate.cc auto* profile = ProfileManager::GetLastUsedProfileIfLoaded(); auto* factory = SpeechRecognitionServiceFactory::GetForProfile(profile); factory->BindSpeechRecognitionContext(std::move(receiver));ProfileManager::GetLastUsedProfileIfLoaded()returns the profile corresponding to the most recently activated on-disk window, which does not necessarily match the requesting frame’s profile and will never return an Off-The-Record (Incognito) profile.
Potential Attack Steps
Note: These are potential steps as we have not executed functional proof-of-concept exploit code:
- SODA (Speech On-Device API) is installed on the user’s system.
- A compromised renderer running in Profile B (e.g., an Incognito window or a secondary profile) invokes
media::mojom::SpeechRecognizer::Startwithon_device=trueover theSpeechRecognitionDispatcherHostinterface. - The browser process receives the request and, in
SpeechRecognitionManagerImpl::CreateSession, triggers the first-time bind ofspeech_recognition_context_if it is currently unbound. - The delegate resolves the profile using
ProfileManager::GetLastUsedProfileIfLoaded(), which returns Profile A (the last active regular profile). - The Mojo pipes (
session_receiver,client_remote,audio_forwarder) originating from Profile B are forwarded directly into Profile A’s SODA utility process. - Any subsequent on-device Web Speech requests from any profile reuse the cached
speech_recognition_context_, permanently routing their sessions into Profile A’s utility process.
Impact
A compromised renderer in one profile can bypass the cross-profile and incognito boundaries to interact with another profile’s utility process. Standalone impact is limited, but if chained with a separate memory corruption primitive within the SODA utility process, it could elevate a single-profile compromise to a cross-profile or regular-profile compromise from an Incognito session.
Suggested Fix
- Pass the initiating frame’s
BrowserContextor its identifier down through theSpeechRecognitionSessionConfigduring session creation. - Update
SpeechRecognitionManagerImplto maintain a map ofSpeechRecognitionContextremotes keyed byBrowserContext(or manage the remote lifecycle within the delegate). - Update the delegate’s
BindSpeechRecognitionContextmethod signature to accept theBrowserContextand bind to the correct profile-keyed service instance rather than falling back toGetLastUsedProfileIfLoaded().
Evaluated with Chrome root at commit: e9507a33bb4148ee071aaaf8a7e9ad68770359bf
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.