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
+14 -1
View File
@@ -30,6 +30,7 @@ export class AudioManager {
private stereoMerger: ChannelMergerNode | null = null;
private rnnoiseEnabled = false;
private rnnoiseReady = false;
private keepAliveOscillator: OscillatorNode | null = null;
private constructor() {}
@@ -69,7 +70,19 @@ export class AudioManager {
this.silentGain.connect(this.ctx.destination);
this.inputGain.gain.setValueAtTime(1, this.ctx.currentTime);
// Safari suspends the AudioContext when it detects no audible output,
// even while WebRTC audio is flowing through the pipeline. A sub-bass
// oscillator at near-zero gain keeps the rendering thread alive without
// producing audible sound.
this.keepAliveOscillator = this.ctx.createOscillator();
this.keepAliveOscillator.frequency.value = 20;
const keepAliveGain = this.ctx.createGain();
keepAliveGain.gain.value = 0.00001;
this.keepAliveOscillator.connect(keepAliveGain);
keepAliveGain.connect(this.ctx.destination);
this.keepAliveOscillator.start();
this.ctx.onstatechange = () => {
console.log(`[AudioManager] Context state: ${this.ctx?.state}`);
if (this.ctx?.state === 'running') {
+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;