fix: security hardening and Safari stability

- Remove hardcoded JWT_SECRET fallback (crash on boot if unset)
- Make LiveKit config optional with 503 guard on token endpoint
- Add REST rate limiting via @fastify/rate-limit (auth 10/15m, messages 5/5s, uploads 10/1m, global 60/1m)
- Add WebSocket token bucket rate limiter (30 burst, 2/sec refill)
- Add DM channel ownership (ownerId) with migration, enforce on add-member
- Require friendship to add users to group DMs
- Add silent 20Hz oscillator to prevent Safari AudioContext suspension
- Move WebSocket heartbeat to Web Worker to bypass Safari background throttling
This commit is contained in:
Jannis Braun
2026-02-24 04:34:36 +01:00
parent 36e27121da
commit 024833c470
16 changed files with 205 additions and 36 deletions
+43 -16
View File
@@ -9,10 +9,48 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared'
let globalWs: WebSocket | null = null;
let reconnectAttempts = 0;
let reconnectTimer: ReturnType<typeof setTimeout> | undefined;
let heartbeatInterval: ReturnType<typeof setInterval> | undefined;
let currentToken: string | null = null;
let isInitialized = false;
// Worker-based heartbeat: Safari throttles main-thread setInterval in
// background tabs, causing ping timeouts. A Web Worker's timers run on a
// separate thread and are not subject to the same throttling.
let heartbeatWorker: Worker | null = null;
function createHeartbeatWorker(): Worker {
const blob = new Blob([`
let timerId = null;
self.onmessage = function(e) {
if (e.data === 'start') {
if (timerId) clearInterval(timerId);
timerId = setInterval(function() { self.postMessage('tick'); }, 15000);
} else if (e.data === 'stop') {
if (timerId) { clearInterval(timerId); timerId = null; }
}
};
`], { type: 'application/javascript' });
return new Worker(URL.createObjectURL(blob));
}
function startHeartbeat(ws: WebSocket): void {
stopHeartbeat();
heartbeatWorker = createHeartbeatWorker();
heartbeatWorker.onmessage = () => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
};
heartbeatWorker.postMessage('start');
}
function stopHeartbeat(): void {
if (heartbeatWorker) {
heartbeatWorker.postMessage('stop');
heartbeatWorker.terminate();
heartbeatWorker = null;
}
}
function handleEvent(event: ServerEvent): void {
const { setUser } = useAuthStore.getState();
const { populateFromReady, loadServerDetail, currentServerId, updateMemberPresence, addMember, removeMember, addDmChannel, removeDmChannel } = useServerStore.getState();
@@ -351,13 +389,8 @@ function connect(): void {
reconnectAttempts = 0;
ws.send(JSON.stringify({ type: 'auth', token: currentToken }));
// Start heartbeat to keep connection alive through proxies/NATs
if (heartbeatInterval) clearInterval(heartbeatInterval);
heartbeatInterval = setInterval(() => {
if (ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'ping' }));
}
}, 15_000);
// Start heartbeat via Web Worker (immune to Safari background throttling)
startHeartbeat(ws);
};
ws.onmessage = (e) => {
@@ -371,10 +404,7 @@ function connect(): void {
ws.onclose = () => {
globalWs = null;
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = undefined;
}
stopHeartbeat();
if (currentToken) {
const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
reconnectAttempts++;
@@ -394,10 +424,7 @@ function disconnect(): void {
clearTimeout(reconnectTimer);
reconnectTimer = undefined;
}
if (heartbeatInterval) {
clearInterval(heartbeatInterval);
heartbeatInterval = undefined;
}
stopHeartbeat();
if (globalWs) {
globalWs.close();
globalWs = null;