fix: call system bugs and security hardening

- 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.
This commit is contained in:
root
2026-05-15 15:08:55 -04:00
parent 0d28f10c95
commit e30212f409
4 changed files with 41 additions and 14 deletions
+13 -5
View File
@@ -102,11 +102,19 @@ export function CallControls({ callEmbed }: CallControlsProps) {
if (e.code !== pttKey || e.repeat) return; if (e.code !== pttKey || e.repeat) return;
// Don't intercept keys typed into a text input or editable element // Don't intercept keys typed into a text input or editable element
const target = e.target as HTMLElement; const target = e.target as HTMLElement;
if ( // Skip PTT if key is pressed inside any text-input or editable surface
target.tagName === 'INPUT' || const isEditable = (el: HTMLElement): boolean => {
target.tagName === 'TEXTAREA' || const tag = el.tagName;
target.contentEditable === 'true' if (tag === 'INPUT' || tag === 'TEXTAREA' || tag === 'SELECT') return true;
) return; 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(); e.preventDefault();
if (!microphoneRef.current) callEmbed.control.setMicrophone(true); if (!microphoneRef.current) callEmbed.control.setMicrophone(true);
setPttActive(true); setPttActive(true);
+12
View File
@@ -457,8 +457,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
const handleGifSelect = useCallback( const handleGifSelect = useCallback(
async (gifUrl: string, w: number, h: number) => { async (gifUrl: string, w: number, h: number) => {
try { 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); 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(); const blob = await res.blob();
if (blob.size > 20 * 1024 * 1024) return; // 20 MB cap
const uploadRes = await mx.uploadContent( const uploadRes = await mx.uploadContent(
new File([blob], 'image.gif', { type: 'image/gif' }), new File([blob], 'image.gif', { type: 'image/gif' }),
{ type: 'image/gif', name: 'image.gif', includeFilename: false } { type: 'image/gif', name: 'image.gif', includeFilename: false }
+14 -8
View File
@@ -51,6 +51,12 @@ export class CallEmbed {
private styleRetryObserver?: MutationObserver; 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 { static getIntent(dm: boolean, ongoing: boolean, video?: boolean): ElementCallIntent {
if (dm && ongoing) { if (dm && ongoing) {
return video ? ElementCallIntent.JoinExistingDM : ElementCallIntent.JoinExistingDMVoice; 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 // 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(ClientEvent.Event, this.boundOnEvent);
this.mx.on(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); this.mx.on(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
this.mx.on(RoomStateEvent.Events, this.onStateUpdate.bind(this)); this.mx.on(RoomStateEvent.Events, this.boundOnStateUpdate);
this.mx.on(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); this.mx.on(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);
} }
/** /**
@@ -272,10 +278,10 @@ export class CallEmbed {
this.container.removeChild(this.iframe); this.container.removeChild(this.iframe);
this.control.dispose(); this.control.dispose();
this.mx.off(ClientEvent.Event, this.onEvent.bind(this)); this.mx.off(ClientEvent.Event, this.boundOnEvent);
this.mx.off(MatrixEventEvent.Decrypted, this.onEventDecrypted.bind(this)); this.mx.off(MatrixEventEvent.Decrypted, this.boundOnEventDecrypted);
this.mx.off(RoomStateEvent.Events, this.onStateUpdate.bind(this)); this.mx.off(RoomStateEvent.Events, this.boundOnStateUpdate);
this.mx.off(ClientEvent.ToDeviceEvent, this.onToDeviceEvent.bind(this)); this.mx.off(ClientEvent.ToDeviceEvent, this.boundOnToDeviceEvent);
// Clear internal state // Clear internal state
this.readUpToMap = {}; this.readUpToMap = {};
+2 -1
View File
@@ -36,6 +36,7 @@ export const useCallPreferences = (): CallPreferences & {
}, [setPref, pref]); }, [setPref, pref]);
const toggleVideo = useCallback(() => { const toggleVideo = useCallback(() => {
if (!cameraOnJoin) return;
const video = !pref.video; const video = !pref.video;
setPref({ setPref({
@@ -43,7 +44,7 @@ export const useCallPreferences = (): CallPreferences & {
video, video,
sound: pref.sound, sound: pref.sound,
}); });
}, [setPref, pref]); }, [setPref, pref, cameraOnJoin]);
const toggleSound = useCallback(() => { const toggleSound = useCallback(() => {
const sound = !pref.sound; const sound = !pref.sound;