[5] SpeechSynthesis teardown dispatches events inside a script-disallowed scope
Teardown promised no script would run, then fired an event at a live listener.
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.
Commit message
ActiveDOMObject::stop()andsuspend()are called from withinScriptExecutionContext::forEachActiveDOMObject, which holds aScriptDisallowedScopethat prohibits JavaScript from running. The previousSpeechSynthesis::stop()andsuspend()implementations both calledcancel(), which synchronously fires error events on every queued utterance. In a debug build this trips theScriptDisallowedScopeassertion; in a release build it silently executes JS listeners during a scope that is supposed to forbid it.The fix introduces
stopPlatformSpeech(), called by bothstop()andsuspend(), which clears the utterance queue and nullsm_currentSpeechUtterancewithout firing any events, then tells the platform/client to cancel. Any subsequent completion callbacks from the platform findm_currentSpeechUtterancenull and return early inhandleSpeakingCompleted().A secondary crash was found where
PlatformSpeechSynthesizerMock::cancel()callsspeakingErrorOccurred()synchronously, re-enteringhandleSpeakingCompleted()before the caller has returned. SincestopPlatformSpeech()nullsm_currentSpeechUtterancebefore calling cancel, the re-entrant call arrived with a null current utterance and hit theASSERT(m_currentSpeechUtterance)inhandleSpeakingCompleted(). 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
LayoutTests/fast/speechsynthesis/speech-synthesis-stop-cross-document-utterance-crash.html
Patch Details
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.
Background
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.
Analysis
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.
Insight
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.
Audit directions
-
Teardown/suspension callbacks that run user-extensible code from inside a scope declaring that no such code may run. A lifecycle hook invoked by a container mid-iteration must be event-free, and the invariant is hard to hold because event dispatch is usually several frames deep and looks inert in the common case. Narrow: grep
Source/WebCore/ModulesandSource/WebCore/htmlforvoid stop()/suspend(ReasonForSuspension)overrides ofActiveDOMObjectand check whether any transitively reachdispatchEvent,queueTaskToDispatchEvent, aDeferredPromiseresolution, or a JS callback — match tell: astop()/suspend()body that delegates to the same method the public JS-facing API calls (herecancel()), because the public method is written to fire events. Wider: the same class shows up wherever an engine walks a registry under a no-mutation guard and calls into participants — style/layout recalculation invokingResizeObserver/IntersectionObserverdelivery, IDB transaction abort paths, media element teardown; the match tell in code-search results is any function taking a callback-per-element over a member container while a scope guard is on the stack. Widest: never re-enter untrusted or extensible code while holding an iterator into a container you own — it recurs as ConcurrentModificationException in Java collections,RefCelldouble-borrow panics in Rust observer lists, and listener-list mutation during dispatch in Node's EventEmitter; the tell to carry across codebases is "who can register a callback that reaches this loop, and can that callback add or remove elements?" -
Clear-then-notify versus notify-then-clear ordering around calls into a lower layer that may call back synchronously. All state a re-entrant callback will inspect must reach its terminal value before the outbound call, and this breaks when a back end's sync/async delivery contract is documented only by whichever implementation the author happened to test against. Narrow: audit the other
SpeechSynthesispaths that call out tom_speechSynthesisClient/m_platformSpeechSynthesizer—cancel(),pause(),resumeSynthesis(),startSpeakingImmediately()— and check each for state mutated after the outbound call; match tell: a member assignment textually below a->cancel()/->speak()/->pause()call in the same function. Wider: the same class covers every WebCore module pairing a mock back end with a real one — comparePlatformSpeechSynthesizerMock(which here usescallOnMainThread, i.e. async) against the production Cocoa synthesizer and againstSpeechSynthesisClientimplementations in WebKit2, and do the same for media, WebRTC, and geolocation mocks; match tell: a mock that invokesclient().someDidHappen(...)on the same stack as the method being mocked while the real back end posts. Widest: a callback interface whose sync-versus-async delivery is not enforced by the type system will eventually be implemented both ways — applies to any codebase with completion handlers (Chromium'sbase::OnceCallbackposted versus run-inline, Rust futures that complete on poll, Node APIs that sometimes call back before returning); the audit question to carry is "if this callback fired before my call returned, what state would it observe?" -
Cross-document object graphs where module A's teardown fires events into document B's still-live context. Destroying a context should stop all script it could cause to run, and cross-document object sharing breaks that because the event target's context is not the module's context. Narrow: audit WebCore APIs that accept a DOM object argument without checking its
scriptExecutionContext()against their own —SpeechSynthesis::speak(SpeechSynthesisUtterance&)is the instance here; match tell is anyEventTarget-derived parameter stored into a member queue with no same-document check at entry. Wider: the same shape applies anywhere an object constructed by one realm is retained by another realm's controller — Web Animations effects,MediaStreamTrackhanded across frames,AbortSignalpassed into a cross-frame API; match tell in code-search: a memberDeque/VectorofRef<SomeEventTarget>in anActiveDOMObjectwhose own teardown iterates and dispatches. Widest: lifetime scopes must be closed under reachability — any system with per-tenant or per-session teardown (DI container scopes, actor supervision trees, request-scoped caches holding objects owned by a longer-lived scope) has the same failure mode; the carry-across tell is an object whose lifetime is governed by scope A sitting in a collection drained by scope B's shutdown. -
Verify whether releasing
m_currentSpeechUtterancebefore the outbound platformcancel()can drop the last reference to theSpeechSynthesisUtterancewhilePlatformSpeechSynthesisUtterancestill holds a client back-pointer. Start atSource/WebCore/Modules/speech/SpeechSynthesisUtterance.cppandSource/WebCore/platform/PlatformSpeechSynthesisUtterance.h: determine whether the platform utterance's client reference is aWeakPtr, a raw pointer, or refcounted, then tracePlatformSpeechSynthesizerMock::cancel()'s deferredclient().speakingErrorOccurred(*utterance)and the Cocoa equivalent. Match tell: a raw or non-zeroing back-pointer from the platform object to the WebCore object combined with any teardown path that drops the WebCore side first. The same asymmetry is worth checking for every WebCore/platform object pair where the platform half is independently refcounted and outlives its WebCore owner.