Skip to content

fix(core): route media through Web Audio only when the document can actually read it - #3937

Merged
miguel-heygen merged 1 commit into
mainfrom
fix/webaudio-opaque-origin
Sep 14, 2026
Merged

miguel-heygen merged 1 commit into
mainfrom
fix/webaudio-opaque-origin

Conversation

@miguel-heygen

Copy link
Copy Markdown
Collaborator

Summary

isCorsSilenced() in @hyperframes/core's Web Audio routing decides whether a media element would go silent if captured through createMediaElementSource. It compared the media URL's origin against window.location.origin, which stays URL-shaped even when the document's actual security origin is opaque (a sandboxed iframe without allow-same-origin). Inside such a frame, window.origin reads the string "null", but location.origin still reports the parent-looking URL — so same-URL-origin media without a crossorigin attribute was waved through createMediaElementSource, and per the Web Audio spec's MediaElementAudioSourceNode security section that node outputs silence without throwing. The composition kept playing — timeline advancing, visuals animating — with no audio at all.

Fix

Compare against window.origin (the document's real security origin) instead, treating the opaque value "null" as never same-origin with anything. Falls back to location.origin only if a host doesn't expose window.origin as a string at all, so behavior on every real browser is unchanged except for the opaque-document case this fixes.

Root cause only — no caller-side workaround. All five callers of classifyWebAudioMediaRoute/isCorsSilenced route through this one function, so the fix applies uniformly.

Test plan

  • New unit tests in webAudioRoute.test.ts stub the document's security origin (window.origin) independently of location.origin, matching how a sandboxed iframe actually diverges. One asserts the opaque case now falls back to the decode-only path; one asserts a genuinely same-origin document keeps today's web-audio result.
  • Confirmed the new opaque-origin test fails on the pre-fix source and passes on the fix (reverted the source, reran, restored it).
  • packages/core vitest suite: 132 files / 2774 tests passing.
  • packages/core lint (oxlint) and both typecheck targets (tsc --noEmit, typecheck:runtime) clean.

Pre-review pass

An independent reviewer (separate from the author) audited this diff before it was opened: read the full file plus every external call site, verified the new tests are non-vacuous by reverting the fix and re-running, checked the diff for absent-as-ok fallback patterns, and confirmed no comment overclaims. One should-fix finding (a host where window.origin is undefined would have widened the fallback) was applied before this PR was opened.

Status Item
Audited isCorsSilenced diff and surrounding function
Audited All external call sites of classifyWebAudioMediaRoute
Audited Non-http(s) scheme early return, unaffected by this change
Audited New tests fail on pre-fix code, pass on fixed code; worktree left clean
Audited Diff checked for absent-as-ok fallback patterns — none found
Trusting Real-browser confirmation that a sandboxed iframe without allow-same-origin reports window.origin === "null" — spec-documented, not exercised live in this review
Not exercised A host where window.origin is unsupported/undefined (now guarded, not covered by a dedicated test)

…ctually read it

isCorsSilenced() compared a media URL's origin against window.location.origin,
which stays URL-shaped even inside a sandboxed iframe without
allow-same-origin. Inside such a frame the document's actual security origin
(window.origin) is the string "null", so same-URL-origin media without a
crossorigin attribute was waved through createMediaElementSource, and per the
Web Audio spec's MediaElementAudioSourceNode security section that node
outputs silence without throwing. The composition kept playing with the
timeline advancing and no audio at all.

Compare against window.origin instead, treating "null" as never same-origin,
so an opaque document falls back to the existing decode-only path unless the
media element opts in with crossorigin.

@jrusso1020 jrusso1020 left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

APPROVE at 5e4625de on the code. The root cause is identified correctly, the fix is at the right chokepoint, and the tests are a genuine divergent pair rather than a restatement of the fix. One should-fix before this leaves draft: the diagnostic this module exists to emit gives dead-end advice in exactly the case the PR adds.

Verified independently

  • The mechanism. isCorsSilenced (webAudioRoute.ts:151-153) now reads window.origin — the document's security origin — with location.origin only as a fallback for a host that doesn't expose it as a string. Treating "null" as never-same-origin is right: an opaque origin is same-origin with nothing, so the shortcut at :152 must not fire.
  • No regression for ordinary documents. window.origin only reads "null" for an opaque origin. On a normal http(s) page it equals location.origin, so the :152 shortcut behaves exactly as before. The widening is confined to documents that were already broken.
  • A CORS opt-in still rescues the opaque case. :153 still defers to hasCorsOptIn, so an element with crossorigin in a sandboxed document keeps web-audio. Correct — an opaque document sends Origin: null, which a server answering Access-Control-Allow-Origin: null or * will satisfy.
  • file: is untouched. A file: page's media URL exits at the :146 protocol guard before any origin comparison, so the boundary the docstring describes at :131-136 still holds.
  • The chokepoint claim holds. isCorsSilenced is module-private — the only reference anywhere is this file. External consumers all enter through classifyWebAudioMediaRoute: runtime/init.ts, runtime/webAudioTransport.ts, and studio/src/components/sidebar/AudioRow.tsx. (Body says "five callers"; that's five files including this module and its test. The substance — one private predicate, uniform application — is right.) AudioRow.tsx runs in the main studio document, so it sees no behavior change.
  • The test helper is sound. withSelfOrigin captures the descriptor, redefines with configurable: true, and restores in a finally — including the delete branch for when origin was inherited rather than an own property. No cross-test leakage.

Should-fix: the diagnostic's remediation line is a no-op in the new case

reportWebAudioMediaRoute is the reason this module is a standalone classifier at all — the docstring at :18 says the whole complaint in #3458 was that nothing was said. On the path this PR adds, something is now said, and it points the wrong way.

The new test's own fixture is ${SAME_ORIGIN}/assets/vo.mp3 — an asset that is same-origin by URL. It now classifies decode-only, so reportWebAudioMediaRoute fires and emits:

  • details.note (:266): "cross-origin media without a crossorigin opt-in is silent…"
  • lostNote (:277-280): "…— proxy or download the asset to a same-origin URL to keep it."

That last sentence is the one a human acts on, and in an opaque document it cannot work: the asset is already same-origin, and no amount of proxying makes anything same-origin with an opaque origin. The two remediations that would actually work — allow-same-origin on the sandbox, or crossorigin on the element plus Access-Control-Allow-Origin: null/* from the server — are never mentioned. It lands precisely on the compositions that matter, too: lostNote is only appended when nativeUnexpressibleProcessing is non-empty, i.e. fx-chain / automation / audio-group / above-unity-gain.

To be fair to the reason token itself: cross_origin_no_cors is defensible, since from an opaque document every http(s) origin genuinely is foreign. It's the prose remediation that misleads.

Either shape works — a second reason ("opaque_document_origin") carrying its own note, or branching lostNote on whether url.origin matched location.origin. The CLI contract is safe either way: packages/cli/src/utils/checkBrowser.ts:309 matches text.startsWith("[hyperframes] runtime_web_audio_bypass") — the code prefix only, never the reason — so adding a reason value can't break the scraper. Worth doing here rather than as follow-up, because the new test currently pins the misleading reason as expected output, which will ossify it.

Status, not findings

  • Still isDraft: true, so nothing merges on this approval regardless.
  • regression-shards (shard-3 … audio-mux-parity …) has since finished green (8m22s) — that's the shard worth caring about for an audio-routing change. Shards 4/5/7/8 and Tests on windows-latest: studio-engine-cli were still pending when I looked; none of them cover audio mux.

— Rames

@jrusso1020

Copy link
Copy Markdown
Collaborator

Sharpening one line in my review: I offered the second-reason option and the branch-the-prose option as equivalent. They aren't — prefer the distinct reason (opaque_document_origin).

The reason it matters is the thing that makes the prose wrong in the first place: the new opaque test asserts reason: "cross_origin_no_cors", the same token a genuinely foreign-origin asset gets. So the two cases are already indistinguishable at the classifier's output, and details.reason is what goes over the bridge in postRuntimeMessage — not just into the console string. Branching only lostNote fixes what a human reads while leaving the structured verdict conflated; a distinct reason puts the opaque-vs-foreign distinction in the output itself, and the new test then pins the correct remediation instead of locking in the conflated one.

Still safe for the CLI either way, and now checked rather than assumed: checkBrowser.test.ts:447-453 exercises the whole console line as a fixture rather than parsing the reason, and its asset is https://cdn.example.com/track.mp3 — genuinely cross-origin, so that expectation is unaffected by adding a second reason value.

(Credit to Somu's read in the Lane C thread for the push on this one — I'd left the two options as a coin flip.)

— Rames

@miguel-heygen
miguel-heygen marked this pull request as ready for review September 14, 2026 16:32
@miguel-heygen
miguel-heygen merged commit 293829a into main Sep 14, 2026
60 checks passed
@miguel-heygen
miguel-heygen deleted the fix/webaudio-opaque-origin branch September 14, 2026 16:32
jrusso1020 added a commit that referenced this pull request Sep 14, 2026
…back (#3940)

The opaque-origin fix in #3937 routed correctly but sent the diagnostic down
a dead end. An opaque document reached the same `cross_origin_no_cors` verdict
a genuinely foreign asset gets, so the message told the author to "proxy or
download the asset to a same-origin URL". That is impossible advice, because
nothing is same-origin with an opaque origin and the asset is usually
same-origin by URL already.

Split the verdict into two reasons and give each its own note and remedy. The
opaque case now names the two fixes that work: `allow-same-origin` on the
sandbox, or `crossorigin` plus an `Access-Control-Allow-Origin` of `null` or
`*`. Opacity wins when both conditions hold, since it is the blocking one and
the one an author cannot fix by moving the asset.

The CLI scraper matches on the code prefix only (checkBrowser.ts:309), so the
new reason value does not touch that contract.

- Rames
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants