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 303f6fbd45
commit d81c3c8721
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;
// 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);
+12
View File
@@ -457,8 +457,20 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
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 }
+14 -8
View File
@@ -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 = {};
+2 -1
View File
@@ -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;