From a93bd9d7d80ddb4ebfc0b6e772ae705e67e96a4d Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Sat, 26 Sep 2026 23:24:32 -0400 Subject: [PATCH] feat(lotus): work when served from its own origin (cinny #43) Two things tied EC to the host's origin: - Widget message check: matrix-widget-api's strictOriginCheck compares ev.origin with THIS frame's origin, so on call.chat.lotusguild.org every message from chat.lotusguild.org would be dropped and calls would not start. restrictToHost() instead requires ev.source === window.parent and ev.origin === parentUrl's origin. Same-origin deployments keep working (the host origin is our own origin there), and it is stricter than before: the sender must also be our parent window. - Soundboard: the host's blob: clip URL is origin-bound. io.lotus.inject_audio now accepts the clip's bytes (`audio`, ArrayBuffer, <= 8 MiB) and prefers them over `url`; hosts that only send `url` are unchanged. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA --- src/lotus/lotusAudioInject.test.ts | 37 +++++++++++++- src/lotus/lotusAudioInject.ts | 78 ++++++++++++++++++++--------- src/lotus/lotusWidgetOrigin.test.ts | 65 ++++++++++++++++++++++++ src/lotus/lotusWidgetOrigin.ts | 53 ++++++++++++++++++++ src/widget.ts | 16 +++--- 5 files changed, 213 insertions(+), 36 deletions(-) create mode 100644 src/lotus/lotusWidgetOrigin.test.ts create mode 100644 src/lotus/lotusWidgetOrigin.ts diff --git a/src/lotus/lotusAudioInject.test.ts b/src/lotus/lotusAudioInject.test.ts index ee0dcff9..f919661f 100644 --- a/src/lotus/lotusAudioInject.test.ts +++ b/src/lotus/lotusAudioInject.test.ts @@ -10,7 +10,11 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest"; import { of } from "rxjs"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; -import { startLotusAudioInject } from "./lotusAudioInject"; +import { + MAX_INJECT_BYTES, + parseInjectSource, + startLotusAudioInject, +} from "./lotusAudioInject"; import { LotusWidgetActions } from "./lotusActions"; const lazyActions = new EventEmitter(); @@ -223,3 +227,34 @@ test("#14: the shared context stays open while another instance is still active" stopB(); expect(ctx.close).toHaveBeenCalledTimes(1); }); + +// [cinny #43] Cross-origin hosts send the clip bytes instead of a blob: URL. +test("parseInjectSource prefers the clip bytes over the url", () => { + const audio = new ArrayBuffer(16); + expect(parseInjectSource({ audio, url: "https://x.example/a.ogg" })).toBe( + audio, + ); +}); + +test("parseInjectSource falls back to a safe url", () => { + expect(parseInjectSource({ url: "https://x.example/a.ogg" })).toBe( + "https://x.example/a.ogg", + ); + expect( + parseInjectSource({ + audio: new ArrayBuffer(0), + url: "https://x.example/a.ogg", + }), + ).toBe("https://x.example/a.ogg"); + expect(parseInjectSource({ url: "javascript" + ":alert(1)" })).toBeNull(); + expect(parseInjectSource({})).toBeNull(); + expect(parseInjectSource(undefined)).toBeNull(); +}); + +test("parseInjectSource rejects oversized or non-buffer audio", () => { + expect( + parseInjectSource({ audio: new ArrayBuffer(MAX_INJECT_BYTES + 1) }), + ).toBeNull(); + expect(parseInjectSource({ audio: "not bytes" })).toBeNull(); + expect(parseInjectSource({ audio: new Uint8Array(4) })).toBeNull(); +}); diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index 19dd6a47..59b2fb3a 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -52,8 +52,10 @@ function acquireSharedAudio(): { * that was impossible against the prebuilt EC bundle (LiveKit's * LocalParticipant lived in EC's module scope). * - * Action data: `{ url: string, volume?: number }`. `url` must be an https/blob - * URL (the host resolves mxc → media URL). + * Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`. + * `audio` is the clip's bytes (cinny #43: a host `blob:` URL can't be fetched + * once EC is served from its own origin, so the host sends the bytes and they + * win); otherwise `url` must be an https/blob URL (older hosts). * * No effect unless the host sends the action. Returns a teardown function that * also aborts any clip still playing. @@ -88,12 +90,12 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { return; } const data = ev.detail.data as - | { url?: unknown; volume?: unknown } + | { url?: unknown; audio?: unknown; volume?: unknown } | undefined; - const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null; - if (!url) { + const clip = parseInjectSource(data); + if (!clip) { w.api.transport.reply(ev.detail, {}); - logger.warn("[lotus] inject_audio: missing/invalid url"); + logger.warn("[lotus] inject_audio: missing/invalid url or audio"); return; } const volume = @@ -115,7 +117,7 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { } w.api.transport.reply(ev.detail, {}); - void playInjectedClip(url, volume, rooms, activeClips).catch((e) => + void playInjectedClip(clip, volume, rooms, activeClips).catch((e) => logger.warn("[lotus] inject_audio failed", e), ); }; @@ -144,6 +146,27 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { }; } +/** Largest clip accepted as bytes (the host's soundboard clips are small). */ +export const MAX_INJECT_BYTES = 8 * 1024 * 1024; + +/** + * The clip to play: the bytes the host sent (`audio`), or else a fetchable + * `url`. Exported for tests. + */ +export function parseInjectSource( + data: { url?: unknown; audio?: unknown } | undefined, +): string | ArrayBuffer | null { + const audio = data?.audio; + if ( + audio instanceof ArrayBuffer && + audio.byteLength > 0 && + audio.byteLength <= MAX_INJECT_BYTES + ) { + return audio; + } + return typeof data?.url === "string" ? safeMediaUrl(data.url) : null; +} + /** Only allow fetchable media URLs; never same-origin credentialed GETs etc. */ function safeMediaUrl(raw: string): string | null { try { @@ -155,7 +178,7 @@ function safeMediaUrl(raw: string): string | null { } async function playInjectedClip( - url: string, + clip: string | ArrayBuffer, volume: number, rooms: LivekitRoom[], activeClips: Set<() => void>, @@ -188,25 +211,30 @@ async function playInjectedClip( }; activeClips.add(placeholder); - let resp: Response; - try { - resp = await fetch(url, { - credentials: "omit", - mode: "cors", - signal: controller.signal, - }); - } catch (e) { - // Superseded by a newer clip mid-fetch — expected, not a failure. + let arrayBuffer: ArrayBuffer; + if (typeof clip !== "string") { + arrayBuffer = clip; + } else { + let resp: Response; + try { + resp = await fetch(clip, { + credentials: "omit", + mode: "cors", + signal: controller.signal, + }); + } catch (e) { + // Superseded by a newer clip mid-fetch — expected, not a failure. + if (aborted) return; + throw e; + } + if (aborted) return; + if (!resp.ok) { + activeClips.delete(placeholder); + throw new Error(`fetch ${clip} -> ${resp.status}`); + } + arrayBuffer = await resp.arrayBuffer(); if (aborted) return; - throw e; } - if (aborted) return; - if (!resp.ok) { - activeClips.delete(placeholder); - throw new Error(`fetch ${url} -> ${resp.status}`); - } - const arrayBuffer = await resp.arrayBuffer(); - if (aborted) return; // [lotus] Reuse the shared module-level context/destination (#14) rather // than `new AudioContext()` per clip — see the declaration above. diff --git a/src/lotus/lotusWidgetOrigin.test.ts b/src/lotus/lotusWidgetOrigin.test.ts new file mode 100644 index 00000000..b2488c56 --- /dev/null +++ b/src/lotus/lotusWidgetOrigin.test.ts @@ -0,0 +1,65 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +import { describe, expect, it } from "vitest"; + +import { isFromHost, restrictToHost } from "./lotusWidgetOrigin"; + +const parent = {} as Window; +const other = {} as Window; +const HOST = "https://chat.example.org"; + +describe("isFromHost", () => { + it("accepts only the parent window at the host origin", () => { + expect(isFromHost({ source: parent, origin: HOST }, parent, HOST)).toBe( + true, + ); + expect(isFromHost({ source: other, origin: HOST }, parent, HOST)).toBe( + false, + ); + expect( + isFromHost( + { source: parent, origin: "https://evil.example" }, + parent, + HOST, + ), + ).toBe(false); + expect(isFromHost({ source: parent, origin: "null" }, parent, HOST)).toBe( + false, + ); + }); +}); + +describe("restrictToHost", () => { + it("replaces the listener and drops foreign messages", () => { + const seen: unknown[] = []; + const original = (ev: MessageEvent): void => { + seen.push(ev.data); + }; + const transport = { handleMessage: original }; + const listeners = new Set([original as EventListener]); + const inbound = { + addEventListener: (_t: string, l: EventListener): void => { + listeners.add(l); + }, + removeEventListener: (_t: string, l: EventListener): void => { + listeners.delete(l); + }, + } as unknown as Window; + + restrictToHost(transport, HOST, parent, inbound); + expect(listeners.has(original as EventListener)).toBe(false); + expect(listeners.size).toBe(1); + + const dispatch = (ev: Partial): void => + listeners.forEach((l) => l(ev as unknown as Event)); + dispatch({ source: other, origin: HOST, data: "spoof" }); + dispatch({ source: parent, origin: "https://evil.example", data: "bad" }); + dispatch({ source: parent, origin: HOST, data: "real" }); + expect(seen).toEqual(["real"]); + }); +}); diff --git a/src/lotus/lotusWidgetOrigin.ts b/src/lotus/lotusWidgetOrigin.ts new file mode 100644 index 00000000..ac166b01 --- /dev/null +++ b/src/lotus/lotusWidgetOrigin.ts @@ -0,0 +1,53 @@ +/* +Copyright 2026 Lotus Guild + +SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial +Please see LICENSE in the repository root for full details. +*/ + +type MessageLike = Pick; + +/** True when a message came from our parent window, at the host's origin. */ +export function isFromHost( + ev: MessageLike, + parentWindow: Window, + parentOrigin: string, +): boolean { + return ev.source === parentWindow && ev.origin === parentOrigin; +} + +interface InboundTransport { + handleMessage: (ev: MessageEvent) => void; +} + +/** + * [cinny #43] Only handle widget messages sent by the host (our parent window) + * from the origin it was loaded at (`parentUrl`). + * + * matrix-widget-api's `strictOriginCheck` compares `ev.origin` with this + * frame's OWN origin, which only works while EC is served from the host's + * origin; served from its own origin (e.g. call.chat.lotusguild.org) every + * host message would be dropped. Turning the check off instead would let any + * window post toWidget actions (including io.lotus.*). This compares against + * the host's origin and also requires the sender to be our parent, so it is + * correct same-origin and cross-origin alike. + * + * Swaps the transport's `message` listener; safe whether or not the transport + * has started (`stop()` removes `handleMessage`, the guarded one after this). + */ +export function restrictToHost( + transport: unknown, + parentOrigin: string, + parentWindow: Window = window.parent, + inbound: Pick = window, +): void { + const t = transport as InboundTransport; + const original = t.handleMessage; + const guarded = (ev: MessageEvent): void => { + if (!isFromHost(ev, parentWindow, parentOrigin)) return; + original(ev); + }; + inbound.removeEventListener("message", original as EventListener); + t.handleMessage = guarded; + inbound.addEventListener("message", guarded as EventListener); +} diff --git a/src/widget.ts b/src/widget.ts index f9ac07c1..da92c1c4 100644 --- a/src/widget.ts +++ b/src/widget.ts @@ -24,6 +24,7 @@ import { Config } from "./config/Config"; import { seedSettingsFromConfig } from "./settings/settings"; import { ElementCallReactionEventType } from "./reactions"; import { LOTUS_TO_WIDGET_ACTIONS } from "./lotus/lotusActions"; +import { restrictToHost } from "./lotus/lotusWidgetOrigin"; // Subset of the actions in element-web export enum ElementWidgetActions { @@ -94,16 +95,11 @@ export const initializeWidget = ( const parentOrigin = new URL(parentUrl).origin; logger.info("Widget API is available"); const api = new WidgetApi(widgetId, parentOrigin); - // [lotus] matrix-widget-api's PostmessageTransport defaults - // strictOriginCheck to false, which would let any frame holding a - // handle to our window post toWidget actions (including the - // io.lotus.* actions below). The Lotus deployment serves EC - // same-origin with the host (cinny loads /public/element-call/index.html), - // so globalThis.origin === parentOrigin and this check passes safely. - // A cross-origin deployment would need to compare ev.origin to - // parentOrigin instead, since strictOriginCheck compares against - // globalThis.origin. - api.transport.strictOriginCheck = true; + // [lotus] Only messages from the host (our parent, at parentUrl's + // origin) reach the widget API. matrix-widget-api's own + // strictOriginCheck compares against THIS frame's origin, so it only + // worked while EC was served from the host's origin (cinny #43). + restrictToHost(api.transport, parentOrigin); api.requestCapability(MatrixCapabilities.AlwaysOnScreen); api.requestCapability(MatrixCapabilities.MSC4039DownloadFile); -- 2.47.3