Chrome · Speech
CVE-2026-14150
Logic Error in Speech
Overview
Low
Severity
—
CVSS
No
Exploited ITW
Fixed
Fix Status
Changed Functions
| Function | Change | Notes |
|---|---|---|
EventThunkcontent/browser/speech/speech_synthesis_impl.cc |
modified | |
is_audible_content/browser/speech/speech_synthesis_impl.cc |
modified | |
switchcontent/browser/speech/speech_synthesis_impl.cc |
modified | |
ifcontent/browser/speech/speech_synthesis_impl.cc |
modified | |
FakeTtsPlatformcontent/browser/speech/speech_synthesis_impl_browsertest.cc |
modified | |
SpeechSynthesisBrowserTestcontent/browser/speech/speech_synthesis_impl_browsertest.cc |
modified | |
MockSpeechSynthesisClientcontent/browser/speech/speech_synthesis_impl_browsertest.cc |
modified | |
BindNewPipeAndPassRemotecontent/browser/speech/speech_synthesis_impl_browsertest.cc |
modified |
Files Changed
content/browser/speech/speech_synthesis_impl.cccontent/browser/speech/speech_synthesis_impl_browsertest.cc
Patch
From 1eb42257b1f3d090cefad6d368ff22b9c9d282f6 Mon Sep 17 00:00:00 2001 From: Evan Liu <[email protected]> Date: Mon, 01 Jun 2026 13:41:25 -0700 Subject: [PATCH] Gate SpeechSynthesis audibility on non-zero volume SpeechSynthesisImpl previously unconditionally registered the frame as audible upon starting speech, regardless of volume. This allowed a compromised renderer to trigger a silent SpeechSynthesis request (volume = 0.0) to spoof tab audibility and bypass browser-side autoplay/gesture restrictions (e.g., gaining silent Wake Locks or Auto-PiP). This CL ensures SpeechSynthesis only registers as audible in the browser process if the volume is default (-1.0) or explicitly greater than 0.0. It also adds a browser integration test under content_browsertests to verify the logic. Fixed=517376041 Change-Id: I86d362ac61d1f1c3e6079529478ace06a11979ed Reviewed-on: https://chromium-review.googlesource.com/c/chromium/src/+/7882174 Reviewed-by: Katie D <[email protected]> Commit-Queue: Evan Liu <[email protected]> Cr-Commit-Position: refs/heads/main@{#1639638} --- diff --git a/content/browser/speech/speech_synthesis_impl.cc b/content/browser/speech/speech_synthesis_impl.cc index 1f07dd1..daefb651 100644 --- a/content/browser/speech/speech_synthesis_impl.cc +++ b/content/browser/speech/speech_synthesis_impl.cc @@ -21,8 +21,11 @@ class EventThunk : public UtteranceEventDelegate { public: EventThunk(mojo::PendingRemote<blink::mojom::SpeechSynthesisClient> client, - AudibleCB audible_cb) - : client_(std::move(client)), audible_cb_(std::move(audible_cb)) {} + AudibleCB audible_cb, + bool is_audible) + : client_(std::move(client)), + audible_cb_(std::move(audible_cb)), + is_audible_(is_audible) {} ~EventThunk() override = default; // UtteranceEventDelegate methods: @@ -38,7 +41,9 @@ switch (event_type) { case TTS_EVENT_START: - audible_client_ = audible_cb_.Run(); + if (is_audible_) { + audible_client_ = audible_cb_.Run(); + } client_->OnStartedSpeaking(); break; case TTS_EVENT_END: @@ -75,7 +80,9 @@ client_->OnPausedSpeaking(); break; case TTS_EVENT_RESUME: - audible_client_ = audible_cb_.Run(); + if (is_audible_) { + audible_client_ = audible_cb_.Run(); + } client_->OnResumedSpeaking(); break; } @@ -86,6 +93,7 @@ AudibleCB audible_cb_; std::unique_ptr<AudioStreamMonitor::AudibleClientRegistration> audible_client_; + bool is_audible_; }; void SendVoiceListToObserver( @@ -158,6 +166,10 @@ tts_utterance->SetContinuousParameters(utterance->rate, utterance->pitch, utterance->volume); + bool is_audible = + (utterance->volume == blink::mojom::kSpeechSynthesisDoublePrefNotSet) || + (utterance->volume > 0.0); + // See comments on EventThunk about how lifetime of this instance is managed. tts_utterance->SetEventDelegate(std::make_unique<EventThunk>( std::move(client), @@ -165,7 +177,8 @@ &AudioStreamMonitor::RegisterAudibleClient, base::Unretained(static_cast<WebContentsImpl*>(web_contents_) ->audio_stream_monitor()), - frame_id_))); + frame_id_), + is_audible)); TtsController::GetInstance()->SpeakOrEnqueue(std::move(tts_utterance)); } diff --git a/content/browser/speech/speech_synthesis_impl_browsertest.cc b/content/browser/speech/speech_synthesis_impl_browsertest.cc new file mode 100644 index 0000000..c44723b3 --- /dev/null +++ b/content/browser/speech/speech_synthesis_impl_browsertest.cc @@ -0,0 +1,220 @@ +// Copyright 2026 The Chromium Authors +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#include "base/run_loop.h" +#include "base/task/single_thread_task_runner.h" +#include "content/browser/renderer_host/render_frame_host_impl.h" +#include "content/browser/speech/tts_controller_impl.h" +#include "content/browser/web_contents/web_contents_impl.h" +#include "content/public/browser/render_frame_host.h" +#include "content/public/browser/tts_controller.h" +#include "content/public/browser/tts_platform.h" +#include "content/public/browser/web_contents.h" +#include "content/public/test/browser_test.h" +#include "content/public/test/content_browser_test.h" +#include "content/public/test/content_browser_test_utils.h" +#include "content/shell/browser/shell.h" +#include "mojo/public/cpp/bindings/receiver.h" +#include "mojo/public/cpp/bindings/remote.h" +#include "testing/gtest/include/gtest/gtest.h" +#include "third_party/blink/public/mojom/speech/speech_synthesis.mojom.h" + +namespace content { + +// A fake TTS platform implementation to simulate starting speech events in +// tests. +class FakeTtsPlatform : public TtsPlatform { + public: + FakeTtsPlatform() = default; + ~FakeTtsPlatform() = default; + + bool PlatformImplSupported() override { return true; } + bool PlatformImplInitialized() override { return true; } + void LoadBuiltInTtsEngine(BrowserContext* browser_context) override {} + + void Speak( + int utterance_id, + const std::string& utterance, + const std::string& lang, + const VoiceData& voice, + const UtteranceContinuousParameters& params, + base::OnceCallback<void(bool)> did_start_speaking_callback) override { + // Accept the speak request. + std::move(did_start_speaking_callback).Run(true); + + // Post a task to simulate the asynchronous firing of the start event from + // the OS engine. + base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask( + FROM_HERE, base::BindOnce(&FakeTtsPlatform::FireStartEvent, + base::Unretained(this), utterance_id)); + } + + bool StopSpeaking() override { return true; } + bool IsSpeaking() override { return false; } + + void GetVoices(std::vector<VoiceData>* out_voices) override { + VoiceData voice; + voice.name = "Fake Voice"; + voice.lang = "en-US"; + voice.native = true; + out_voices->push_back(voice); + } + + void Pause() override {} + void Resume() override {} + void WillSpeakUtteranceWithVoice(TtsUtterance* utterance, + const VoiceData& voice_data) override {} + std::string GetError() override { return ""; } + void ClearError() override {} + void SetError(const std::string& error) override {} + void Shutdown() override {} + void FinalizeVoiceOrdering(std::vector<VoiceData>& voices) override {} + void RefreshVoices() override {} + + private: + void FireStartEvent(int utterance_id) { + TtsController::GetInstance()->OnTtsEvent( + utterance_id, TTS_EVENT_START, /*char_index=*/0, /*length=*/0, ""); + } +}; + +class SpeechSynthesisBrowserTest : public ContentBrowserTest { + public: + SpeechSynthesisBrowserTest() = default; + ~SpeechSynthesisBrowserTest() override = default; + + void TearDownOnMainThread() override { + TtsController::GetInstance()->SetTtsPlatform(nullptr); + ContentBrowserTest::TearDownOnMainThread(); + } +}; + +class MockSpeechSynthesisClient : public blink::mojom::SpeechSynthesisClient { + public: + MockSpeechSynthesisClient() = default; + ~MockSpeechSynthesisClient() override = default; + + mojo::PendingRemote<blink::mojom::SpeechSynthesisClient> + BindNewPipeAndPassRemote() {
Loading diff…
Regression Test / PoC
shipped with the fix
diff --git a/content/browser/speech/speech_synthesis_impl_browsertest.cc b/content/browser/speech/speech_synthesis_impl_browsertest.cc
new file mode 100644
index 0000000..c44723b3
--- /dev/null
+++ b/content/browser/speech/speech_synthesis_impl_browsertest.cc
@@ -0,0 +1,220 @@
+// Copyright 2026 The Chromium Authors
+// Use of this source code is governed by a BSD-style license that can be
+// found in the LICENSE file.
+
+#include "base/run_loop.h"
+#include "base/task/single_thread_task_runner.h"
+#include "content/browser/renderer_host/render_frame_host_impl.h"
+#include "content/browser/speech/tts_controller_impl.h"
+#include "content/browser/web_contents/web_contents_impl.h"
+#include "content/public/browser/render_frame_host.h"
+#include "content/public/browser/tts_controller.h"
+#include "content/public/browser/tts_platform.h"
+#include "content/public/browser/web_contents.h"
+#include "content/public/test/browser_test.h"
+#include "content/public/test/content_browser_test.h"
+#include "content/public/test/content_browser_test_utils.h"
+#include "content/shell/browser/shell.h"
+#include "mojo/public/cpp/bindings/receiver.h"
+#include "mojo/public/cpp/bindings/remote.h"
+#include "testing/gtest/include/gtest/gtest.h"
+#include "third_party/blink/public/mojom/speech/speech_synthesis.mojom.h"
+
+namespace content {
+
+// A fake TTS platform implementation to simulate starting speech events in
+// tests.
+class FakeTtsPlatform : public TtsPlatform {
+ public:
+ FakeTtsPlatform() = default;
+ ~FakeTtsPlatform() = default;
+
+ bool PlatformImplSupported() override { return true; }
+ bool PlatformImplInitialized() override { return true; }
+ void LoadBuiltInTtsEngine(BrowserContext* browser_context) override {}
+
+ void Speak(
+ int utterance_id,
+ const std::string& utterance,
+ const std::string& lang,
+ const VoiceData& voice,
+ const UtteranceContinuousParameters& params,
+ base::OnceCallback<void(bool)> did_start_speaking_callback) override {
+ // Accept the speak request.
+ std::move(did_start_speaking_callback).Run(true);
+
+ // Post a task to simulate the asynchronous firing of the start event from
+ // the OS engine.
+ base::SingleThreadTaskRunner::GetCurrentDefault()->PostTask(
+ FROM_HERE, base::BindOnce(&FakeTtsPlatform::FireStartEvent,
+ base::Unretained(this), utterance_id));
+ }
+
+ bool StopSpeaking() override { return true; }
+ bool IsSpeaking() override { return false; }
+
+ void GetVoices(std::vector<VoiceData>* out_voices) override {
+ VoiceData voice;
+ voice.name = "Fake Voice";
+ voice.lang = "en-US";
+ voice.native = true;
+ out_voices->push_back(voice);
+ }
+
+ void Pause() override {}
+ void Resume() override {}
+ void WillSpeakUtteranceWithVoice(TtsUtterance* utterance,
+ const VoiceData& voice_data) override {}
+ std::string GetError() override { return ""; }
+ void ClearError() override {}
+ void SetError(const std::string& error) override {}
+ void Shutdown() override {}
+ void FinalizeVoiceOrdering(std::vector<VoiceData>& voices) override {}
+ void RefreshVoices() override {}
+
+ private:
+ void FireStartEvent(int utterance_id) {
+ TtsController::GetInstance()->OnTtsEvent(
+ utterance_id, TTS_EVENT_START, /*char_index=*/0, /*length=*/0, "");
+ }
+};
+
+class SpeechSynthesisBrowserTest : public ContentBrowserTest {
+ public:
+ SpeechSynthesisBrowserTest() = default;
+ ~SpeechSynthesisBrowserTest() override = default;
+
+ void TearDownOnMainThread() override {
+ TtsController::GetInstance()->SetTtsPlatform(nullptr);
+ ContentBrowserTest::TearDownOnMainThread();
+ }
+};
+
+class MockSpeechSynthesisClient : public blink::mojom::SpeechSynthesisClient {
+ public:
+ MockSpeechSynthesisClient() = default;
+ ~MockSpeechSynthesisClient() override = default;
+
+ mojo::PendingRemote<blink::mojom::SpeechSynthesisClient>
+ BindNewPipeAndPassRemote() {
+ return receiver_.BindNewPipeAndPassRemote();
+ }
+
+ // blink::mojom::SpeechSynthesisClient implementation:
+ void OnStartedSpeaking() override {
+ started_ = true;
+ if (quit_closure_) {
+ std::move(quit_closure_).Run();
+ }
+ }
+
+ void OnFinishedSpeaking(
+ blink::mojom::SpeechSynthesisErrorCode error_code) override {}
+ void OnPausedSpeaking() override {}
+ void OnResumedSpeaking() override {}
+ void OnEncounteredWordBoundary(uint32_t char_index,
+ uint32_t char_length) override {}
+ void OnEncounteredSentenceBoundary(uint32_t char_index,
+ uint32_t char_length) override {}
+ void OnEncounteredSpeakingError() override {}
+
+ void WaitForStart() {
+ if (started_) {
+ return;
+ }
+ base::RunLoop run_loop;
+ quit_closure_ = run_loop.QuitClosure();
+ run_loop.Run();
+ }
+
+ private:
+ bool started_ = false;
+ base::OnceClosure quit_closure_;
+ mojo::Receiver<blink::mojom::SpeechSynthesisClient> receiver_{this};
+};
+
+IN_PROC_BROWSER_TEST_F(SpeechSynthesisBrowserTest,
+ SilentSpeechDoesNotTriggerAudibility) {
+ // Navigate to a fake page to properly initialize the frame document and
+ // PerformanceManager node.
+ ASSERT_TRUE(NavigateToURL(shell(), GURL("about:blank")));
+
+ WebContentsImpl* web_contents =
+ static_cast<WebContentsImpl*>(shell()->web_contents());
+ RenderFrameHostImpl* rfh =
+ static_cast<RenderFrameHostImpl*>(web_contents->GetPrimaryMainFrame());
+
+ web_contents->SetAudioMuted(false);
+ web_contents->WasShown();
+
+ // Register the fake platform TTS engine.
+ FakeTtsPlatform fake_platform;
+ TtsController::GetInstance()->SetTtsPlatform(&fake_platform);
+ static_cast<TtsControllerImpl*>(TtsController::GetInstance())
+ ->SetStopSpeakingWhenHidden(false);
+
+ mojo::Remote<blink::mojom::SpeechSynthesis> speech_synthesis;
+ rfh->GetSpeechSynthesis(speech_synthesis.BindNewPipeAndPassReceiver());
+
+ ASSERT_TRUE(speech_synthesis.is_bound());
+ ASSERT_FALSE(rfh->HasTransientUserActivation());
+
+ // 1. Silent volume
+ {
+ auto utterance = blink::mojom::SpeechSynthesisUtterance::New();
+ utterance->text = "Silent";
+ utterance->lang = "en-US";
+ utterance->volume = 0.0;
+
+ MockSpeechSynthesisClient client;
+ speech_synthesis->Speak(std::move(utterance),
+ client.BindNewPipeAndPassRemote());
+ client.WaitForStart();
+
+ // Verify that silent SpeechSynthesis (volume = 0.0) does not incorrectly
+ // register the web contents as audible.
+ EXPECT_FALSE(web_contents->IsCurrentlyAudible());
+
+ TtsController::GetInstance()->Stop();
+ }
+
+ // 2. Default volume
+ {
+ auto utterance = blink::mojom::SpeechSynthesisUtterance::New();
+ utterance->text = "Default volume";
+ utterance->lang = "en-US";
+ utterance->volume = blink::mojom::kSpeechSynthesisDoublePrefNotSet;
+
+ MockSpeechSynthesisClient client;
+ speech_synthesis->Speak(std::move(utterance),
+ client.BindNewPipeAndPassRemote());
+ client.WaitForStart();
+
+ // Verify that default SpeechSynthesis correctly registers the web contents
+ // as audible.
+ EXPECT_TRUE(web_contents->IsCurrentlyAudible());
+
+ TtsController::GetInstance()->Stop();
+ }
+
+ // 3. Explicit non-zero volume
+ {
+ auto utterance = blink::mojom::SpeechSynthesisUtterance::New();
+ utterance->text = "Loud volume";
+ utterance->lang = "en-US";
+ utterance->volume = 1.0;
+
+ MockSpeechSynthesisClient client;
+ speech_synthesis->Speak(std::move(utterance),
+ client.BindNewPipeAndPassRemote());
+ client.WaitForStart();
+
+ // Verify that non-zero volume SpeechSynthesis correctly registers the web
+ // contents as audible.
+ EXPECT_TRUE(web_contents->IsCurrentlyAudible());
+
+ TtsController::GetInstance()->Stop();
+ }
+}
+
+} // namespace content
diff --git a/content/test/BUILD.gn b/content/test/BUILD.gn
index 040fb038..ea4954e 100644
--- a/content/test/BUILD.gn
+++ b/content/test/BUILD.gn
@@ -2319,6 +2319,7 @@
"../browser/font_preferences_browsertest.cc",
"../browser/renderer_host/clipboard_host_impl_browsertest.cc",
"../browser/speech/speech_recognition_browsertest.cc",
+ "../browser/speech/speech_synthesis_impl_browsertest.cc",
"../browser/speech/tts_ssml_browsertest.cc",
"../browser/storage_service_restart_browsertest.cc",
"../browser/storage_service_sandbox_browsertest.cc",
Loading diff…
Original Bug Report
The reporter's bug is still restricted on the tracker. Chrome de-restricts security bugs ~30–90 days after the fix ships; a later run will backfill it here.
References
On This Page