feat: security hardening, DB indexes, token revocation, and input validation
- SSRF protection: DNS resolution + private IP blocking on metadata fetcher - Upload security: CSP/X-Frame-Options headers, SVG forced download, nosniff - Auth hardening: JWT secret min length, password min 8 chars, token revocation via password_changed_at - Attachment ownership verification before linking to messages - Message length limit (4000 chars) enforced on client and server - Asset URL validation on avatar/banner updates - Federation instance validation (domain regex, origin scheme, length limits) - DB indexes on all FK columns for query performance - Migrations: nullable moderator columns, dm_messages reply_to FK constraint - File cleanup on avatar/banner replacement and space deletion - Fastify trustProxy, AbortController on fetches, typing map size cap
This commit is contained in:
@@ -89,6 +89,8 @@ class ConnectionManager {
|
||||
private spaceDeafenedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||
// Permission-muted users (SPEAK permission revoked while in voice)
|
||||
private permissionMutedUsers: Set<string> = new Set(); // Stores spaceId:userId
|
||||
// Per-user WebSocket rate limiters (shared across all tabs/connections)
|
||||
private userRateLimiters: Map<string, WsRateLimiter> = new Map();
|
||||
|
||||
addConnection(userId: string, ws: WebSocket): void {
|
||||
if (!this.connections.has(userId)) {
|
||||
@@ -203,6 +205,9 @@ class ConnectionManager {
|
||||
|
||||
// Clean up userSpaces (re-populated on next connect via setUserSpaces)
|
||||
this.userSpaces.delete(userId);
|
||||
|
||||
// Clean up per-user rate limiter
|
||||
this.userRateLimiters.delete(userId);
|
||||
}
|
||||
|
||||
getUserConnections(userId: string): Set<WebSocket> {
|
||||
@@ -214,6 +219,15 @@ class ConnectionManager {
|
||||
return conns !== undefined && conns.size > 0;
|
||||
}
|
||||
|
||||
getUserRateLimiter(userId: string): WsRateLimiter {
|
||||
let limiter = this.userRateLimiters.get(userId);
|
||||
if (!limiter) {
|
||||
limiter = new WsRateLimiter();
|
||||
this.userRateLimiters.set(userId, limiter);
|
||||
}
|
||||
return limiter;
|
||||
}
|
||||
|
||||
setUserSpaces(userId: string, spaceIds: string[]): void {
|
||||
this.userSpaces.set(userId, new Set(spaceIds));
|
||||
}
|
||||
@@ -1068,7 +1082,6 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
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(() => {
|
||||
@@ -1104,7 +1117,7 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
userId = payload.userId;
|
||||
username = payload.username;
|
||||
|
||||
// Reject deleted users
|
||||
// Reject deleted users and revoked tokens
|
||||
const db = getDb();
|
||||
const userRow = db.select().from(schema.users).where(eq(schema.users.id, userId)).get();
|
||||
if (!userRow || userRow.isDeleted) {
|
||||
@@ -1112,6 +1125,14 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
// Token revocation: reject tokens issued before last password change
|
||||
if (userRow.passwordChangedAt && payload.iat) {
|
||||
if (payload.iat < Math.floor(userRow.passwordChangedAt / 1000)) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Token has been revoked' }));
|
||||
ws.close();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
authenticated = true;
|
||||
clearTimeout(authTimeout);
|
||||
@@ -1155,15 +1176,22 @@ export async function registerWebSocket(app: FastifyInstance): Promise<void> {
|
||||
return;
|
||||
}
|
||||
|
||||
// Rate limit all post-auth, non-ping messages
|
||||
if (!rateLimiter.consume()) {
|
||||
// Rate limit all post-auth, non-ping messages (per-user, shared across tabs)
|
||||
if (!connectionManager.getUserRateLimiter(userId!).consume()) {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Rate limited' }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle authenticated events
|
||||
if (userId && username) {
|
||||
handleClientEvent(parsed, userId, username);
|
||||
try {
|
||||
handleClientEvent(parsed, userId, username);
|
||||
} catch (err) {
|
||||
app.log.error({ err, eventType: parsed.type, userId }, 'Unhandled error in WS event handler');
|
||||
try {
|
||||
ws.send(JSON.stringify({ type: 'error', message: 'Internal server error' }));
|
||||
} catch { /* ws may already be closed */ }
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user