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:
Jannis Braun
2026-03-15 00:06:15 +01:00
parent ed4dcdcf69
commit 7c544c1ff4
37 changed files with 892 additions and 178 deletions
+33 -5
View File
@@ -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 */ }
}
}
});