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 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01PPmy3tPq869XDW4njjVaKA
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
43a5e93c37
commit
a93bd9d7d8
@@ -10,7 +10,11 @@ import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
|||||||
import { of } from "rxjs";
|
import { of } from "rxjs";
|
||||||
|
|
||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { startLotusAudioInject } from "./lotusAudioInject";
|
import {
|
||||||
|
MAX_INJECT_BYTES,
|
||||||
|
parseInjectSource,
|
||||||
|
startLotusAudioInject,
|
||||||
|
} from "./lotusAudioInject";
|
||||||
import { LotusWidgetActions } from "./lotusActions";
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
|
||||||
const lazyActions = new EventEmitter();
|
const lazyActions = new EventEmitter();
|
||||||
@@ -223,3 +227,34 @@ test("#14: the shared context stays open while another instance is still active"
|
|||||||
stopB();
|
stopB();
|
||||||
expect(ctx.close).toHaveBeenCalledTimes(1);
|
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();
|
||||||
|
});
|
||||||
|
|||||||
@@ -52,8 +52,10 @@ function acquireSharedAudio(): {
|
|||||||
* that was impossible against the prebuilt EC bundle (LiveKit's
|
* that was impossible against the prebuilt EC bundle (LiveKit's
|
||||||
* LocalParticipant lived in EC's module scope).
|
* LocalParticipant lived in EC's module scope).
|
||||||
*
|
*
|
||||||
* Action data: `{ url: string, volume?: number }`. `url` must be an https/blob
|
* Action data: `{ url?: string, audio?: ArrayBuffer, volume?: number }`.
|
||||||
* URL (the host resolves mxc → media URL).
|
* `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
|
* No effect unless the host sends the action. Returns a teardown function that
|
||||||
* also aborts any clip still playing.
|
* also aborts any clip still playing.
|
||||||
@@ -88,12 +90,12 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = ev.detail.data as
|
const data = ev.detail.data as
|
||||||
| { url?: unknown; volume?: unknown }
|
| { url?: unknown; audio?: unknown; volume?: unknown }
|
||||||
| undefined;
|
| undefined;
|
||||||
const url = typeof data?.url === "string" ? safeMediaUrl(data.url) : null;
|
const clip = parseInjectSource(data);
|
||||||
if (!url) {
|
if (!clip) {
|
||||||
w.api.transport.reply(ev.detail, {});
|
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;
|
return;
|
||||||
}
|
}
|
||||||
const volume =
|
const volume =
|
||||||
@@ -115,7 +117,7 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
|||||||
}
|
}
|
||||||
|
|
||||||
w.api.transport.reply(ev.detail, {});
|
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),
|
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. */
|
/** Only allow fetchable media URLs; never same-origin credentialed GETs etc. */
|
||||||
function safeMediaUrl(raw: string): string | null {
|
function safeMediaUrl(raw: string): string | null {
|
||||||
try {
|
try {
|
||||||
@@ -155,7 +178,7 @@ function safeMediaUrl(raw: string): string | null {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function playInjectedClip(
|
async function playInjectedClip(
|
||||||
url: string,
|
clip: string | ArrayBuffer,
|
||||||
volume: number,
|
volume: number,
|
||||||
rooms: LivekitRoom[],
|
rooms: LivekitRoom[],
|
||||||
activeClips: Set<() => void>,
|
activeClips: Set<() => void>,
|
||||||
@@ -188,25 +211,30 @@ async function playInjectedClip(
|
|||||||
};
|
};
|
||||||
activeClips.add(placeholder);
|
activeClips.add(placeholder);
|
||||||
|
|
||||||
let resp: Response;
|
let arrayBuffer: ArrayBuffer;
|
||||||
try {
|
if (typeof clip !== "string") {
|
||||||
resp = await fetch(url, {
|
arrayBuffer = clip;
|
||||||
credentials: "omit",
|
} else {
|
||||||
mode: "cors",
|
let resp: Response;
|
||||||
signal: controller.signal,
|
try {
|
||||||
});
|
resp = await fetch(clip, {
|
||||||
} catch (e) {
|
credentials: "omit",
|
||||||
// Superseded by a newer clip mid-fetch — expected, not a failure.
|
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;
|
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
|
// [lotus] Reuse the shared module-level context/destination (#14) rather
|
||||||
// than `new AudioContext()` per clip — see the declaration above.
|
// than `new AudioContext()` per clip — see the declaration above.
|
||||||
|
|||||||
@@ -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<EventListener>([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<MessageEvent>): 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"]);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<MessageEvent, "source" | "origin">;
|
||||||
|
|
||||||
|
/** 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, "addEventListener" | "removeEventListener"> = 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);
|
||||||
|
}
|
||||||
+6
-10
@@ -24,6 +24,7 @@ import { Config } from "./config/Config";
|
|||||||
import { seedSettingsFromConfig } from "./settings/settings";
|
import { seedSettingsFromConfig } from "./settings/settings";
|
||||||
import { ElementCallReactionEventType } from "./reactions";
|
import { ElementCallReactionEventType } from "./reactions";
|
||||||
import { LOTUS_TO_WIDGET_ACTIONS } from "./lotus/lotusActions";
|
import { LOTUS_TO_WIDGET_ACTIONS } from "./lotus/lotusActions";
|
||||||
|
import { restrictToHost } from "./lotus/lotusWidgetOrigin";
|
||||||
|
|
||||||
// Subset of the actions in element-web
|
// Subset of the actions in element-web
|
||||||
export enum ElementWidgetActions {
|
export enum ElementWidgetActions {
|
||||||
@@ -94,16 +95,11 @@ export const initializeWidget = (
|
|||||||
const parentOrigin = new URL(parentUrl).origin;
|
const parentOrigin = new URL(parentUrl).origin;
|
||||||
logger.info("Widget API is available");
|
logger.info("Widget API is available");
|
||||||
const api = new WidgetApi(widgetId, parentOrigin);
|
const api = new WidgetApi(widgetId, parentOrigin);
|
||||||
// [lotus] matrix-widget-api's PostmessageTransport defaults
|
// [lotus] Only messages from the host (our parent, at parentUrl's
|
||||||
// strictOriginCheck to false, which would let any frame holding a
|
// origin) reach the widget API. matrix-widget-api's own
|
||||||
// handle to our window post toWidget actions (including the
|
// strictOriginCheck compares against THIS frame's origin, so it only
|
||||||
// io.lotus.* actions below). The Lotus deployment serves EC
|
// worked while EC was served from the host's origin (cinny #43).
|
||||||
// same-origin with the host (cinny loads /public/element-call/index.html),
|
restrictToHost(api.transport, parentOrigin);
|
||||||
// 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;
|
|
||||||
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
|
api.requestCapability(MatrixCapabilities.AlwaysOnScreen);
|
||||||
api.requestCapability(MatrixCapabilities.MSC4039DownloadFile);
|
api.requestCapability(MatrixCapabilities.MSC4039DownloadFile);
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user