From b8543c3fe1efb4255be8e5f571dec319103811ef Mon Sep 17 00:00:00 2001 From: Lotus CI Date: Tue, 30 Jun 2026 00:00:40 -0400 Subject: [PATCH] lotus(security): harden denoise base, audio-inject, decorations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Holistic security audit findings: - C1 (CRITICAL): force lotusDenoiseBase to same-origin before it reaches audioWorklet.addModule()/fetch — a crafted call-link param could otherwise load attacker JS/WASM as a worklet processing the live mic. Non-same-origin/malformed values fall back to bundled ./denoise/. - H1 (HIGH): gate audio-inject behind explicit lotusAudioInject=1 (still acks the action so no transport hang) — it publishes under the local user's identity, so it must not be silently armed for every call. - M1 (MED): cap the decoration roster at 512 entries. Co-Authored-By: Claude Opus 4.8 --- src/lotus/lotusAudioInject.ts | 5 +++++ src/lotus/lotusDecorations.ts | 6 +++++- src/lotus/lotusDenoise.ts | 21 ++++++++++++++++++++- 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/src/lotus/lotusAudioInject.ts b/src/lotus/lotusAudioInject.ts index 259c4d24..998392e0 100644 --- a/src/lotus/lotusAudioInject.ts +++ b/src/lotus/lotusAudioInject.ts @@ -12,6 +12,7 @@ import { type IWidgetApiRequest } from "matrix-widget-api"; import { type CallViewModel } from "../state/CallViewModel/CallViewModel"; import { widget } from "../widget"; import { LotusWidgetActions } from "./lotusActions"; +import { lotusFlag } from "./lotusWidget"; /** Hard cap so a malformed/huge clip can't hold a published track open forever. */ const MAX_CLIP_MS = 30_000; @@ -48,7 +49,11 @@ export function startLotusAudioInject(vm: CallViewModel): () => void { const activeClips = new Set<() => void>(); const handler = (ev: CustomEvent): void => { + // Always ack so the transport doesn't hang, but only act when the host has + // explicitly opted in: audio-inject publishes under the local user's + // identity, so it must not be silently armed for every call. void w.api.transport.reply(ev.detail, {}); + if (!lotusFlag("lotusAudioInject")) return; const data = ev.detail.data as | { url?: unknown; volume?: unknown } | undefined; diff --git a/src/lotus/lotusDecorations.ts b/src/lotus/lotusDecorations.ts index 6dbb8d6d..a08cff75 100644 --- a/src/lotus/lotusDecorations.ts +++ b/src/lotus/lotusDecorations.ts @@ -70,7 +70,11 @@ export function startLotusDecorations(): () => void { | undefined; const next: Record = {}; if (data?.decorations && typeof data.decorations === "object") { - for (const [userId, url] of Object.entries(data.decorations)) { + // Cap the roster so a pathological map can't spawn unbounded overlays. + for (const [userId, url] of Object.entries(data.decorations).slice( + 0, + 512, + )) { const safe = safeImageUrl(url); if (safe) next[userId] = safe; } diff --git a/src/lotus/lotusDenoise.ts b/src/lotus/lotusDenoise.ts index 623c986a..a3f3ed22 100644 --- a/src/lotus/lotusDenoise.ts +++ b/src/lotus/lotusDenoise.ts @@ -31,12 +31,31 @@ import { * survives EC's mid-call reconnect — fixing the A7 "mic dead after reconnect" * bug. Additive: no-op without the flag. */ +/** + * Resolve the denoise asset base, forcing it to be SAME-ORIGIN. The base is fed + * to `audioWorklet.addModule()`, which executes the target as code in the + * worklet scope (processing the live mic) — so a cross-origin base from a + * crafted call link would be arbitrary code execution. Any non-same-origin or + * malformed value falls back to the bundled "./denoise/". + */ +function safeAssetBase(raw: string | null): string { + const fallback = "./denoise/"; + if (!raw) return fallback; + try { + const u = new URL(raw, window.location.href); + if (u.origin !== window.location.origin) return fallback; + return u.href.endsWith("/") ? u.href : `${u.href}/`; + } catch { + return fallback; + } +} + export function startLotusDenoise(vm: CallViewModel): () => void { if (lotusParam("lotusDenoise") !== "ml") return () => undefined; const config: LotusDenoiseConfig = { model: lotusParam("lotusModel") === "speex" ? "speex" : "rnnoise", - assetBase: lotusParam("lotusDenoiseBase") || "./denoise/", + assetBase: safeAssetBase(lotusParam("lotusDenoiseBase")), gate: lotusFlag("lotusGate"), gateThreshold: Number(lotusParam("lotusGateThreshold")) || -50, };