lotus(security): harden denoise base, audio-inject, decorations
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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
39b57db3b2
commit
b8543c3fe1
@@ -12,6 +12,7 @@ import { type IWidgetApiRequest } from "matrix-widget-api";
|
|||||||
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
import { type CallViewModel } from "../state/CallViewModel/CallViewModel";
|
||||||
import { widget } from "../widget";
|
import { widget } from "../widget";
|
||||||
import { LotusWidgetActions } from "./lotusActions";
|
import { LotusWidgetActions } from "./lotusActions";
|
||||||
|
import { lotusFlag } from "./lotusWidget";
|
||||||
|
|
||||||
/** Hard cap so a malformed/huge clip can't hold a published track open forever. */
|
/** Hard cap so a malformed/huge clip can't hold a published track open forever. */
|
||||||
const MAX_CLIP_MS = 30_000;
|
const MAX_CLIP_MS = 30_000;
|
||||||
@@ -48,7 +49,11 @@ export function startLotusAudioInject(vm: CallViewModel): () => void {
|
|||||||
const activeClips = new Set<() => void>();
|
const activeClips = new Set<() => void>();
|
||||||
|
|
||||||
const handler = (ev: CustomEvent<IWidgetApiRequest>): void => {
|
const handler = (ev: CustomEvent<IWidgetApiRequest>): 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, {});
|
void w.api.transport.reply(ev.detail, {});
|
||||||
|
if (!lotusFlag("lotusAudioInject")) return;
|
||||||
const data = ev.detail.data as
|
const data = ev.detail.data as
|
||||||
| { url?: unknown; volume?: unknown }
|
| { url?: unknown; volume?: unknown }
|
||||||
| undefined;
|
| undefined;
|
||||||
|
|||||||
@@ -70,7 +70,11 @@ export function startLotusDecorations(): () => void {
|
|||||||
| undefined;
|
| undefined;
|
||||||
const next: Record<string, string> = {};
|
const next: Record<string, string> = {};
|
||||||
if (data?.decorations && typeof data.decorations === "object") {
|
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);
|
const safe = safeImageUrl(url);
|
||||||
if (safe) next[userId] = safe;
|
if (safe) next[userId] = safe;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -31,12 +31,31 @@ import {
|
|||||||
* survives EC's mid-call reconnect — fixing the A7 "mic dead after reconnect"
|
* survives EC's mid-call reconnect — fixing the A7 "mic dead after reconnect"
|
||||||
* bug. Additive: no-op without the flag.
|
* 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 {
|
export function startLotusDenoise(vm: CallViewModel): () => void {
|
||||||
if (lotusParam("lotusDenoise") !== "ml") return () => undefined;
|
if (lotusParam("lotusDenoise") !== "ml") return () => undefined;
|
||||||
|
|
||||||
const config: LotusDenoiseConfig = {
|
const config: LotusDenoiseConfig = {
|
||||||
model: lotusParam("lotusModel") === "speex" ? "speex" : "rnnoise",
|
model: lotusParam("lotusModel") === "speex" ? "speex" : "rnnoise",
|
||||||
assetBase: lotusParam("lotusDenoiseBase") || "./denoise/",
|
assetBase: safeAssetBase(lotusParam("lotusDenoiseBase")),
|
||||||
gate: lotusFlag("lotusGate"),
|
gate: lotusFlag("lotusGate"),
|
||||||
gateThreshold: Number(lotusParam("lotusGateThreshold")) || -50,
|
gateThreshold: Number(lotusParam("lotusGateThreshold")) || -50,
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user