From 024833c470c6361d13e1fdc1b2f2795f3da8e9f7 Mon Sep 17 00:00:00 2001 From: Jannis Braun <151788261+TheZwiss@users.noreply.github.com> Date: Tue, 24 Feb 2026 04:34:36 +0100 Subject: [PATCH] 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 --- packages/server/package.json | 5 ++- packages/server/src/config.ts | 12 ++++-- packages/server/src/db/index.ts | 1 + packages/server/src/db/migrate.ts | 6 +++ packages/server/src/db/schema.ts | 1 + packages/server/src/index.ts | 7 +++ packages/server/src/routes/auth.ts | 20 ++++++++- packages/server/src/routes/dm.ts | 45 +++++++++++++++----- packages/server/src/routes/livekit.ts | 6 ++- packages/server/src/routes/messages.ts | 7 +++ packages/server/src/routes/uploads.ts | 7 +++ packages/server/src/ws/handler.ts | 37 ++++++++++++++++ packages/shared/src/types.ts | 1 + packages/web/src/audio/AudioManager.ts | 15 ++++++- packages/web/src/hooks/useWebSocket.ts | 59 +++++++++++++++++++------- pnpm-lock.yaml | 12 ++++++ 16 files changed, 205 insertions(+), 36 deletions(-) diff --git a/packages/server/package.json b/packages/server/package.json index c0d34d5c..e2a2897c 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -12,17 +12,18 @@ "dependencies": { "@fastify/cors": "^9.0.1", "@fastify/multipart": "^8.3.0", + "@fastify/rate-limit": "^9.1.0", "@fastify/static": "^7.0.4", "@fastify/websocket": "^10.0.1", "@opencord/shared": "workspace:*", "bcryptjs": "^2.4.3", "better-sqlite3": "^11.3.0", + "cheerio": "^1.0.0", "dotenv": "^16.4.5", "drizzle-orm": "^0.33.0", "fastify": "^4.28.1", "jsonwebtoken": "^9.0.2", - "livekit-server-sdk": "^2.6.1", - "cheerio": "^1.0.0" + "livekit-server-sdk": "^2.6.1" }, "devDependencies": { "@types/bcryptjs": "^2.4.6", diff --git a/packages/server/src/config.ts b/packages/server/src/config.ts index f305117e..5137e05f 100644 --- a/packages/server/src/config.ts +++ b/packages/server/src/config.ts @@ -14,6 +14,10 @@ function env(key: string, defaultValue?: string): string { return value; } +function envOptional(key: string): string | undefined { + return process.env[key] || undefined; +} + function envInt(key: string, defaultValue: number): number { const value = process.env[key]; if (value === undefined) return defaultValue; @@ -33,13 +37,13 @@ function envBool(key: string, defaultValue: boolean): boolean { export const config = { port: envInt('PORT', 3000), host: env('HOST', '0.0.0.0'), - jwtSecret: env('JWT_SECRET', 'dev-secret-change-me-in-production-please-use-64-chars-hex-string'), + jwtSecret: env('JWT_SECRET'), jwtExpiresIn: env('JWT_EXPIRES_IN', '30d'), livekit: { - url: env('LIVEKIT_URL', 'wss://nova.ddns.net/livekit'), - apiKey: env('LIVEKIT_API_KEY', 'REDACTED_LIVEKIT_KEY'), - apiSecret: env('LIVEKIT_API_SECRET', 'REDACTED_LIVEKIT_SECRET'), + url: envOptional('LIVEKIT_URL'), + apiKey: envOptional('LIVEKIT_API_KEY'), + apiSecret: envOptional('LIVEKIT_API_SECRET'), }, uploadDir: env('UPLOAD_DIR', resolve(__dirname, '../../../data/uploads')), diff --git a/packages/server/src/db/index.ts b/packages/server/src/db/index.ts index 35117deb..c1348ad8 100644 --- a/packages/server/src/db/index.ts +++ b/packages/server/src/db/index.ts @@ -76,6 +76,7 @@ function createTables(db: Database.Database): void { CREATE TABLE IF NOT EXISTS dm_channels ( id TEXT PRIMARY KEY, + owner_id TEXT, created_at INTEGER NOT NULL ); diff --git a/packages/server/src/db/migrate.ts b/packages/server/src/db/migrate.ts index c4b4310b..354c718a 100644 --- a/packages/server/src/db/migrate.ts +++ b/packages/server/src/db/migrate.ts @@ -41,6 +41,12 @@ export function runMigrations(db: Database.Database): void { columns: [ { name: 'closed', type: 'INTEGER DEFAULT 0' } ] + }, + { + name: 'dm_channels', + columns: [ + { name: 'owner_id', type: 'TEXT' } + ] } ]; diff --git a/packages/server/src/db/schema.ts b/packages/server/src/db/schema.ts index f8f2fa41..202d67d3 100644 --- a/packages/server/src/db/schema.ts +++ b/packages/server/src/db/schema.ts @@ -68,6 +68,7 @@ export const attachments = sqliteTable('attachments', { export const dmChannels = sqliteTable('dm_channels', { id: text('id').primaryKey(), + ownerId: text('owner_id'), createdAt: integer('created_at').notNull(), }); diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts index 412e8cd0..92632c87 100644 --- a/packages/server/src/index.ts +++ b/packages/server/src/index.ts @@ -1,5 +1,6 @@ import Fastify from 'fastify'; import cors from '@fastify/cors'; +import rateLimit from '@fastify/rate-limit'; import websocket from '@fastify/websocket'; import multipart from '@fastify/multipart'; import fastifyStatic from '@fastify/static'; @@ -34,6 +35,12 @@ async function main(): Promise { allowedHeaders: ['Content-Type', 'Authorization'], }); + await app.register(rateLimit, { + max: 60, + timeWindow: '1 minute', + keyGenerator: (request) => (request as any).userId || request.ip, + }); + await app.register(websocket); await app.register(multipart, { diff --git a/packages/server/src/routes/auth.ts b/packages/server/src/routes/auth.ts index 235a2083..51ffb8e5 100644 --- a/packages/server/src/routes/auth.ts +++ b/packages/server/src/routes/auth.ts @@ -19,7 +19,15 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User { } export async function authRoutes(app: FastifyInstance): Promise { - app.post<{ Body: RegisterRequest }>('/api/auth/register', async (request, reply) => { + app.post<{ Body: RegisterRequest }>('/api/auth/register', { + config: { + rateLimit: { + max: 10, + timeWindow: '15 minutes', + keyGenerator: (request: any) => request.ip, + }, + }, + }, async (request, reply) => { const { username, password, displayName } = request.body; if (!username || typeof username !== 'string') { @@ -83,7 +91,15 @@ export async function authRoutes(app: FastifyInstance): Promise { return reply.code(201).send(response); }); - app.post<{ Body: LoginRequest }>('/api/auth/login', async (request, reply) => { + app.post<{ Body: LoginRequest }>('/api/auth/login', { + config: { + rateLimit: { + max: 10, + timeWindow: '15 minutes', + keyGenerator: (request: any) => request.ip, + }, + }, + }, async (request, reply) => { const { username, password } = request.body; if (!username || typeof username !== 'string') { diff --git a/packages/server/src/routes/dm.ts b/packages/server/src/routes/dm.ts index a99f7785..159a5db9 100644 --- a/packages/server/src/routes/dm.ts +++ b/packages/server/src/routes/dm.ts @@ -1,5 +1,5 @@ import type { FastifyInstance } from 'fastify'; -import { eq, and, desc, lt, inArray } from 'drizzle-orm'; +import { eq, and, or, desc, lt, inArray } from 'drizzle-orm'; import { getDb, schema } from '../db/index.js'; import { authenticate } from '../utils/auth.js'; import { generateSnowflake } from '../utils/snowflake.js'; @@ -189,6 +189,7 @@ export function broadcastDmMessage(dmChannelId: string, message: DmMessageWithUs type: 'dm_channel_created', dmChannel: { id: dmChannel.id, + ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, members: users.map(sanitizeUser), lastMessage: message, @@ -251,6 +252,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { dmChannels.push({ id: dmChannel.id, + ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, members: users.map(sanitizeUser), lastMessage: lastMessage ? { @@ -359,6 +361,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const result: DmChannel = { id: dmChannel.id, + ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, members: users.map(sanitizeUser), lastMessage: lastMsg ? { @@ -381,6 +384,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { db.transaction((tx) => { tx.insert(schema.dmChannels).values({ id: dmChannelId, + ownerId: request.userId, createdAt: now, }).run(); @@ -402,6 +406,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const result: DmChannel = { id: dmChannelId, + ownerId: request.userId, createdAt: now, members, lastMessage: null, @@ -472,12 +477,33 @@ export async function dmRoutes(app: FastifyInstance): Promise { return reply.code(403).send({ error: 'You are not a member of this DM channel', statusCode: 403 }); } + // Enforce DM channel ownership: only the owner can add members (for new-style group DMs) + const dmChannel = db.select().from(schema.dmChannels).where(eq(schema.dmChannels.id, id)).get(); + if (!dmChannel) { + return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 }); + } + if (dmChannel.ownerId && dmChannel.ownerId !== request.userId) { + return reply.code(403).send({ error: 'Only the group owner can add members', statusCode: 403 }); + } + // Validate target user exists const targetUser = db.select().from(schema.users).where(eq(schema.users.id, targetUserId)).get(); if (!targetUser) { return reply.code(404).send({ error: 'User not found', statusCode: 404 }); } + // Validate the adder and target are friends + const friendship = db.select().from(schema.friends).where( + or( + and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, targetUserId)), + and(eq(schema.friends.userId, targetUserId), eq(schema.friends.friendId, request.userId)), + ), + ).get(); + + if (!friendship) { + return reply.code(403).send({ error: 'You can only add friends to group DMs', statusCode: 403 }); + } + // Validate target is not already a member const existingMembership = db.select() .from(schema.dmMembers) @@ -517,15 +543,6 @@ export async function dmRoutes(app: FastifyInstance): Promise { ? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all() : []; - const dmChannel = db.select() - .from(schema.dmChannels) - .where(eq(schema.dmChannels.id, id)) - .get(); - - if (!dmChannel) { - return reply.code(404).send({ error: 'DM channel not found', statusCode: 404 }); - } - // Fetch last message const lastMsgRows = db.select() .from(schema.dmMessages) @@ -537,6 +554,7 @@ export async function dmRoutes(app: FastifyInstance): Promise { const result: DmChannel = { id: dmChannel.id, + ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, members: users.map(sanitizeUser), lastMessage: lastMsg ? { @@ -776,6 +794,13 @@ export async function dmRoutes(app: FastifyInstance): Promise { // POST /api/dm/:id/messages - Send a DM message app.post<{ Params: { id: string }; Body: CreateDmMessageRequest }>('/api/dm/:id/messages', { preHandler: authenticate, + config: { + rateLimit: { + max: 5, + timeWindow: '5 seconds', + keyGenerator: (request: any) => request.userId || request.ip, + }, + }, }, async (request, reply) => { const { id } = request.params; const { content, attachments: attachmentIds, replyToId } = request.body; diff --git a/packages/server/src/routes/livekit.ts b/packages/server/src/routes/livekit.ts index e01b6a49..c67c140d 100644 --- a/packages/server/src/routes/livekit.ts +++ b/packages/server/src/routes/livekit.ts @@ -9,6 +9,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise { app.post<{ Body: LiveKitTokenRequest & { dmChannelId?: string } }>('/api/livekit/token', { preHandler: authenticate, }, async (request, reply) => { + if (!config.livekit.apiKey || !config.livekit.apiSecret) { + return reply.code(503).send({ error: 'Voice/video is not configured on this server', statusCode: 503 }); + } + const { channelId, dmChannelId } = request.body as { channelId?: string; dmChannelId?: string }; // Determine room name based on channel type @@ -54,7 +58,7 @@ export async function livekitRoutes(app: FastifyInstance): Promise { const requestHost = request.headers.host?.replace(/:\d+$/, '') || ''; const livekitUrl = requestHost ? `wss://${requestHost}/livekit` - : config.livekit.url; + : (config.livekit.url ?? ''); const response: LiveKitTokenResponse = { token: jwt, diff --git a/packages/server/src/routes/messages.ts b/packages/server/src/routes/messages.ts index 82477448..e233ff3e 100644 --- a/packages/server/src/routes/messages.ts +++ b/packages/server/src/routes/messages.ts @@ -252,6 +252,13 @@ export async function messageRoutes(app: FastifyInstance): Promise { // POST /api/channels/:id/messages - Create a message app.post<{ Params: { id: string }; Body: CreateMessageRequest }>('/api/channels/:id/messages', { preHandler: authenticate, + config: { + rateLimit: { + max: 5, + timeWindow: '5 seconds', + keyGenerator: (request: any) => request.userId || request.ip, + }, + }, }, async (request, reply) => { const { id } = request.params; const { content, attachments: attachmentIds, replyToId } = request.body; diff --git a/packages/server/src/routes/uploads.ts b/packages/server/src/routes/uploads.ts index 4face880..cfe71ce6 100644 --- a/packages/server/src/routes/uploads.ts +++ b/packages/server/src/routes/uploads.ts @@ -18,6 +18,13 @@ export async function uploadRoutes(app: FastifyInstance): Promise { // POST /api/uploads - Upload a file app.post('/api/uploads', { preHandler: authenticate, + config: { + rateLimit: { + max: 10, + timeWindow: '1 minute', + keyGenerator: (request: any) => (request as any).userId || request.ip, + }, + }, }, async (request, reply) => { const data = await request.file(); if (!data) { diff --git a/packages/server/src/ws/handler.ts b/packages/server/src/ws/handler.ts index 7d5ab083..1ec748d6 100644 --- a/packages/server/src/ws/handler.ts +++ b/packages/server/src/ws/handler.ts @@ -473,6 +473,35 @@ class ConnectionManager { export const connectionManager = new ConnectionManager(); +// ─── WebSocket Rate Limiter (Token Bucket) ───────────────────────────────── + +class WsRateLimiter { + private tokens: number; + private readonly maxTokens: number; + private readonly refillRate: number; // tokens per second + private lastRefill: number; + + constructor(maxTokens = 30, refillRate = 2) { + this.maxTokens = maxTokens; + this.tokens = maxTokens; + this.refillRate = refillRate; + this.lastRefill = Date.now(); + } + + consume(): boolean { + const now = Date.now(); + const elapsed = (now - this.lastRefill) / 1000; + this.tokens = Math.min(this.maxTokens, this.tokens + elapsed * this.refillRate); + this.lastRefill = now; + + if (this.tokens >= 1) { + this.tokens -= 1; + return true; + } + return false; + } +} + function buildReadyPayload(userId: string): { user: User; servers: ServerWithChannelsAndMembers[]; @@ -685,6 +714,7 @@ function buildReadyPayload(userId: string): { dmChannels.push({ id: dmChannel.id, + ownerId: dmChannel.ownerId ?? null, createdAt: dmChannel.createdAt, members, lastMessage: last ? { @@ -791,6 +821,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise { let authenticated = false; let userId: string | undefined; let username: string | undefined; + const rateLimiter = new WsRateLimiter(); // Set auth timeout - must authenticate within 10 seconds const authTimeout = setTimeout(() => { @@ -861,6 +892,12 @@ export async function registerWebSocket(app: FastifyInstance): Promise { return; } + // Rate limit all post-auth, non-ping messages + if (!rateLimiter.consume()) { + ws.send(JSON.stringify({ type: 'error', message: 'Rate limited' })); + return; + } + // Handle authenticated events if (userId && username) { handleClientEvent(parsed, userId, username); diff --git a/packages/shared/src/types.ts b/packages/shared/src/types.ts index 82fa6e38..5c404839 100644 --- a/packages/shared/src/types.ts +++ b/packages/shared/src/types.ts @@ -149,6 +149,7 @@ export interface ActiveCallInfo { export interface DmChannel { id: string; + ownerId?: string | null; createdAt: number; members: User[]; lastMessage?: DmMessage | null; diff --git a/packages/web/src/audio/AudioManager.ts b/packages/web/src/audio/AudioManager.ts index 792165ec..5dbad8d0 100644 --- a/packages/web/src/audio/AudioManager.ts +++ b/packages/web/src/audio/AudioManager.ts @@ -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') { diff --git a/packages/web/src/hooks/useWebSocket.ts b/packages/web/src/hooks/useWebSocket.ts index 0b178ea0..bc58235c 100644 --- a/packages/web/src/hooks/useWebSocket.ts +++ b/packages/web/src/hooks/useWebSocket.ts @@ -9,10 +9,48 @@ import type { ServerEvent, ClientEvent, ActiveCallInfo } from '@opencord/shared' let globalWs: WebSocket | null = null; let reconnectAttempts = 0; let reconnectTimer: ReturnType | undefined; -let heartbeatInterval: ReturnType | 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; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 34374427..cb20aabe 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -32,6 +32,9 @@ importers: '@fastify/multipart': specifier: ^8.3.0 version: 8.3.1 + '@fastify/rate-limit': + specifier: ^9.1.0 + version: 9.1.0 '@fastify/static': specifier: ^7.0.4 version: 7.0.4 @@ -980,6 +983,9 @@ packages: '@fastify/multipart@8.3.1': resolution: {integrity: sha512-pncbnG28S6MIskFSVRtzTKE9dK+GrKAJl0NbaQ/CG8ded80okWFsYKzSlP9haaLNQhNRDOoHqmGQNvgbiPVpWQ==} + '@fastify/rate-limit@9.1.0': + resolution: {integrity: sha512-h5dZWCkuZXN0PxwqaFQLxeln8/LNwQwH9popywmDCFdKfgpi4b/HoMH1lluy6P+30CG9yzzpSpwTCIPNB9T1JA==} + '@fastify/send@2.1.0': resolution: {integrity: sha512-yNYiY6sDkexoJR0D8IDy3aRP3+L4wdqCpvx5WP+VtEU58sn7USmKynBzDQex5X42Zzvw2gNzzYgP90UfWShLFA==} @@ -4660,6 +4666,12 @@ snapshots: secure-json-parse: 2.7.0 stream-wormhole: 1.1.0 + '@fastify/rate-limit@9.1.0': + dependencies: + '@lukeed/ms': 2.0.2 + fastify-plugin: 4.5.1 + toad-cache: 3.7.0 + '@fastify/send@2.1.0': dependencies: '@lukeed/ms': 2.0.2