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
+18 -2
View File
@@ -19,7 +19,15 @@ function sanitizeUser(row: typeof schema.users.$inferSelect): User {
}
export async function authRoutes(app: FastifyInstance): Promise<void> {
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<void> {
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') {
+35 -10
View File
@@ -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<void> {
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<void> {
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<void> {
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<void> {
const result: DmChannel = {
id: dmChannelId,
ownerId: request.userId,
createdAt: now,
members,
lastMessage: null,
@@ -472,12 +477,33 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
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<void> {
? 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<void> {
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<void> {
// 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;
+5 -1
View File
@@ -9,6 +9,10 @@ export async function livekitRoutes(app: FastifyInstance): Promise<void> {
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<void> {
const requestHost = request.headers.host?.replace(/:\d+$/, '') || '';
const livekitUrl = requestHost
? `wss://${requestHost}/livekit`
: config.livekit.url;
: (config.livekit.url ?? '');
const response: LiveKitTokenResponse = {
token: jwt,
+7
View File
@@ -252,6 +252,13 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
// 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;
+7
View File
@@ -18,6 +18,13 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
// 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) {