From d81c3c872184bc1b0440fac607ade98f9d845ba3 Mon Sep 17 00:00:00 2001 From: root Date: Fri, 15 May 2026 15:08:55 -0400 Subject: [PATCH] fix: call system bugs and security hardening MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CallEmbed: fix memory leak — mx event listeners were never removed because dispose() called .bind(this) again, creating new function objects. Now uses arrow class fields so start()/dispose() share the exact same reference. - callPreferences: toggleVideo is a no-op when cameraOnJoin=false, preventing internal state drift from the returned value. - CallControls: PTT key guard now blocks on SELECT elements and walks the DOM for inherited contentEditable to prevent key interception inside dropdowns and custom editors. - RoomInput: GIF fetch validates Giphy CDN domain allow-list, HTTP Content-Type header, and enforces 20 MB size cap. --- src/app/features/call/CallControls.tsx | 18 +++++++++++++----- src/app/features/room/RoomInput.tsx | 12 ++++++++++++ src/app/plugins/call/CallEmbed.ts | 22 ++++++++++++++-------- src/app/state/hooks/callPreferences.ts | 3 ++- 4 files changed, 41 insertions(+), 14 deletions(-) diff --git a/src/app/features/call/CallControls.tsx b/src/app/features/call/CallControls.tsx index ddf51f38e..75c64e254 100644 --- a/src/app/features/call/CallControls.tsx +++ b/src/app/features/call/CallControls.tsx @@ -102,11 +102,19 @@ export function CallControls({ callEmbed }: CallControlsProps) { if (e.code !== pttKey || e.repeat) return; // Don't intercept keys typed into a text input or editable element const target = e.target as HTMLElement; - if ( - target.tagName === 'INPUT' || - target.tagName === 'TEXTAREA' || - target.contentEditable === 'true' - ) return; + // Skip PTT if key is pressed inside any text-input or editable surface + const isEditable = (el: HTMLElement): boolean => { + const tag = el.tagName; + if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true; + let node: HTMLElement | null = el; + while (node && node !== document.body) { + if (node.contentEditable === 'true') return true; + if (node.contentEditable === 'false') return false; + node = node.parentElement; + } + return false; + }; + if (isEditable(target)) return; e.preventDefault(); if (!microphoneRef.current) callEmbed.control.setMicrophone(true); setPttActive(true); diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 00533d422..aac26a076 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -457,8 +457,20 @@ export const RoomInput = forwardRef( const handleGifSelect = useCallback( async (gifUrl: string, w: number, h: number) => { try { + // Only fetch from trusted Giphy CDN domains + const allowed = ['media.giphy.com', 'i.giphy.com', 'media0.giphy.com', + 'media1.giphy.com', 'media2.giphy.com', 'media3.giphy.com', 'media4.giphy.com']; + const { hostname } = new URL(gifUrl); + if (!allowed.includes(hostname)) return; + const res = await fetch(gifUrl); + if (!res.ok) return; + const contentType = res.headers.get('content-type') ?? ''; + if (!contentType.startsWith('image/')) return; + const blob = await res.blob(); + if (blob.size > 20 * 1024 * 1024) return; // 20 MB cap + const uploadRes = await mx.uploadContent( new File([blob], 'image.gif', { type: 'image/gif' }), { type: 'image/gif', name: 'image.gif', includeFilename: false } diff --git a/src/app/plugins/call/CallEmbed.ts b/src/app/plugins/call/CallEmbed.ts index 760582617..78bcb9bab 100644 --- a/src/app/plugins/call/CallEmbed.ts +++ b/src/app/plugins/call/CallEmbed.ts @@ -51,6 +51,12 @@ export class CallEmbed { private styleRetryObserver?: MutationObserver; + // Arrow-function class fields so dispose() passes the exact same reference to mx.off() + private readonly boundOnEvent = (ev: MatrixEvent) => this.onEvent(ev); + private readonly boundOnEventDecrypted = (ev: MatrixEvent) => this.onEventDecrypted(ev); + private readonly boundOnStateUpdate = (ev: MatrixEvent) => this.onStateUpdate(ev); + private readonly boundOnToDeviceEvent = (ev: MatrixEvent) => this.onToDeviceEvent(ev); + static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent { if (dm && ongoing) { return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice; @@ -252,10 +258,10 @@ export class CallEmbed { }); // Attach listeners for feeding events - the underlying widget classes handle permissions for us - this.mx.on(ClientEvent.Event, this.onEvent.bind(this)); - this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); - this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this)); - this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + this.mx.on(ClientEvent.Event, this.boundOnEvent); + this.mx.on(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted); + this.mx.on(RoomStateEvent.Events, this.boundOnStateUpdate); + this.mx.on(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent); } /** @@ -272,10 +278,10 @@ export class CallEmbed { this.container.removeChild(this.iframe); this.control.dispose(); - this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); - this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); - this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this)); - this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); + this.mx.off(ClientEvent.Event, this.boundOnEvent); + this.mx.off(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted); + this.mx.off(RoomStateEvent.Events, this.boundOnStateUpdate); + this.mx.off(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent); // Clear internal state this.readUpToMap = {}; diff --git a/src/app/state/hooks/callPreferences.ts b/src/app/state/hooks/callPreferences.ts index 0dd379e29..b931e1239 100644 --- a/src/app/state/hooks/callPreferences.ts +++ b/src/app/state/hooks/callPreferences.ts @@ -36,6 +36,7 @@ export const useCallPreferences = (): CallPreferences & { }, [setPref, pref]); const toggleVideo = useCallback(() => { + if (!cameraOnJoin) return; const video = !pref.video; setPref({ @@ -43,7 +44,7 @@ export const useCallPreferences = (): CallPreferences & { video, sound: pref.sound, }); - }, [setPref, pref]); + }, [setPref, pref, cameraOnJoin]); const toggleSound = useCallback(() => { const sound = !pref.sound;