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:
@@ -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);
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user