fix(core): prevent cross-origin Web Audio capture from silencing audio - #3481
fix(core): prevent cross-origin Web Audio capture from silencing audio#3481miga-heygen wants to merge 2 commits into
Conversation
Classify each <audio> element before Web Audio capture: same-origin, CORS-opted-in, or a non-http(s) scheme stays on the primary createMediaElementSource() path; cross-origin media without a crossorigin opt-in withholds that call (the Web Audio spec makes such a node output silence without throwing) and falls back to fetch + decodeAudioData, preserving the FX graph whenever the server allows CORS. Recheck the route at the transport's irreversible capture boundary, and account for currentSrc, src, and <source> candidates the same way the HTML resource-selection algorithm does. Emit a stable preview diagnostic (`runtime_web_audio_bypass`) at media discovery time, not only from playback scheduling, so `hyperframes check` — which seeks but never plays — can surface it as a `web_audio_bypass` finding. Diagnostics are suppressed during export rendering, where the producer mixes audio offline and already applies the FX chain. The existing non-unit-rate fail-closed rule stays scoped to fx-chain/automation so this fix does not newly mute grouped or above-unity tracks. Takes over #3459 with the data-native-audio escape hatch removed per review feedback: the automatic cross-origin detection already covers the cases that mattered, so the extra per-element opt-in attribute, its route-classifier branch, and its diagnostic path are dropped in favor of a single automatic behavior. Fixes #3458 Original-Author: desenmeng Co-Authored-By: desenmeng <desenmeng@users.noreply.github.com> Co-Authored-By: Miga <noreply@anthropic.com>
miguel-heygen
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES
Reasoning: The guard only protects an element before its first MediaElementAudioSourceNode is created. acquireMediaElementSource() returns the cached node before reclassifying the element. If a reused <audio> node first plays a same-origin source and later its src or <source> changes to cross-origin without CORS, direct callers reconnect that cached node and get the exact spec-mandated silence this PR fixes. The runtime caller classifies first and skips the transport, but the element was already permanently rerouted by the cached node; if decode fails, its claimed native fallback is still silent. Dynamic source updates on an existing DOM node therefore remain broken.
Please cover the same-element same-origin-to-cross-origin transition and make the route safe after a node has already been created (or explicitly replace or recreate the media element before native fallback). The current tests only exercise fresh elements, so they cannot see the one-way cached-node case. No merge action.
— Magi
terencecho
left a comment
There was a problem hiding this comment.
Concur with @magi-bot CHANGES_REQUESTED at cce17da5. The cached MediaElementAudioSourceNode reuse is the primary blocker. Adding orthogonal concerns.
Concur with @magi-bot (verified at head):
WebAudioTransport.acquireMediaElementSource(packages/core/src/runtime/webAudioTransport.ts:263-273) returns_mediaElementSources.get(el)before the classifier runs. Grepped:_mediaElementSourcesis only invalidated indestroy()(line 753) — noemptied/abort/loadedmetadata-driven cache clear. Same-origin → cross-origin src mutation permanently holds the cached node; the element's native output stays hijacked per WebAudio spec; the classifier's "native fallback" is spec-impossible for that element.decodeAudioElementbecomes the ONLY working audio; if decode fails the element is silent — the same failure #3458 is meant to fix, now blessed by the runtime.- No test exercises reused elements (
init.test.ts/webAudioRoute.test.tsconstruct fresh per case).
Additional (differentiated):
-
Bind-time false-positive diagnostic on
<source>fallback selection.init.ts:1871-1890callsreportWebAudioRoute(mediaEl)synchronously at bind.webAudioRoute.ts:801-813routeCandidates(): when bothcurrentSrcandsrcattr are empty, walks<source>children and returns "decode-only" on the FIRST cross-origin URL. Scenario:<audio><source src="https://cdn.example.com/a.mp3"><source src="/assets/fallback.mp3"></audio>. Bind fires with no committed resource → conservative decode-only verdict →reportWebAudioMediaRouteemits[hyperframes] runtime_web_audio_bypass+ latchesdiagnosedElements.add(el). Laterloadedmetadatafires withcurrentSrc = /assets/fallback.mp3(same-origin, browser-selected). Classifier now returns web-audio, no report. But the false-positive diagnostic already fired and the CLI check gate reports a phantom bypass. Fix: emit only from theloadedmetadatahandler, or only latch aftercurrentSrcis set. -
hasCorsOptInsecondary IDL check is over-permissive.webAudioRoute.ts:78-82:if (hasAttr(el, "crossorigin")) return true; return typeof el.crossOrigin === "string";
In Chromium/Firefox/Safari,
el.crossOriginfor an element with no attribute returnsnull(typeof"object"), so the fallback is inert. But in jsdom variants (and any host that returns""for absent-attribute IDL), the fallback returnstruefor ALL cross-origin audio, silently disabling the entire guard in test envs. A genuine IDL-setel.crossOrigin = "anonymous"reflects to the attribute → primary check catches it. Recommendreturn typeof el.crossOrigin === "string" && el.crossOrigin.length > 0— the empty-string fallback buys nothing and risks fail-open. -
Comment overstates enforcement reach.
webAudioTransport.tsdocstring: "init.ts routes on the same verdict before ever calling in; this stays the enforcement point so a direct caller (studio, player) cannot reopen the one-way door." Butpackages/studio/src/components/sidebar/AudioRow.tsx:149callsactxRef.current.createMediaElementSource(el)DIRECTLY, bypassingWebAudioTransportentirely. Same-originserveUrlin dev makes this benign today, but the enforcement claim is factually wrong. Either correct the comment or route AudioRow through the transport. -
srcObject/ MediaStream binding unclassified.routeCandidatesonly walkscurrentSrc/src/<source>.el.srcObject = mediaStreamleaves all three empty → classifier returns web-audio → MediaElementSource proceeds. For MediaStream, spec-fine. For MediaSource attached via srcObject (streaming HLS/DASH from a cross-origin CDN), MediaElementSource may still be silenced. Low prevalence in current HF compositions; document in the module preamble. -
Redirect chains bypass classification.
isCorsSilenced(webAudioRoute.ts:822-833) judges the raw URL string, not the resolved fetch.<audio src="/proxy/track.mp3">where/proxy302s tohttps://cdn.example.com/track.mp3→ same-origin verdict → capture proceeds → spec silences. Fundamental gap without a HEAD probe or fetch-hook; document.
Softer-floor suggestion on @magi-bot's remediation: since the DOM <audio> node is composition-owned (not runtime-owned), a hard "explicitly recreate the element" is expensive. Softer: (a) listen for emptied/loadstart on bound media, _mediaElementSources.delete(el) + disconnect the stale node before new src loads; (b) if verdict flips to decode-only on a cached element, log a distinct DIAGNOSTIC_CACHE_POISONED code — since native recovery is spec-impossible, at minimum the failure has to be OBSERVABLE (matches the whole point of this PR).
— Review by tai (pr-review)
…gin, document gaps
vanceingalls
left a comment
There was a problem hiding this comment.
Verdict: REQUEST_CHANGES at acc6898
R2 delta verification (base cce17da5 → head acc6898).
Addressed cleanly (4/4 R2 claims verified):
- Bind-time false-positive (tai#1).
isRouteSelectionSettled()gate added inwebAudioRoute.ts;init.ts:1847-1852skips the discovery-time report when bothcurrentSrcis empty andsrcattr is absent. Theloadedmetadatalistener still fires with a settledcurrentSrcand callsreportWebAudioMediaRoute(latched per-element, so single report). No race: gate + classify are both synchronous with no microtask boundary between them, androuteCandidatesreads the samecurrentSrc/srcthe gate just read. hasCorsOptIn(tai#2). Reduced toel.crossOrigin != null. Two new regression tests viawithUnreflectedCrossOrigin(Object.defineProperty) cover both directions. Miga's!= nullcorrectly respects<audio crossorigin>— the bare-attribute anonymous opt-in whose IDL fallback is"". tai's suggested.length > 0would have wrongly rejected that valid opt-in; Miga's trade-off is more spec-faithful. Untouched element (crossOrigin === null) still classifies as no-opt-in.- AudioRow bypass (tai#3).
AudioRow.tsx:172now setsel.src = serveUrlBEFORE the classifier read (necessary — classifier readscurrentSrc/src), then gatescreateMediaElementSource(el)behindclassifyWebAudioMediaRoute(el).kind === "web-audio". Falls through to native<audio>playback (no visualizer) when the verdict blocks capture. Grep confirms AudioRow was the only other directcreateMediaElementSourcecall site outside the transport (the hit inpackages/lint/src/rules/media.tsis a rule reference, not a call). Public subpath@hyperframes/core/runtime/web-audio-routewired viapackage-subpaths.json+package.jsonexports. - srcObject (tai#4). Documented in
webAudioRoute.tsmodule docstring as "recorded as a boundary rather than fixed" — defensible: no current codepath feedscreateMediaElementSourcefrom asrcObjectelement.
BLOCKER unresolved — Magi's R1 primary finding:
WebAudioTransport.acquireMediaElementSource (webAudioTransport.ts:262-274) is untouched in this delta:
private acquireMediaElementSource(el) {
const cached = this._mediaElementSources.get(el);
if (cached) return cached; // ← returns before reclassifying
...
const route = classifyWebAudioMediaRoute(el);
if (route.kind !== "web-audio") { reportWebAudioMediaRoute(el, route); return null; }
const sourceNode = this._ctx.createMediaElementSource(el);
this._mediaElementSources.set(el, sourceNode);
return sourceNode;
}
_mediaElementSources is still only cleared in destroy() (line 753) — no emptied / loadstart / abort-driven eviction. The same-element same-origin-then-cross-origin src mutation Magi described therefore remains broken: the cached MediaElementAudioSourceNode created against the original same-origin src is returned on the reused-element path, permanently rerouting the element's native output per the Web Audio spec even though a fresh classify would now say decode-only.
The AudioRow classifier gate does not cover this case — that fix protects the studio preview player, not runtime element reuse in the transport. And no transport-level test exercises reused elements: webAudioTransport.test.ts:167 covers a fresh cross-origin element only, and line 150's "cached native source reusable" test is same-origin throughout.
Also unresolved (soft):
- tai#5 (redirect chains).
isCorsSilencedstill judges the raw URL string; not documented in the delta. webAudioTransport.tsdocstring onacquireMediaElementSourcestill claims to be "the enforcement point so a direct caller (studio, player) cannot reopen the one-way door." AudioRow (a studio direct caller) demonstrates it is one of two co-equal enforcement points; the softened claim inwebAudioRoute.tsacknowledges this, but the transport's own docstring does not. Minor.
Minimum ask:
Either (a) invalidate _mediaElementSources on emptied / loadstart and disconnect the stale node before the next classify, or (b) if out of scope, document the cached-node hazard in the module docstring alongside the srcObject boundary and add a distinct DIAGNOSTIC_CACHE_POISONED observable code so failures are at least surfaced (per tai's soft-floor suggestion). Either resolves Magi's block; leaving it silent-and-undocumented does not.
CI at time of review: Producer unit tests SUCCESS, Lint SUCCESS, Fallow audit SUCCESS, Typecheck in progress; regression + windows-render + player-perf shards still running. No merge action.
— Via
terencecho
left a comment
There was a problem hiding this comment.
R2 at head acc68982 after Miga's fix-up. Items 1, 3, 4 + @magi-bot's cached-node reuse are addressed well; Item 5 (redirect chains) is explicitly deferred (acceptable). One residual defense-in-depth gap on the transport worth flagging, but no code-level blocker at head.
Retraction on my R1 Item 2 — I was wrong.
My R1 recommendation to tighten hasCorsOptIn to typeof el.crossOrigin === "string" && el.crossOrigin.length > 0 is spec-wrong. Per HTML spec crossorigin is an enumerated attribute whose invalid-value default is anonymous; therefore crossorigin="" IS a valid opt-in equivalent to anonymous, and its IDL fallback reads as "". Miga's != null at webAudioRoute.ts:80-83 is the correct predicate. My length > 0 suggestion would have falsely rejected legitimate opt-ins for hosts that expose the value only via the IDL property (not reflected to the attribute), reintroducing the silent-audio bug this PR is meant to fix. The module comment at webAudioRoute.ts:61-79 calls this out explicitly, and the tests at webAudioRoute.test.ts:60-68, 114-138 assert both the reflected-attribute path and the unreflected empty-IDL path correctly. Withdrawing the R1 recommendation.
Concur on the fixes (verified at head):
- Item 1 (bind-time false-positive) —
isRouteSelectionSettled(el)atwebAudioRoute.ts:150-154returns true only whencurrentSrcorsrcattr is set.init.ts:1845-1855's discovery-timereportWebAudioRouteskips when unsettled;init.ts:1898bindsloadedmetadatafor the deferred report. Latch inreportWebAudioMediaRoute(webAudioRoute.ts:238-240) only fires on non-web-audio verdicts, so the early-skip does not consume the latch. Edge case: if<source>selection genuinely never settles (all candidates fail to load), discovery-time won't fire, BUT the schedule path atinit.ts:3170-3171still callsreportWebAudioMediaRoute(rawEl, route)when the classifier's<source>-walk produces a decode-only verdict — so an authored composition that ever tries to schedule the audio still emits. Acceptable trade-off. - Item 3 (AudioRow bypass) —
AudioRow.tsx:174setsel.src = serveUrlbefore classifying;:184callsclassifyWebAudioMediaRoute(el)and only wirescreateMediaElementSourceon.kind === "web-audio". Non-web-audio verdict is a graceful no-op: analyser/visualizer bar skipped, but native<audio>playback below (:194 audioRef.current.play()) still runs. New public subpath@hyperframes/core/runtime/web-audio-routecorrectly declared inpackages/core/package-subpaths.json(import, browser export, CommonJS export all present). - Item 4 (srcObject / MediaStream) — module docstring at
webAudioRoute.ts:30-36is explicit and useful: "asrcObjectelement always reads asweb-audiohere, correctly or not. Nothing in this codebase feedscreateMediaElementSourcefrom asrcObjectelement today, so this is recorded as a boundary rather than fixed." Documented deferral is fine. - @magi-bot's cached-node reuse — closed at the caller side.
init.ts:3170callsclassifyWebAudioMediaRoute(rawEl)fresh on every schedule invocation and only invokesscheduleMediaElementPlaybackon.kind === "web-audio", so a src flip from same-origin (cached node exists) to cross-origin can no longer trigger the poisoned-cache path via the timeline scheduler.
One residual defense-in-depth gap (non-blocking, scope call):
WebAudioTransport.acquireMediaElementSource at webAudioTransport.ts:262-274 still returns this._mediaElementSources.get(el) unconditionally when cached, WITHOUT re-classifying — the classifyWebAudioMediaRoute call at :266 only runs on cache miss. So the transport's docstring at :258-260 ("this stays the enforcement point so a direct caller … cannot reopen the one-way door") overclaims: the enforcement only holds on FIRST bind. init.ts:3170's pre-classify closes the observed path today, but any future direct caller of acquireMediaElementSource (or a refactor of init.ts that stops pre-classifying) touching an element whose src was flipped after first cache would hand back a stale silent node.
Two ways to close it:
- (a) Invalidate the
_mediaElementSourcesentry when src changes — element listener foremptied/loadstartor comparingcurrentSrcagainst the value cached at first bind. - (b) Move the
classifyWebAudioMediaRoutecheck ahead of the cache return insideacquireMediaElementSource— reject cached nodes for elements whose current verdict is no longer web-audio.
Scope call — happy to see either in this PR or a follow-up.
Minor:
- Redirect-chain boundary (Item 5) is deferred but not documented in
isCorsSilenced— a parallel doc line matching the srcObject boundary note (webAudioRoute.ts:30-36) would be honest. - Transport docstring at
:258-260still says "enforcement point"; Miga's Slack described "softened to caller contract," but that exact phrase isn't in the code. Downstream of the defense-in-depth call above — if you close the cached-node re-classify, the "enforcement point" claim becomes literally true and the comment can stay.
CI status at head: MANY required checks still IN_PROGRESS (Build, CLI smoke, Typecheck aggregate, Tests, Tests on windows-latest, all 9 regression-shards, Render on windows, Preview parity, Producer integration, Perf suite). Not stamping until CI settles.
Test coverage on the new work is strong — webAudioRoute.test.ts has 12 tests including both crossorigin-attr-reflection paths and the <source> walk. Good.
— Review by tai (pr-review)
Summary
<audio>elements that would be silently muted by Web Audio's CORS policyhyperframes checkTakes over #3459 with the
data-native-audioescape hatch removed per review feedback — automatic detection covers the use cases.Closes #3458
Original-Author: desenmeng
Co-Authored-By: desenmeng desenmeng@users.noreply.github.com
Co-Authored-By: Miga noreply@anthropic.com