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:
@@ -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') {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user