← WebKit Silent-Fix Report — 2026-W22

35c8316c38  Audio from SpeechSynthesis may leak to the next page on cross-origin navigation

severity medium class CrossOrigin confidence 0.65 WebCore SpeechSynthesis exploitable-grade
Chris Dumez Thu May 28 05:30:39 2026 -0700 full: 35c8316c38ff4ca167e6e92cb8b1139257a1d191 bug report ↗ view on GitHub ↗
Primitive: speech audio leaks across cross-origin navigation / bfcache
Triage note: Adds ActiveDOMObject suspend/stop to stop speech; prevents cross-origin audio bleed-through.
Contents

The bug at a glance

A cross-origin information-leak / privacy defect rather than a memory-safety bug: SpeechSynthesis audio started by one origin keeps playing after the document is torn down or suspended for the back/forward cache, so it bleeds into the next, possibly cross-origin, page. No memory corruption, but audible content crossing an origin boundary is a real confidentiality and user-confusion issue, and events could fire into a suspended context. Medium is appropriate; confidence 0.65 because the fix’s intent (cancel on suspend/stop, gate events on isAllowedToRunScript) is explicit while the precise platform-synthesizer lifetime is inferred.

SpeechSynthesis is an ActiveDOMObject but never overrode suspend() or stop(), so entering the back/forward cache or destroying the execution context did not stop in-flight utterances; the platform speech synthesizer kept talking while the next page loaded. Separately, utterance event callbacks could still dispatch DOM events into a context no longer allowed to run script.

Root cause

SpeechSynthesis is registered as an ActiveDOMObject, the mechanism WebCore uses to notify DOM API objects when their ScriptExecutionContext is suspended (for example when a page enters the back/forward cache) or stopped (when the context is being destroyed on navigation). The base class provides virtual suspend(ReasonForSuspension) and stop() hooks that concrete objects override to quiesce ongoing activity. Before this patch SpeechSynthesis implemented neither, so nothing tied the lifetime of an ongoing utterance to the lifetime or active-state of the page that started it.

When a page that is currently speaking navigates away, the old document can either be destroyed or, if eligible, be frozen into the back/forward cache. In both cases the platform-level speech job that SpeechSynthesis kicked off keeps running because no suspend/stop path calls cancel(). The result is that audio synthesized on behalf of the previous origin continues to play while, and after, the next page is shown – a cross-origin audio leak: the new page’s user hears content produced by, and under the control of, the previous origin.

The fix adds SpeechSynthesis::suspend(ReasonForSuspension) and SpeechSynthesis::stop(), each of which calls cancel() when speaking() is true. suspend() covers the bfcache-freeze path and stop() covers context destruction, so an utterance is torn down at exactly the moment the page loses the right to keep producing audio.

The patch also hardens SpeechSynthesisUtterance::eventOccurred() and errorEventOccurred(): each now returns early if !isAllowedToRunScript(). These callbacks are driven by the speech backend and construct and dispatch SpeechSynthesisEvent / SpeechSynthesisErrorEvent objects. Gating them on isAllowedToRunScript() prevents utterance events from firing into a context that has been suspended or torn down, which both avoids running script at an unsafe time and stops residual events from the cancelled speech from leaking timing/state to the next page.

Key code

SpeechSynthesis.cpp: cancel speech on ActiveDOMObject suspend/stop

void SpeechSynthesis::suspend(ReasonForSuspension)
{
    if (speaking())
        cancel();
}

void SpeechSynthesis::stop()
{
    if (speaking())
        cancel();
}

Patch walkthrough

  • Source/WebCore/Modules/speech/SpeechSynthesis.cpp — Adds two ActiveDOMObject overrides. suspend(ReasonForSuspension) calls cancel() if speaking(); stop() does the same. These give the back/forward-cache suspend path and the context-teardown stop path a way to halt in-flight speech, which previously had no lifetime tie to the document.
  • Source/WebCore/Modules/speech/SpeechSynthesis.h — Declares void suspend(ReasonForSuspension) final and void stop() final in the ActiveDOMObject override section, wiring the new implementations into the ActiveDOMObject virtual dispatch.
  • Source/WebCore/Modules/speech/SpeechSynthesisUtterance.cpp — eventOccurred() and errorEventOccurred() each gain an early return if (!isAllowedToRunScript()), placed before building the SpeechSynthesisEvent(/Error) Init and dispatching, so backend-driven utterance events are not delivered into a suspended or destroyed execution context.

Background

ActiveDOMObject — Base class for DOM objects whose activity is tied to a ScriptExecutionContext. It exposes suspend(ReasonForSuspension), stop(), and virtualHasPendingActivity(), invoked by the context when it is suspended (e.g. bfcache), stopped (destroyed), or checked for GC-liveness.

Back/forward cache (bfcache) — WebKit freezes an eligible outgoing document instead of destroying it so back/forward navigation is instant. On entry the context is suspended; ActiveDOMObjects must quiesce ongoing work such as audio playback.

SpeechSynthesis / SpeechSynthesisUtterance — The Web Speech API surface: speechSynthesis.speak() queues an utterance the platform synthesizer voices; eventOccurred()/errorEventOccurred() are backend-driven callbacks that dispatch start/end/error DOM events on the utterance.

isAllowedToRunScript() — A guard indicating the associated context may currently execute script (not suspended/detached). Gating event dispatch on it prevents delivering events into a frozen or torn-down document.

Vulnerability window

  1. Baseline — SpeechSynthesis registers as an ActiveDOMObject but implements neither suspend() nor stop(), so nothing halts in-flight speech on context transitions.
  2. Trigger — A page calls speechSynthesis.speak() and then navigates; the outgoing document is suspended into bfcache or destroyed while still speaking.
  3. Leak — The platform synthesizer keeps voicing the previous origin’s utterance while the next (possibly cross-origin) page is displayed.
  4. Report — Filed as webkit.org/b/310260, rdar://171777671; reviewed by Brady Eidson.
  5. Fix — suspend()/stop() cancel() active speech and utterance event callbacks are gated on isAllowedToRunScript() (canonical 314031@main).

Proof of concept

Added as LayoutTests/fast/speechsynthesis/speech-synthesis-cancel-on-navigation.html (with matching expected.txt). It enables the mock synthesizer, calls speak() so speechSynthesis.speaking is true, then navigates to a page-cache helper so the document enters the back/forward cache. On restore (pageshow with event.persisted) it asserts speechSynthesis.speaking and .pending are both false, proving suspend() cancelled the utterance instead of letting it continue playing.

<!-- webkit-test-runner [ UsesBackForwardCache=true ] -->
<!DOCTYPE html>
<html>
<body>
<script src="../../resources/js-test-pre.js"></script>
<script>
description('Tests that speech synthesis is cancelled when the page enters the back-forward cache.');
window.jsTestIsAsync = true;

if (window.internals)
    window.internals.enableMockSpeechSynthesizer();

window.addEventListener("pageshow", function(event) {
    if (event.persisted) {
        testPassed("Page was restored from the page cache");
        shouldBeFalse("speechSynthesis.speaking");
        shouldBeFalse("speechSynthesis.pending");
        finishJSTest();
    }
});

var u = new SpeechSynthesisUtterance("This is a test utterance that should be cancelled on navigation");

// Start speaking and navigate in a setTimeout after load to ensure the page is
// fully loaded and has a proper history entry for back-forward cache eligibility.
window.addEventListener("load", function() {
    setTimeout(function() {
        speechSynthesis.speak(u);
        // The mock synthesizer fires didStartSpeaking synchronously, so speech
        // is active right after speak() returns.
        shouldBeTrue("speechSynthesis.speaking");
        window.location.href = "../history/resources/page-cache-helper.html";
    }, 0);
});
</script>
<script src="../../resources/js-test-post.js"></script>
</body>
</html>

Exploitation

  1. Start audio — A malicious or ordinary page calls speechSynthesis.speak() with an utterance whose text the origin controls.
  2. Navigate — The page triggers navigation to a cross-origin destination; the outgoing document is frozen into bfcache or destroyed while speech is still active.
  3. Bleed-through — Absent suspend/stop cancellation the platform synthesizer keeps voicing the previous origin’s text over the next page, an audible cross-origin content leak and user-spoofing vector; no memory-safety escalation. INFERRED impact from the described leak.

Detection & hunting

For defenders and SOC / detection engineers:

  • Audio after navigation
  • Events into suspended contexts

Audit directions

  • ActiveDOMObject audio/media producers
  • Backend callback event gating
  • Cross-origin resource carry-over

Before / after

Loading diff…