From 8886743f447176094aaee7d9ac0570fe735e10ff Mon Sep 17 00:00:00 2001 From: James Russo Date: Mon, 14 Sep 2026 11:56:45 -0700 Subject: [PATCH] fix(core): tell an opaque document how to actually get its Web Audio 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 --- .../core/src/runtime/webAudioRoute.test.ts | 62 +++++++++++-- packages/core/src/runtime/webAudioRoute.ts | 92 +++++++++++++++---- 2 files changed, 131 insertions(+), 23 deletions(-) diff --git a/packages/core/src/runtime/webAudioRoute.test.ts b/packages/core/src/runtime/webAudioRoute.test.ts index f9c072c8cf..80da214b1c 100644 --- a/packages/core/src/runtime/webAudioRoute.test.ts +++ b/packages/core/src/runtime/webAudioRoute.test.ts @@ -48,6 +48,13 @@ function withSelfOrigin(origin: string, run: () => T): T { } } +/** The one console line `reportWebAudioMediaRoute` emits for `el`, or "". */ +function bypassLine(el: HTMLAudioElement): string { + const info = vi.spyOn(console, "info").mockImplementation(() => {}); + reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el)); + return (info.mock.calls[0]?.[0] as string) ?? ""; +} + describe("classifyWebAudioMediaRoute", () => { it("routes same-origin media through Web Audio", () => { expect(classifyWebAudioMediaRoute(audio({ src: "/assets/vo.mp3" }))).toEqual({ @@ -156,7 +163,23 @@ describe("classifyWebAudioMediaRoute", () => { const asset = `${SAME_ORIGIN}/assets/vo.mp3`; const route = withSelfOrigin("null", () => classifyWebAudioMediaRoute(audio({ src: asset }))); - expect(route).toEqual({ kind: "decode-only", reason: "cross_origin_no_cors", asset }); + expect(route).toEqual({ kind: "decode-only", reason: "opaque_document_origin", asset }); + }); + + it("reports opacity, not foreignness, when the document is opaque AND the asset is remote", () => { + // Both conditions hold. Opacity is the blocking one and the only one the + // author cannot fix by moving the asset, so it must win the reason. + const asset = `${CROSS_ORIGIN}/track.mp3`; + const route = withSelfOrigin("null", () => classifyWebAudioMediaRoute(audio({ src: asset }))); + + expect(route).toEqual({ kind: "decode-only", reason: "opaque_document_origin", asset }); + }); + + it("still lets a crossorigin opt-in rescue an opaque document", () => { + const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, crossorigin: "anonymous" }); + const route = withSelfOrigin("null", () => classifyWebAudioMediaRoute(el)); + + expect(route).toEqual({ kind: "web-audio" }); }); it("keeps today's same-origin result when window.origin genuinely matches", () => { @@ -256,13 +279,40 @@ describe("reportWebAudioMediaRoute", () => { expect(info).not.toHaveBeenCalled(); }); - it("names the dropped processing in the CORS bypass line", () => { - const info = vi.spyOn(console, "info").mockImplementation(() => {}); - const el = audio({ src: `${CROSS_ORIGIN}/track.mp3`, "data-audio-group": "vo" }); + it("names the dropped processing, and offers a proxy, when a same-origin URL could exist", () => { + const line = bypassLine(audio({ src: `${CROSS_ORIGIN}/track.mp3`, "data-audio-group": "vo" })); - reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el)); + expect(line).toContain("audio-group"); + expect(line).toContain("proxy or download the asset to a same-origin URL"); + }); - expect(info.mock.calls[0]?.[0]).toContain("audio-group"); + it("tells an opaque document to fix the sandbox, not to move the asset", () => { + // The whole point of the second reason. Nothing is same-origin with an + // opaque origin, so the proxy advice names a fix that cannot exist here. + const el = audio({ src: `${SAME_ORIGIN}/assets/vo.mp3`, "data-audio-group": "vo" }); + + const line = withSelfOrigin("null", () => bypassLine(el)); + + expect(line).toContain("audio-group"); + expect(line).toContain("allow-same-origin"); + expect(line).toContain("Access-Control-Allow-Origin"); + expect(line).not.toContain("proxy or download"); + }); + + it("puts the opaque reason and its note on the bridge message, not just the console", () => { + const posted: unknown[] = []; + const post = vi + .spyOn(window.parent, "postMessage") + .mockImplementation((message: unknown) => void posted.push(message)); + vi.spyOn(console, "info").mockImplementation(() => {}); + const el = audio({ src: `${SAME_ORIGIN}/assets/vo.mp3` }); + + withSelfOrigin("null", () => reportWebAudioMediaRoute(el, classifyWebAudioMediaRoute(el))); + + const details = (posted[0] as { details?: Record })?.details ?? {}; + expect(details.reason).toBe("opaque_document_origin"); + expect(String(details.note)).toContain("opaque"); + post.mockRestore(); }); describe("render mode", () => { diff --git a/packages/core/src/runtime/webAudioRoute.ts b/packages/core/src/runtime/webAudioRoute.ts index 9c1252f09c..3459a4f82b 100644 --- a/packages/core/src/runtime/webAudioRoute.ts +++ b/packages/core/src/runtime/webAudioRoute.ts @@ -35,7 +35,7 @@ import type { RuntimeJson } from "./types"; * `createMediaElementSource` from a `srcObject` element today, so this is * recorded as a boundary rather than fixed. * - * Second known gap, same shape: `isCorsSilenced` judges the RAW url string — + * Second known gap, same shape: `corsSilenceReason` judges the RAW url string — * the same-origin URL the author wrote, or whatever the browser resolved into * `currentSrc` — not wherever a server-side redirect chain actually lands. * A same-origin URL that 302s to a cross-origin CDN reads as `web-audio` here @@ -60,7 +60,18 @@ export type WebAudioMediaRoute = * attribute is the common shape of this bug — and that route keeps the whole * FX graph. So: withhold the node, still let the decode path try. */ - | { kind: "decode-only"; reason: "cross_origin_no_cors"; asset: string }; + | { kind: "decode-only"; reason: WebAudioSilenceReason; asset: string }; + +/** + * Why capture was withheld. The two cases need DIFFERENT remediation, which is + * the whole reason they are separate values: a foreign asset can be proxied to + * a same-origin URL, but an opaque document has no same-origin URL to proxy to. + */ +export type WebAudioSilenceReason = + /** The asset's origin differs from ours, and no `crossorigin` opt-in is set. */ + | "cross_origin_no_cors" + /** OUR document's security origin is opaque, so nothing is same-origin with it. */ + | "opaque_document_origin"; /** Fired when the runtime withholds Web Audio capture from a media element. */ const DIAGNOSTIC_BYPASS_CODE = "runtime_web_audio_bypass"; @@ -135,22 +146,45 @@ function routeCandidates(el: HTMLMediaElement): string[] { * pre-existing behaviour rather than losing Web Audio on a guess. The guard * changes behaviour ONLY in the case that is already known-broken. */ -function isCorsSilenced(rawUrl: string, el: HTMLMediaElement): boolean { - if (typeof window === "undefined") return false; +/** + * The candidate as an absolute URL, or null when it is not a fetch this check + * governs: unparseable, or a scheme (`data:`, `blob:`, `file:`) where origin + * comparison means nothing. + */ +function httpMediaUrl(rawUrl: string, el: HTMLMediaElement): URL | null { let url: URL; try { url = new URL(rawUrl, baseUri(el)); } catch { - return false; + return null; } - if (url.protocol !== "http:" && url.protocol !== "https:") return false; - // `location.origin` is the URL's origin, not the document's actual - // security origin: a sandboxed iframe without `allow-same-origin` still - // reports the parent-looking URL there while `window.origin` reads - // `"null"`. An opaque document can't prove same-origin with anything. - const selfOrigin = typeof window.origin === "string" ? window.origin : window.location.origin; - if (selfOrigin !== "null" && url.origin === selfOrigin) return false; - return !hasCorsOptIn(el); + if (url.protocol !== "http:" && url.protocol !== "https:") return null; + return url; +} + +/** + * Our OWN security origin, which is `"null"` for an opaque document. + * + * Not `location.origin`: that is the URL's origin, so a sandboxed iframe + * without `allow-same-origin` still reports the parent-looking URL there + * while `window.origin` reads `"null"`. An opaque document cannot prove + * same-origin with anything, including an asset served from its own host. + */ +function documentSecurityOrigin(): string { + return typeof window.origin === "string" ? window.origin : window.location.origin; +} + +function corsSilenceReason(rawUrl: string, el: HTMLMediaElement): WebAudioSilenceReason | null { + if (typeof window === "undefined") return null; + const url = httpMediaUrl(rawUrl, el); + if (!url) return null; + const selfOrigin = documentSecurityOrigin(); + const opaqueDocument = selfOrigin === "null"; + if (!opaqueDocument && url.origin === selfOrigin) return null; + if (hasCorsOptIn(el)) return null; + // Opacity wins when both hold. It is the blocking condition, and it is the + // one the author cannot fix by moving the asset. + return opaqueDocument ? "opaque_document_origin" : "cross_origin_no_cors"; } /** @@ -182,8 +216,9 @@ export function isRouteSelectionSettled(el: HTMLMediaElement): boolean { */ export function classifyWebAudioMediaRoute(el: HTMLMediaElement): WebAudioMediaRoute { for (const candidate of routeCandidates(el)) { - if (isCorsSilenced(candidate, el)) { - return { kind: "decode-only", reason: "cross_origin_no_cors", asset: candidate }; + const reason = corsSilenceReason(candidate, el); + if (reason) { + return { kind: "decode-only", reason, asset: candidate }; } } return { kind: "web-audio" }; @@ -252,6 +287,29 @@ const diagnosedElements = new WeakSet(); * The bypass always reports: it is an accident by definition, and the whole * complaint in #3458 is that nothing was said. */ +/** + * What was actually wrong, per reason. The opaque case is NOT "cross-origin + * media": the asset can be same-origin by URL and still unreadable, because it + * is OUR document that cannot prove an origin. + */ +const NOTE_BY_REASON: Record = { + cross_origin_no_cors: + "cross-origin media without a `crossorigin` opt-in is silent through createMediaElementSource (Web Audio spec); using native playback instead", + opaque_document_origin: + "this document's security origin is opaque (a sandbox without `allow-same-origin`), so no media is same-origin with it and createMediaElementSource would be silent (Web Audio spec); using native playback instead", +}; + +/** + * How to get the processing back, per reason. Keeping these apart is the point: + * telling an opaque document to "proxy the asset to a same-origin URL" names a + * fix that cannot exist, since nothing is same-origin with an opaque origin. + */ +const REMEDY_BY_REASON: Record = { + cross_origin_no_cors: "proxy or download the asset to a same-origin URL to keep it.", + opaque_document_origin: + "add `allow-same-origin` to the sandbox, or serve the asset with a `crossorigin` attribute and an `Access-Control-Allow-Origin` of `null` or `*`, to keep it.", +}; + export function reportWebAudioMediaRoute(el: HTMLMediaElement, route: WebAudioMediaRoute): void { if (route.kind === "web-audio") return; if (isRenderMode()) return; @@ -263,7 +321,7 @@ export function reportWebAudioMediaRoute(el: HTMLMediaElement, route: WebAudioMe asset: route.asset, reason: route.reason, lostProcessing: lost, - note: "cross-origin media without a `crossorigin` opt-in is silent through createMediaElementSource (Web Audio spec); using native playback instead", + note: NOTE_BY_REASON[route.reason], }; postRuntimeMessage({ source: "hf-preview", @@ -276,7 +334,7 @@ export function reportWebAudioMediaRoute(el: HTMLMediaElement, route: WebAudioMe // same contract mediaProxy.ts's diagnostics use. const lostNote = lost.length > 0 - ? ` Native playback cannot reproduce: ${lost.join(", ")} — proxy or download the asset to a same-origin URL to keep it.` + ? ` Native playback cannot reproduce: ${lost.join(", ")} — ${REMEDY_BY_REASON[route.reason]}` : ""; console.info( `[hyperframes] ${DIAGNOSTIC_BYPASS_CODE}: "${route.asset}" (${route.reason}): ` +