CVE-2026-113144
Overview
Changed Functions
| Function | Change | Notes |
|---|---|---|
forcontent/browser/speech/speech_recognition_manager_impl.cc |
modified | |
ifcontent/browser/speech/speech_recognition_manager_impl.cc |
modified |
Files Changed
content/browser/speech/speech_recognition_browsertest.cccontent/browser/speech/speech_recognition_manager_impl.cccontent/browser/speech/speech_recognition_manager_impl.h
Patch
From df1d051fa2b027197ccb58dac03ad97fffc03b33 Mon Sep 17 00:00:00 2001 From: Evan Liu <[email protected]> Date: Tue, 15 Sep 2026 12:31:32 -0700 Subject: [PATCH] Subscribe to microphone permission changes in speech recognition Web Speech recognition sessions previously validated microphone access only during session startup and did not observe subsequent permission status updates. If audio capture permissions changed during an active session, ongoing audio capture continued running until manually stopped or terminated by silence endpointing. This updates FrameSessionTracker to subscribe to PermissionController for audio capture permission status changes while microphone sessions are active. When permission status transitions away from granted, associated speech recognition sessions are terminated. Additionally, a valid stop callback is provided to the media stream UI indicator. Fixed: 544498565 Change-Id: Ia4738b63b703d34cfc48583eb96b47e79e193916 Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/8407668 Reviewed-by: Yiren Wang <[email protected]> Commit-Queue: Evan Liu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1698302} --- diff --git a/content/browser/speech/speech_recognition_manager_impl.cc b/content/browser/speech/speech_recognition_manager_impl.cc index 9f9056a..37753a9 100644 --- a/content/browser/speech/speech_recognition_manager_impl.cc +++ b/content/browser/speech/speech_recognition_manager_impl.cc @@ -20,16 +20,21 @@ #include "build/build_config.h" #include "components/soda/soda_util.h" #include "content/browser/browser_main_loop.h" +#include "content/browser/permissions/permission_util.h" #include "content/browser/renderer_host/media/media_stream_manager.h" #include "content/browser/renderer_host/media/media_stream_ui_proxy.h" #include "content/browser/speech/network_speech_recognition_engine_impl.h" #include "content/browser/speech/speech_recognizer_impl.h" #include "content/browser/web_contents/web_contents_impl.h" +#include "content/public/browser/browser_context.h" #include "content/public/browser/browser_task_traits.h" #include "content/public/browser/browser_thread.h" #include "content/public/browser/content_browser_client.h" #include "content/public/browser/document_user_data.h" #include "content/public/browser/global_routing_id.h" +#include "content/public/browser/permission_controller.h" +#include "content/public/browser/permission_descriptor_util.h" +#include "content/public/browser/permission_result.h" #include "content/public/browser/render_frame_host.h" #include "content/public/browser/speech_recognition_audio_forwarder_config.h" #include "content/public/browser/speech_recognition_event_listener.h" @@ -49,6 +54,8 @@ #include "media/mojo/mojom/speech_recognizer.mojom.h" #include "mojo/public/cpp/bindings/pending_receiver.h" #include "mojo/public/cpp/bindings/self_owned_receiver.h" +#include "third_party/blink/public/common/permissions/permission_utils.h" +#include "third_party/blink/public/mojom/permissions/permission_status.mojom.h" #include "url/gurl.h" #include "url/origin.h" @@ -113,13 +120,19 @@ public: using FrameDeletedCallback = base::RepeatingCallback<void(int /* session_id */)>; + using PermissionRevokedCallback = + base::RepeatingCallback<void(int /* session_id */)>; ~FrameSessionTracker() override { CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); - for (auto session : sessions_) { - GetIOThreadTaskRunner({})->PostTask( - FROM_HERE, base::BindOnce(frame_deleted_callback_, session)); + UnsubscribeFromPermissionController(); + + if (frame_deleted_callback_) { + for (auto session : sessions_) { + GetIOThreadTaskRunner({})->PostTask( + FROM_HERE, base::BindOnce(frame_deleted_callback_, session)); + } } } @@ -134,25 +147,35 @@ static_cast<WebContentsImpl*>(web_contents()); if (!web_contents_impl || web_contents_impl->GetPageVisibilityState() != PageVisibilityState::kVisible) { - for (int session : sessions_) { - GetIOThreadTaskRunner({})->PostTask( - FROM_HERE, base::BindOnce(frame_deleted_callback_, session)); + UnsubscribeFromPermissionController(); + if (frame_deleted_callback_) { + for (int session : sessions_) { + GetIOThreadTaskRunner({})->PostTask( + FROM_HERE, base::BindOnce(frame_deleted_callback_, session)); + } } sessions_.clear(); + mic_sessions_.clear(); } #else (void)visibility; // Suppress unused parameter warning #endif } - static void CreateObserverForSession(GlobalRenderFrameHostId global_id, - int session_id, - FrameDeletedCallback callback) { + static void CreateObserverForSession( + GlobalRenderFrameHostId global_id, + int session_id, + bool use_microphone, + FrameDeletedCallback frame_deleted_callback, + PermissionRevokedCallback permission_revoked_callback) { CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); RenderFrameHost* render_frame_host = RenderFrameHost::FromID(global_id); - if (!render_frame_host) + if (!render_frame_host) { + GetIOThreadTaskRunner({})->PostTask( + FROM_HERE, base::BindOnce(frame_deleted_callback, session_id)); return; + } #if BUILDFLAG(IS_ANDROID) // On Android, background speech recognition is not permitted. (Desktop @@ -161,8 +184,8 @@ content::WebContents::FromRenderFrameHost(render_frame_host)); if (!web_contents || web_contents->GetPageVisibilityState() != PageVisibilityState::kVisible) { - GetIOThreadTaskRunner({})->PostTask(FROM_HERE, - base::BindOnce(callback, session_id)); + GetIOThreadTaskRunner({})->PostTask( + FROM_HERE, base::BindOnce(frame_deleted_callback, session_id)); return; } #endif @@ -172,8 +195,9 @@ // This will clobber any previously set callback but it will always // be the same binding. - tracker->SetCallback(std::move(callback)); - tracker->AddSession(session_id); + tracker->SetCallbacks(std::move(frame_deleted_callback), + std::move(permission_revoked_callback)); + tracker->AddSession(session_id, use_microphone); } static void RemoveObserverForSession(GlobalRenderFrameHostId global_id, @@ -181,17 +205,20 @@ CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); RenderFrameHost* render_frame_host = RenderFrameHost::FromID(global_id); - if (!render_frame_host) + if (!render_frame_host) { return; + } FrameSessionTracker* tracker = GetForCurrentDocument(render_frame_host); - if (!tracker) + if (!tracker) { return; + } tracker->RemoveSession(session_id); } static int GetSessionCountForTesting( // IN-TEST GlobalRenderFrameHostId global_id) { + CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); RenderFrameHost* render_frame_host = RenderFrameHost::FromID(global_id); if (!render_frame_host) { return 0; @@ -208,16 +235,90 @@ friend class content::DocumentUserData<FrameSessionTracker>; DOCUMENT_USER_DATA_KEY_DECL(); - void AddSession(int session_id) { sessions_.insert(session_id); } + void AddSession(int session_id, bool use_microphone) { + sessions_.insert(session_id); + if (use_microphone) { + mic_sessions_.insert(session_id); + SubscribeToPermissionControllerIfNeeded(); + } + } - void RemoveSession(int session_id) { sessions_.erase(session_id); } + void RemoveSession(int session_id) { + sessions_.erase(session_id); + mic_sessions_.erase(session_id); + UnsubscribeFromPermissionControllerIfNeeded(); + } - void SetCallback(FrameDeletedCallback callback) { - frame_deleted_callback_ = std::move(callback); + void SetCallbacks(FrameDeletedCallback frame_deleted_callback, + PermissionRevokedCallback permission_revoked_callback) { + frame_deleted_callback_ = std::move(frame_deleted_callback); + permission_revoked_callback_ = std::move(permission_revoked_callback); + } + + void SubscribeToPermissionControllerIfNeeded() { + CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); + if (mic_sessions_.empty() || !subscription_id_.is_null()) { + return; + } + PermissionController* controller = + render_frame_host().GetBrowserContext()->GetPermissionController(); + if (!controller) { + return; + } + subscription_id_ = controller->SubscribeToPermissionResultChange( + PermissionDescriptorUtil::CreatePermissionDescriptorForPermissionType( + blink::PermissionType::AUDIO_CAPTURE), + /*render_process_host=*/nullptr, &render_frame_host(), + PermissionUtil::GetLastCommittedOriginAsURL(&render_frame_host()), + /*should_include_device_status=*/false, + base::BindRepeating(&FrameSessionTracker::OnPermissionChanged, + weak_ptr_factory_.GetWeakPtr())); + } + + void UnsubscribeFromPermissionControllerIfNeeded() { + CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); + if (mic_sessions_.empty()) { + UnsubscribeFromPermissionController(); + } + } + + void UnsubscribeFromPermissionController() { + CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); + if (subscription_id_.is_null()) { + return; + } + PermissionController* controller = + render_frame_host().GetBrowserContext()->GetPermissionController(); + if (controller) { + controller->UnsubscribeFromPermissionResultChange(subscription_id_); + } + subscription_id_ = PermissionController::SubscriptionId(); + } + + void OnPermissionChanged(PermissionResult permission_result) { + CHECK_CURRENTLY_ON(BrowserThread::UI, base::NotFatalUntil::M159); + if (permission_result.status == blink::mojom::PermissionStatus::GRANTED) { + return; + } + + std::set<int> mic_sessions_to_abort = std::exchange(mic_sessions_, {}); + UnsubscribeFromPermissionController(); + + for (int session_id : mic_sessions_to_abort) { + if (permission_revoked_callback_) { + GetIOThreadTaskRunner({})->PostTask( + FROM_HERE, + base::BindOnce(permission_revoked_callback_, session_id)); + } + } } FrameDeletedCallback frame_deleted_callback_; + PermissionRevokedCallback permission_revoked_callback_; std::set<int> sessions_; + std::set<int> mic_sessions_; + PermissionController::SubscriptionId subscription_id_; + base::WeakPtrFactory<FrameSessionTracker> weak_ptr_factory_{this}; }; DOCUMENT_USER_DATA_KEY_IMPL(FrameSessionTracker); @@ -443,6 +544,29 @@ weak_factory_.GetWeakPtr(), session_id, EVENT_ABORT)); } +void SpeechRecognitionManagerImpl::AbortSessionForPermissionRevocation( + int session_id) { + CHECK_CURRENTLY_ON(BrowserThread::IO, base::NotFatalUntil::M159); + + auto iter = sessions_.find(session_id); + if (iter == sessions_.end()) { + return; + } + + if (iter->second->abort_requested) { + return; + } + + if (iter->second->use_microphone) { + OnRecognitionError( + session_id, media::mojom::SpeechRecognitionError( + media::mojom::SpeechRecognitionErrorCode::kNotAllowed, + media::mojom::SpeechAudioErrorDetails::kNone)); + } + + AbortSession(session_id); +} + void SpeechRecognitionManagerImpl::StopAudioCaptureForSession(int session_id) { CHECK_CURRENTLY_ON(BrowserThread::IO, base::NotFatalUntil::M159); @@ -493,9 +617,11 @@ if (iter->second->ui) { // Notify the UI that the devices are being used. iter->second->ui->OnStarted( - base::OnceClosure(), MediaStreamUI::SourceCallback(), - MediaStreamUIProxy::WindowIdCallback(), /*label=*/std::string(), - /*screen_capture_ids=*/{}, MediaStreamUI::StateChangeCallback()); + base::BindOnce(&SpeechRecognitionManagerImpl::AbortSession, + weak_factory_.GetWeakPtr(), session_id), + MediaStreamUI::SourceCallback(), MediaStreamUIProxy::WindowIdCallback(), + /*label=*/std::string(), + /*screen_share_ids=*/{}, MediaStreamUI::StateChangeCallback()); } if (SpeechRecognitionEventListener* delegate_listener = GetDelegateListener()) @@ -573,6 +699,11 @@ return; Session* session = GetSession(session_id); + if (session->has_error) { + return; + } + session->has_error = true; + LogBackendSpecificErrorOccurred(session->config, error.code); if (SpeechRecognitionEventListener* delegate_listener = GetDelegateListener()) @@ -813,14 +944,18 @@ session->recognizer = new SpeechRecognizerImplAndroid(this, session_id); #endif //! BUILDFLAG(IS_ANDROID) + const bool use_microphone = session->use_microphone; sessions_[session_id] = std::move(session); GetUIThreadTaskRunner({})->PostTask( FROM_HERE, base::BindOnce( &FrameSessionTracker::CreateObserverForSession, - config.initial_context.global_id, session_id, + config.initial_context.global_id, session_id, use_microphone, base::BindRepeating(&SpeechRecognitionManagerImpl::AbortSessionImpl, + weak_factory_.GetWeakPtr()), + base::BindRepeating(&SpeechRecognitionManagerImpl:: + AbortSessionForPermissionRevocation, weak_factory_.GetWeakPtr()))); return session_id; @@ -1077,10 +1212,8 @@ return GetSession(session_id)->config; } -SpeechRecognitionManagerImpl::Session::Session() - : id(kSessionIDInvalid), abort_requested(false) {} +SpeechRecognitionManagerImpl::Session::Session() = default; -SpeechRecognitionManagerImpl::Session::~Session() { -} +SpeechRecognitionManagerImpl::Session::~Session() = default; } // namespace content diff --git a/content/browser/speech/speech_recognition_manager_impl.h b/content/browser/speech/speech_recognition_manager_impl.h index 6d9de39..9279fd4 100644 --- a/content/browser/speech/speech_recognition_manager_impl.h +++ b/content/browser/speech/speech_recognition_manager_impl.h @@ -137,7 +137,6 @@ ~SpeechRecognitionManagerImpl() override; private: - // Data types for the internal Finite State Machine (FSM). enum FSMState { SESSION_STATE_IDLE = 0, @@ -160,8 +159,9 @@ Session(); ~Session(); - int id; - bool abort_requested; + int id = kSessionIDInvalid; + bool abort_requested = false; + bool has_error = false; SpeechRecognitionSessionConfig config; SpeechRecognitionSessionContext context; scoped_refptr<SpeechRecognizer> recognizer; @@ -171,6 +171,7 @@ }; void AbortSessionImpl(int session_id); + void AbortSessionForPermissionRevocation(int session_id); // Callback issued by the SpeechRecognitionManagerDelegate for reporting // asynchronously the result of the CheckRecognitionIsAllowed call. diff --git a/content/browser/speech/speech_recognition_browsertest.cc b/content/browser/speech/speech_recognition_browsertest.cc index 4c23229..d546bd2 100644 --- a/content/browser/speech/speech_recognition_browsertest.cc +++ b/content/browser/speech/speech_recognition_browsertest.cc @@ -22,11 +22,15 @@ #include "base/strings/stringprintf.h" #include "base/strings/utf_string_conversions.h" #include "base/task/single_thread_task_runner.h" +#include "base/test/run_until.h" +#include "base/test/test_future.h" #include "build/build_config.h" +#include "content/browser/permissions/permission_controller_impl.h" ... (file diff truncated)
Regression Test / PoC
diff --git a/content/browser/speech/speech_recognition_browsertest.cc b/content/browser/speech/speech_recognition_browsertest.cc
index 4c23229..d546bd2 100644
--- a/content/browser/speech/speech_recognition_browsertest.cc
+++ b/content/browser/speech/speech_recognition_browsertest.cc
@@ -22,11 +22,15 @@
#include "base/strings/stringprintf.h"
#include "base/strings/utf_string_conversions.h"
#include "base/task/single_thread_task_runner.h"
+#include "base/test/run_until.h"
+#include "base/test/test_future.h"
#include "build/build_config.h"
+#include "content/browser/permissions/permission_controller_impl.h"
#include "content/browser/speech/network_speech_recognition_engine_impl.h"
#include "content/browser/speech/speech_recognition_dispatcher_host.h"
#include "content/browser/speech/speech_recognition_manager_impl.h"
#include "content/browser/speech/speech_recognizer_impl.h"
+#include "content/public/browser/browser_context.h"
#include "content/public/browser/browser_task_traits.h"
#include "content/public/browser/browser_thread.h"
#include "content/public/browser/global_routing_id.h"
@@ -48,6 +52,8 @@
#include "net/test/embedded_test_server/embedded_test_server.h"
#include "testing/gmock/include/gmock/gmock.h"
#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/common/permissions/permission_utils.h"
+#include "third_party/blink/public/mojom/permissions/permission_status.mojom.h"
#if !BUILDFLAG(IS_FUCHSIA)
#include "base/test/scoped_feature_list.h"
@@ -169,11 +175,9 @@
result->hypotheses.push_back(media::mojom::SpeechRecognitionHypothesis::New(
u"Pictures of the moon", 1.0F));
proto_result->set_final(!result->is_provisional);
- for (size_t i = 0; i < result->hypotheses.size(); ++i) {
+ for (const auto& hypothesis : result->hypotheses) {
proto::SpeechRecognitionAlternative* proto_alternative =
proto_result->add_alternative();
- const media::mojom::SpeechRecognitionHypothesisPtr& hypothesis =
- result->hypotheses[i];
proto_alternative->set_confidence(hypothesis->confidence);
proto_alternative->set_transcript(base::UTF16ToUTF8(hypothesis->utterance));
}
@@ -199,7 +203,11 @@
results) override {}
void ErrorOccurred(media::mojom::SpeechRecognitionErrorPtr error) override {
+ error_code_ = error->code;
event_occurred_ = true;
+ if (error_closure_) {
+ std::move(error_closure_).Run();
+ }
if (event_closure_) {
std::move(event_closure_).Run();
}
@@ -216,10 +224,30 @@
}
}
- void AudioStarted() override {}
+ void AudioStarted() override {
+ audio_started_occurred_ = true;
+ event_occurred_ = true;
+ if (audio_started_closure_) {
+ std::move(audio_started_closure_).Run();
+ }
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
+
void SoundStarted() override {}
void SoundEnded() override {}
- void AudioEnded() override {}
+
+ void AudioEnded() override {
+ audio_ended_occurred_ = true;
+ event_occurred_ = true;
+ if (audio_ended_closure_) {
+ std::move(audio_ended_closure_).Run();
+ }
+ if (event_closure_) {
+ std::move(event_closure_).Run();
+ }
+ }
void Ended() override {
ended_occurred_ = true;
@@ -241,6 +269,15 @@
run_loop.Run();
}
+ void WaitForAudioStarted() {
+ if (audio_started_occurred_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ audio_started_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
void WaitForEnded() {
if (ended_occurred_) {
return;
@@ -250,6 +287,15 @@
run_loop.Run();
}
+ void WaitForError() {
+ if (error_code_ != media::mojom::SpeechRecognitionErrorCode::kNone) {
+ return;
+ }
+ base::RunLoop run_loop;
+ error_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
void WaitForEvent() {
if (event_occurred_) {
return;
@@ -259,6 +305,12 @@
run_loop.Run();
}
+ media::mojom::SpeechRecognitionErrorCode error_code() const {
+ return error_code_;
+ }
+ bool audio_started_occurred() const { return audio_started_occurred_; }
+ bool audio_ended_occurred() const { return audio_ended_occurred_; }
+
void OnDisconnected() {
event_occurred_ = true;
if (event_closure_) {
@@ -278,10 +330,17 @@
private:
mojo::Receiver<media::mojom::SpeechRecognitionSessionClient> receiver_{this};
base::OnceClosure started_closure_;
+ base::OnceClosure audio_started_closure_;
+ base::OnceClosure audio_ended_closure_;
base::OnceClosure ended_closure_;
+ base::OnceClosure error_closure_;
base::OnceClosure event_closure_;
+ media::mojom::SpeechRecognitionErrorCode error_code_ =
+ media::mojom::SpeechRecognitionErrorCode::kNone;
bool started_occurred_ = false;
+ bool audio_started_occurred_ = false;
+ bool audio_ended_occurred_ = false;
bool ended_occurred_ = false;
bool event_occurred_ = false;
};
@@ -366,11 +425,11 @@
ASSERT_EQ(NetworkSpeechRecognitionEngineImpl::kAudioPacketIntervalMs,
capture_packet_interval_ms);
FeedAudioCapturerSource(audio_parameters, capture_callback, 500 /* ms */,
- /*noise=*/false);
+ /*feed_with_noise=*/false);
FeedAudioCapturerSource(audio_parameters, capture_callback, 1000 /* ms */,
- /*noise=*/true);
+ /*feed_with_noise=*/true);
FeedAudioCapturerSource(audio_parameters, capture_callback, 1000 /* ms */,
- /*noise=*/false);
+ /*feed_with_noise=*/false);
}
void OnCapturerSourceStop() {
@@ -536,6 +595,338 @@
global_id));
}
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionBrowserTest,
+ PermissionRevocationStopsAudioCapture) {
+ net::test_server::ControllableHttpResponse upstream_response(
+ embedded_test_server(), "/foo/up?", /*relative_url_is_prefix=*/true);
+ net::test_server::ControllableHttpResponse downstream_response(
+ embedded_test_server(), "/foo/down?", /*relative_url_is_prefix=*/true);
+ 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 url = embedded_test_server()->GetURL("/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+ content::GlobalRenderFrameHostId global_id = rfh->GetGlobalId();
+ url::Origin origin = rfh->GetLastCommittedOrigin();
+
+ PermissionControllerImpl* permission_controller =
+ PermissionControllerImpl::FromBrowserContext(rfh->GetBrowserContext());
+ ASSERT_TRUE(permission_controller);
+
+ // Set initial microphone permission to GRANTED.
+ base::test::TestFuture<PermissionControllerImpl::OverrideStatus> grant_future;
+ permission_controller->SetPermissionOverride(
+ origin, origin, blink::PermissionType::AUDIO_CAPTURE,
+ blink::mojom::PermissionStatus::GRANTED, grant_future.GetCallback());
+ EXPECT_EQ(PermissionControllerImpl::OverrideStatus::kOverrideSet,
+ grant_future.Get());
+
+ mojo::Remote<media::mojom::SpeechRecognizer> speech_recognizer;
+ content::GetIOThreadTaskRunner({})->PostTask(
+ FROM_HERE,
+ base::BindOnce(&SpeechRecognitionDispatcherHost::Create, global_id,
+ speech_recognizer.BindNewPipeAndPassReceiver()));
+
+ MockSpeechRecognitionSessionClient client;
+ media::mojom::StartSpeechRecognitionRequestParamsPtr params =
+ media::mojom::StartSpeechRecognitionRequestParams::New();
+ params->client = client.BindNewPipeAndPassRemote();
+ mojo::Remote<media::mojom::SpeechRecognitionSession> session_remote;
+ params->session_receiver = session_remote.BindNewPipeAndPassReceiver();
+ params->continuous = true;
+
+ speech_recognizer->Start(std::move(params));
+
+ // Wait for audio capture to start.
+ client.WaitForAudioStarted();
+
+ EXPECT_EQ(1, SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
+ global_id));
+ EXPECT_EQ(kTestAudioCapturerSourceOpened, streaming_server_state());
+
+ // Revoke microphone permission by changing the setting to DENIED.
+ base::test::TestFuture<PermissionControllerImpl::OverrideStatus> deny_future;
+ permission_controller->SetPermissionOverride(
+ origin, origin, blink::PermissionType::AUDIO_CAPTURE,
+ blink::mojom::PermissionStatus::DENIED, deny_future.GetCallback());
+ EXPECT_EQ(PermissionControllerImpl::OverrideStatus::kOverrideSet,
+ deny_future.Get());
+
+ // Wait for the session to end following permission revocation.
+ client.WaitForEnded();
+
+ EXPECT_EQ(media::mojom::SpeechRecognitionErrorCode::kNotAllowed,
+ client.error_code());
+ EXPECT_EQ(kTestAudioCapturerSourceClosed, streaming_server_state());
+
+ EXPECT_TRUE(base::test::RunUntil([&]() {
+ return SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
+ global_id) == 0;
+ }));
+
+ NetworkSpeechRecognitionEngineImpl::set_web_service_base_url_for_tests(
+ nullptr);
+}
+
+IN_PROC_BROWSER_TEST_F(SpeechRecognitionBrowserTest,
+ PermissionResetStopsAudioCapture) {
+ net::test_server::ControllableHttpResponse upstream_response(
+ embedded_test_server(), "/foo/up?", /*relative_url_is_prefix=*/true);
+ net::test_server::ControllableHttpResponse downstream_response(
+ embedded_test_server(), "/foo/down?", /*relative_url_is_prefix=*/true);
+ 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 url = embedded_test_server()->GetURL("/empty.html");
+ EXPECT_TRUE(NavigateToURL(shell(), url));
+
+ RenderFrameHost* rfh = shell()->web_contents()->GetPrimaryMainFrame();
+ content::GlobalRenderFrameHostId global_id = rfh->GetGlobalId();
+ url::Origin origin = rfh->GetLastCommittedOrigin();
+
+ PermissionControllerImpl* permission_controller =
+ PermissionControllerImpl::FromBrowserContext(rfh->GetBrowserContext());
+ ASSERT_TRUE(permission_controller);
+
+ // Set initial microphone permission to GRANTED.
+ base::test::TestFuture<PermissionControllerImpl::OverrideStatus> grant_future;
+ permission_controller->SetPermissionOverride(
+ origin, origin, blink::PermissionType::AUDIO_CAPTURE,
+ blink::mojom::PermissionStatus::GRANTED, grant_future.GetCallback());
+ EXPECT_EQ(PermissionControllerImpl::OverrideStatus::kOverrideSet,
+ grant_future.Get());
+
+ mojo::Remote<media::mojom::SpeechRecognizer> speech_recognizer;
+ content::GetIOThreadTaskRunner({})->PostTask(
+ FROM_HERE,
+ base::BindOnce(&SpeechRecognitionDispatcherHost::Create, global_id,
+ speech_recognizer.BindNewPipeAndPassReceiver()));
+
+ MockSpeechRecognitionSessionClient client;
+ media::mojom::StartSpeechRecognitionRequestParamsPtr params =
+ media::mojom::StartSpeechRecognitionRequestParams::New();
+ params->client = client.BindNewPipeAndPassRemote();
+ mojo::Remote<media::mojom::SpeechRecognitionSession> session_remote;
+ params->session_receiver = session_remote.BindNewPipeAndPassReceiver();
+ params->continuous = true;
+
+ speech_recognizer->Start(std::move(params));
+
+ // Wait for audio capture to start.
+ client.WaitForAudioStarted();
+
+ EXPECT_EQ(1, SpeechRecognitionManagerImpl::GetSessionTrackerCountForTesting(
... (truncated)
Original Bug Report
An active Web Speech session returns microphone transcripts after permission is changed to Block
Report description
An active Web Speech session returns microphone transcripts after permission is changed to Block
Bug location
Where do you want to report your vulnerability?
Chrome VRP – Report security issues affecting the Chrome browser. See program rules
Which URL (or repository) have you found the vulnerability in?
https://chromium.googlesource.com/chromium/src/
The problem
Please describe the technical details of the vulnerability
I found that Chrome does not terminate an active Web Speech recognition session when the user changes the site’s Microphone permission from Allow to Block.
The document immediately observes PermissionStatus.state === “denied”, and a new SpeechRecognition.start() call is rejected with not-allowed. However, the session that was already active continues capturing new microphone input and later returns its transcript to the same page.
I reproduced this on Google Chrome Stable 151.0.7922.108 on Debian Linux x86-64.
Security impact
A malicious page that previously received explicit microphone permission can keep an already-started Web Speech session alive after the user explicitly blocks microphone access.
The page can receive transcripts of speech recorded after permission has changed to denied. This crosses the user’s revocation decision and results in disclosure of new microphone-derived information to the page.
My claim is limited to an already-open document with a prior explicit microphone grant and an active recognition session. I am not claiming invisible or indefinite recording, indicator suppression, or capture after navigation or reload.
Steps to reproduce
-
Put poc.html and server.py in the same directory.
-
Start the local server:
python3 server.py –port 8000
-
Launch a released Google Chrome build with a fresh profile:
/usr/bin/google-chrome –user-data-dir=/tmp/webspeech-repro-profile http://127.0.0.1:8000/
-
Click “Grant microphone (preflight)” and choose Allow in Chrome’s native microphone prompt.
-
The short preflight session aborts and the page navigates to a fresh same-origin document. The decisive Web Speech session starts automatically.
-
Wait until the page logs audiostart. Keep the session active for at least five seconds.
-
Open Chrome’s location/site-controls button, find the Microphone permission row, and change it from Allow to Block. Do not navigate or reload the page.
-
Confirm that the page logs permission-change with state “denied”.
-
Only after the denied event appears, speak a unique phrase that was not spoken before the Block action.
-
The page stops recognition 42 seconds after the permission change. Observe that a final result event contains speech captured after permission became denied, followed by audioend and end.
-
Five seconds later, the page automatically attempts a new SpeechRecognition start while permission is still Blocked. This new request is rejected with not-allowed.
Observed result
In my direct reproduction:
- The Web Speech session was established 31.975 seconds before permission revocation.
- Chrome’s native PageInfo UI showed Microphone as Allow / Using now.
- I changed the same switch to Block.
- The Permissions API changed to denied without navigation or reload.
- Chrome continued capturing for 42.060 seconds after denial.
- I isolated 1,345,920 bytes of post-denial PCM.
- An offline recognizer processed only the isolated post-denial segment.
- The recognizer’s exact final transcript was returned to page JavaScript.
- The session ended normally with speechend, soundend, audioend, and end.
- A fresh start while permission remained Blocked returned not-allowed and created no new speech connection.
The recorded run used Google Chrome directly. It did not use CDP, remote debugging, automation flags, fake-media Chrome flags, certificate-bypass flags, proxy flags, unsafe feature flags, or sandbox-disable flags.
For deterministic evidence, I used a controlled network-disabled environment with a locally trusted speech endpoint. The endpoint isolated the FLAC segment after receiving the page’s denied event, passed only that segment to an offline Vosk recognizer without a per-run grammar or expected transcript, returned the recognizer output as a valid Web Speech final-result message, and closed both streams normally. No production speech service was contacted.
The attached captured.flac is the exact decisive upload. Its SHA-256 is:
82dc9390f3bdedbe72d2a67e57eb4265e9448817a9399b659f1dff554706949e
The isolated audio and transcript can be independently recomputed using FFmpeg, vosk==0.3.45, the public vosk-model-small-en-us-0.15 model, and:
python3 verify_audio.py captured.flac /path/to/vosk-model-small-en-us-0.15
Expected result
Changing an origin’s Microphone permission to Block should terminate every active microphone consumer associated with that document before it captures or returns any additional speech.
The already-active Web Speech session should receive audioend/end and must not return a transcript derived from audio captured after permission became denied.
Root cause
Web Speech requests initial microphone access through the MEDIA_DEVICE_ACCESS path.
MediaStreamManager::FinalizeMediaAccessRequest() finalizes and deletes that request without installing the live permission-change subscription used by FinalizeGenerateStreams() for ordinary media streams.
As a result, the permission change updates the current permission state and prevents new recognition sessions, but it does not reach the already-active Web Speech session. That session continues into NetworkSpeechRecognitionEngineImpl::UploadAudioChunk().
Relevant source locations:
Suggested fix
Each active Web Speech microphone session should subscribe to permission changes using its immutable document identity.
Chrome should abort the active recognition session whenever microphone permission becomes anything other than granted. Permission should also be rechecked immediately before starting the audio capturer.
Impact analysis
I consider this a user-information disclosure across Chrome’s microphone-permission revocation boundary.
The attacker is an ordinary web origin that previously received explicit microphone permission and has an active SpeechRecognition session. The user then takes the defensive action of changing that origin’s Microphone permission from Allow to Block.
Chrome immediately exposes the new state as PermissionStatus.state === “denied”, and any new SpeechRecognition.start() call is rejected with not-allowed. Despite this, the existing session continues acquiring new microphone input and returns its transcript to the same page.
This is not a short buffering artifact or a startup race. In my reproduction, the session had already been active for 31.975 seconds before revocation. After the permission became denied, Chrome captured another 42.060 seconds of conservatively isolated audio. An offline recognizer processed only that post-denial segment, and its exact final transcript was delivered to page JavaScript.
A malicious page could therefore obtain transcripts of private speech occurring after the user has explicitly withdrawn microphone access. Forty-two seconds is sufficient to expose meaningful conversations or sensitive spoken information. The user’s Block action is a security decision and should terminate all existing microphone consumers, not only prevent new sessions.
The issue is limited to an already-open document with a prior grant and an active Web Speech session. I am not claiming invisible or indefinite capture, persistence after navigation/reload, or direct access to raw audio. The demonstrated impact is fresh post-revocation speech being converted into attacker-visible transcript data.
The cause
What version of Chrome have you found the security issue in?
Google Chrome Stable 151.0.7922.108
Is the security issue related to a crash?
No, it is not related to a crash.
Choose the type of vulnerability
Information Leak
How would you like to be publicly acknowledged for your report?
Cassio Lima
- http://127.0.0.1:8000/
- https://bughunters.google.com/about/rules/5745167867576320/chrome-vulnerability-reward-program-rules
- https://chromium.googlesource.com/chromium/src/
- https://chromium.googlesource.com/chromium/src/+/4744b886309d987d292e43232776d2206cccb13d/content/browser/renderer_host/media/media_stream_manager.cc#3076
- https://chromium.googlesource.com/chromium/src/+/4744b886309d987d292e43232776d2206cccb13d/content/browser/renderer_host/media/media_stream_manager.cc#3290
- https://chromium.googlesource.com/chromium/src/+/4744b886309d987d292e43232776d2206cccb13d/content/browser/speech/network_speech_recognition_engine_impl.cc#722