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 <evliu@google.com>
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 <katie@chromium.org>
Commit-Queue: Evan Liu <evliu@google.com>
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