← All reports

[5] SpeechSynthesis teardown dispatches events inside a script-disallowed scope

MediumWebCore Web Speech APIRace

Teardown promised no script would run, then fired an event at a live listener.

5273531

Medium. Page content can schedule its own listener to run at the exact moment the engine is walking and mutating its active-object registry — a reliably reachable debug assertion and an unsanctioned script entry in release. It stays Medium because whether that walk is actually corruptible by concurrent mutation is not settled by the supplied context.

WebCore marks regions where its internal state is mid-mutation with a scope that forbids running author JavaScript, and document teardown is one of them: the engine iterates a registry of objects whose activity must be halted with their context and calls a lifecycle hook on each. ActiveDOMObject is the base class for those objects, exposing stop() and suspend() overrides the context invokes; the Web Speech API's SpeechSynthesis is one such object, owning a queue of utterances and driving a platform text-to-speech back end. The contract those hooks operate under is simple: complete without running script.

The angle: a page can hand a same-document utterance to a subframe's speechSynthesis and then remove the subframe, causing its own onerror listener to run while the subframe's context is mid-iteration over its active DOM object set.

ActiveDOMObject::stop() and suspend() are called from within ScriptExecutionContext::forEachActiveDOMObject, which holds a ScriptDisallowedScope that prohibits JavaScript from running. The previous SpeechSynthesis::stop() and suspend() implementations both called cancel(), which synchronously fires error events on every queued utterance. In a debug build this trips the ScriptDisallowedScope assertion; in a release build it silently executes JS listeners during a scope that is supposed to forbid it.

The fix introduces stopPlatformSpeech(), called by both stop() and suspend(), which clears the utterance queue and nulls m_currentSpeechUtterance without firing any events, then tells the platform/client to cancel. Any subsequent completion callbacks from the platform find m_currentSpeechUtterance null and return early in handleSpeakingCompleted().

A secondary crash was found where PlatformSpeechSynthesizerMock::cancel() calls speakingErrorOccurred() synchronously, re-entering handleSpeakingCompleted() before the caller has returned. Since stopPlatformSpeech() nulls m_currentSpeechUtterance before calling cancel, the re-entrant call arrived with a null current utterance and hit the ASSERT(m_currentSpeechUtterance) in handleSpeakingCompleted(). The assert is replaced with an early return for the null case.

An existing fuzzer-crash test is also fixed: it was previously passing only because a teardown crash happened to occur after notifyDone() was already called, masking the crash.

Source/WebCore/Modules/speech/SpeechSynthesis.cpp

void SpeechSynthesis::handleSpeakingCompleted(SpeechSynthesisUtterance& utterance, bool errorOccurred)
{
// Ignore callbacks for stale utterances. This can happen when cancel() is called
- // and a new utterance is queued before the platform's async cancel callback fires.
+ // and a new utterance is queued before the platform's async cancel callback fires, or when
+ // stopPlatformSpeech() already cleared m_currentSpeechUtterance and the platform cancel()
+ // fired this callback synchronously (e.g. the mock).
if (!m_currentSpeechUtterance || &utterance != currentSpeechUtterance())
return;
...
void SpeechSynthesis::suspend(ReasonForSuspension)
{
- if (speaking())
- cancel();
+ stopPlatformSpeech();
}
 
void SpeechSynthesis::stop()
{
- if (speaking())
- cancel();
+ stopPlatformSpeech();
+}
+
+void SpeechSynthesis::stopPlatformSpeech()
+{
+ // Called from inside ScriptExecutionContext::forEachActiveDOMObject, which holds a
+ // ScriptDisallowedScope, so we cannot fire error events here as cancel() would. Any
+ // late completion callback from the platform will see m_currentSpeechUtterance == nullptr
+ // and be ignored by handleSpeakingCompleted().
+ m_utteranceQueue.clear();
+ m_currentSpeechUtterance = nullptr;
+ m_isPaused = false;
+ if (RefPtr speechSynthesisClient = m_speechSynthesisClient.get())
+ speechSynthesisClient->cancel();
+ else if (RefPtr platformSpeechSynthesizer = m_platformSpeechSynthesizer)
+ platformSpeechSynthesizer->cancel();
}

LayoutTests/fast/speechsynthesis/speech-synthesis-stop-cross-document-utterance-crash.html

+onload = () => {
+ let frame = document.createElement('iframe');
+ document.body.appendChild(frame);
+ if (frame.contentWindow.internals)
+ frame.contentWindow.internals.enableMockSpeechSynthesizer();
+ let synth = frame.contentWindow.speechSynthesis;
+ // Utterance is created in the opener's document so its ActiveDOMObject context
+ // is still alive when the iframe's SpeechSynthesis is stopped during teardown.
+ let u = new SpeechSynthesisUtterance("test");
+ u.onerror = () => {};
+ synth.speak(u);
+ frame.remove();
+ ...
+};

Both ActiveDOMObject teardown hooks are rewritten. Previously suspend(ReasonForSuspension) and stop() each did if (speaking()) cancel();. cancel() — visible in the supplied source context and untouched by this commit — calls the platform/client cancel(), then speakingErrorOccurred(), and walks the drained m_utteranceQueue firing error/cancel events on every queued utterance, i.e. it dispatches DOM events and can therefore run author JS.

The patch replaces both bodies with a call to a new private SpeechSynthesis::stopPlatformSpeech(), declared in SpeechSynthesis.h next to handleSpeakingCompleted. It performs event-free teardown in a fixed order: m_utteranceQueue.clear(), m_currentSpeechUtterance = nullptr (a std::unique_ptr<SpeechSynthesisUtteranceActivity>, so this destroys the activity and releases its Ref<SpeechSynthesisUtterance>), m_isPaused = false, and only then speechSynthesisClient->cancel() or platformSpeechSynthesizer->cancel(). In handleSpeakingCompleted() the only change is an expanded comment; the guard if (!m_currentSpeechUtterance || &utterance != currentSpeechUtterance()) return; appears as unchanged context, so that early return already exists at this revision.

Test-side collateral: a new regression test that speaks an utterance constructed in the parent document through an iframe's speechSynthesis and then removes the iframe, plus its expectation; a rewrite of speech-synthesis-speak-fuzzer-crash.html so it enables the mock synthesizer and waits for end/error before notifyDone() instead of finishing early; and a new mac PNG baseline for that rewritten test.

Teardown callback that runs user-extensible code from inside a scope whose contract forbids re-entrancy, while the owning container is mid-iteration.

ActiveDOMObject. Base class for DOM objects whose activity must be suspended or halted with their context; it exposes suspend(ReasonForSuspension) and stop() overrides that the context invokes during suspension and teardown.

ScriptExecutionContext::forEachActiveDOMObject. The registry walk that calls those hooks on every registered active object of a context.

ScriptDisallowedScope. A RAII scope in Source/WebCore/dom/ScriptDisallowedScope.h that increments a global counter; while non-zero, isScriptAllowed() returns false and debug builds assert if script is entered. It marks regions where WebCore internal state is mid-mutation.

Re-entrancy. A point where native code calls into JavaScript, which may synchronously run and mutate C++ state before returning.

Web Speech API model. speechSynthesis.speak(utterance) appends a SpeechSynthesisUtterance to m_utteranceQueue; the head becomes m_currentSpeechUtterance — a std::unique_ptr<SpeechSynthesisUtteranceActivity> holding a Ref to the utterance — and is handed to the platform layer. The platform reports completion or failure back through didFinishSpeaking/speakingErrorOccurred, which funnel into handleSpeakingCompleted() and fire end/error events on the utterance.

Utterance realm binding. SpeechSynthesisUtterance is itself an EventTarget bound to the document that constructed it, which need not be the document whose speechSynthesis object is speaking it.

PlatformSpeechSynthesizerMock. The test back end in Source/WebCore/platform/mock/, enabled via internals.enableMockSpeechSynthesizer(), standing in for the real TTS engine in layout tests.

The bug is script re-entrancy inside a script-disallowed scope — a lifecycle-invariant violation — with a secondary null-state re-entrancy on the platform completion callback.

  iframe context teardown                    parent document (still live)
  ───────────────────────                    ────────────────────────────
  forEachActiveDOMObject
    { ScriptDisallowedScope }
      SpeechSynthesis::stop()
        cancel()
          speakingErrorOccurred() ─────────►  u.onerror listener runs
                                              (author JS, mid-iteration)
          drain m_utteranceQueue  ◄─────────  listener may mutate state here
    } scope exits

Both hooks delegated to cancel(), which fires an event per queued entry. Event dispatch reaches author-registered listeners, so the teardown path could synchronously execute JavaScript from inside a scope whose entire purpose is to forbid it. (The ScriptExecutionContext.cpp excerpt is truncated before forEachActiveDOMObject, so the caller identity and the scope it holds rest on the comment the patch itself adds plus general WebCore knowledge.)

In the common case the violation is invisible: the utterance's own ScriptExecutionContext is already dead by the time its owning document is torn down, so dispatchEvent is inert. The regression test constructs the failing shape deliberately — the utterance is created in the parent document while speak() is called on the iframe's speechSynthesis. When the iframe is removed, the iframe context's teardown walk calls stop(), cancel() fires an error event on an utterance whose context is still live, and the parent's u.onerror listener runs. The test's own comment states this rationale ("Utterance is created in the opener's document so its ActiveDOMObject context is still alive"); that the event actually reaches the parent's listener follows from the test's stated intent, since the dispatch bodies of handleSpeakingCompleted/speakingErrorOccurred are truncated in the supplied source. In debug this trips the assertion; in release the JS simply runs.

The second, ordering-related defect: any platform back end whose cancel() calls speakingErrorOccurred() synchronously re-enters handleSpeakingCompleted() before stopPlatformSpeech()/cancel() has returned. The fix defuses this by nulling m_currentSpeechUtterance before the platform call, so the re-entrant callback hits the if (!m_currentSpeechUtterance ...) return; guard rather than the assertion that used to sit there. Note that the mock supplied at this revision schedules the error via callOnMainThread, i.e. asynchronously; the synchronous-callback shape the new comment describes is not exhibited by the mock source provided here.

Discovery reads as debug-assertion triage feeding into variant analysis. The existing speech-synthesis-speak-fuzzer-crash.html name indicates a fuzzer originally found a crash in this module; that test is rewritten here to enable the mock synthesizer and wait for end/error before notifyDone(), which suggests it had been passing without exercising the completion path at all. A ScriptDisallowedScope assertion firing during stop()/suspend() points directly at cancel()'s event dispatch, and constructing the cross-document utterance case is the kind of deliberate shape an engineer builds after reasoning about when the event target's context is still alive.

This vulnerability weakens the script-execution-forbidden invariant that WebCore relies on during document teardown and context suspension. The security model assumption at stake is that ScriptDisallowedScope guarantees no author code runs while the engine walks and mutates lifecycle-critical structures — here, the active-DOM-object registry the teardown hook is being called from. Before the fix, page content could schedule its own listener to run at that exact point by handing a same-document utterance to a subframe's speechSynthesis and then removing the subframe, so the engine's "nothing can change under me" assumption held only because the event target was usually already dead. The plausible attacker gain is an attacker-chosen re-entrancy window during teardown: at minimum a reliably reachable debug assertion and an unsanctioned script entry in release builds, and if the re-entrant listener can register, destroy, or navigate objects that the in-progress teardown walk still refers to, it could escalate to state corruption in the lifecycle bookkeeping rather than a clean abort. Whether the release-build walk is actually vulnerable to concurrent mutation could not be determined — the supplied ScriptExecutionContext.cpp excerpt is truncated before the iteration function that would show how the registry is snapshotted.

This is the classic shape of a bug hiding behind its own mitigation: cancel() firing events during teardown was almost always harmless because the utterance's context died with the document, so the violation only surfaced when someone constructed the cross-document case. Any "we fire events during teardown but the targets are dead anyway" reasoning in WebCore deserves re-examination for the same escape hatch — an EventTarget whose scriptExecutionContext() differs from the module tearing it down. The rewritten fuzzer test carries the more general lesson: the old test called notifyDone() on a path that did not wait for the completion callbacks, so a teardown crash could land after the harness had already recorded a pass. Regression tests that finish before the code under test does are silent coverage holes. Forward-looking, stopPlatformSpeech() deliberately inverts the ordering cancel() uses — it releases the current-utterance ownership before calling into the platform rather than holding a stack RefPtr across the call. That ordering is what makes the re-entrant callback safe, but it also means the platform layer is called at a moment when the WebCore utterance may have lost its last reference; that trade deserves its own look.