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