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:
@@ -5,8 +5,9 @@ import { connectionManager } from './handler.js';
|
||||
import type { VoiceRoom, DmRoomMeta, SpaceRoomMeta } from './handler.js';
|
||||
import { isMember, getChannelSpaceId, isDmMember, hasPermission, computePermissions, PermissionBits } from '../utils/permissions.js';
|
||||
import { broadcastDmMessage, getDmMessageWithUser } from '../routes/dm.js';
|
||||
import type { MessageWithUser, Attachment, DmMessageWithUser } from '@backspace/shared';
|
||||
import { MAX_MESSAGE_LENGTH, type MessageWithUser, type Attachment, type DmMessageWithUser } from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
|
||||
/**
|
||||
* Re-evaluate SPEAK permission for all participants in voice channels
|
||||
@@ -113,7 +114,8 @@ function getMessageWithUser(messageId: string): MessageWithUser | null {
|
||||
};
|
||||
}
|
||||
|
||||
// Typing timeout tracking
|
||||
// Typing timeout tracking (capped to prevent unbounded growth)
|
||||
const MAX_TYPING_ENTRIES = 10_000;
|
||||
const typingTimeouts: Map<string, NodeJS.Timeout> = new Map();
|
||||
|
||||
export function handleClientEvent(
|
||||
@@ -216,6 +218,11 @@ function handleMessageCreate(event: Record<string, unknown>, userId: string): vo
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.length > MAX_MESSAGE_LENGTH) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
||||
return;
|
||||
}
|
||||
|
||||
const spaceId = getChannelSpaceId(channelId);
|
||||
if (!spaceId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Channel not found' });
|
||||
@@ -264,6 +271,11 @@ function handleMessageEdit(event: Record<string, unknown>, userId: string): void
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.length > MAX_MESSAGE_LENGTH) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const message = db.select().from(schema.messages).where(eq(schema.messages.id, messageId)).get();
|
||||
if (!message) {
|
||||
@@ -321,9 +333,18 @@ function handleMessageDelete(event: Record<string, unknown>, userId: string): vo
|
||||
return;
|
||||
}
|
||||
|
||||
// Delete attachments then message
|
||||
db.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
|
||||
db.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
|
||||
// Collect attachment filenames before deletion
|
||||
const attachmentRows = db.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments).where(eq(schema.attachments.messageId, messageId)).all();
|
||||
|
||||
// Delete attachments + message atomically, file cleanup outside transaction
|
||||
db.transaction((tx) => {
|
||||
tx.delete(schema.attachments).where(eq(schema.attachments.messageId, messageId)).run();
|
||||
tx.delete(schema.messages).where(eq(schema.messages.id, messageId)).run();
|
||||
});
|
||||
|
||||
// Clean up files from disk (outside transaction — file I/O)
|
||||
deleteAttachmentFiles(attachmentRows);
|
||||
|
||||
connectionManager.sendToChannel(spaceId, message.channelId, {
|
||||
type: 'message_deleted',
|
||||
@@ -357,6 +378,9 @@ function handleTypingStart(event: Record<string, unknown>, userId: string, usern
|
||||
username,
|
||||
}, userId);
|
||||
|
||||
// Safety cap: skip if Map is at max capacity (auto-expiry handles normal cleanup)
|
||||
if (!existing && typingTimeouts.size >= MAX_TYPING_ENTRIES) return;
|
||||
|
||||
// Auto-expire typing after 5 seconds
|
||||
const timeout = setTimeout(() => {
|
||||
typingTimeouts.delete(key);
|
||||
@@ -674,12 +698,33 @@ function handleDmMessageCreate(event: Record<string, unknown>, userId: string):
|
||||
return;
|
||||
}
|
||||
|
||||
if (hasContent && content!.length > MAX_MESSAGE_LENGTH) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!isDmMember(dmChannelId, userId)) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Not a member of this DM channel' });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
// Verify attachment ownership before linking
|
||||
if (hasAttachments) {
|
||||
for (const attId of attachmentIds) {
|
||||
const att = db.select().from(schema.attachments).where(eq(schema.attachments.id, attId)).get();
|
||||
if (!att || att.messageId || att.dmMessageId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'Invalid or already-used attachment' });
|
||||
return;
|
||||
}
|
||||
if (att.uploaderId && att.uploaderId !== userId) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: 'You do not own this attachment' });
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const messageId = generateSnowflake();
|
||||
const now = Date.now();
|
||||
|
||||
@@ -760,6 +805,11 @@ function handleDmMessageEdit(event: Record<string, unknown>, userId: string): vo
|
||||
return;
|
||||
}
|
||||
|
||||
if (content.length > MAX_MESSAGE_LENGTH) {
|
||||
connectionManager.sendToUser(userId, { type: 'error', message: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less` });
|
||||
return;
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
const msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, messageId)).get();
|
||||
if (!msg) {
|
||||
@@ -814,6 +864,10 @@ function handleDmMessageDelete(event: Record<string, unknown>, userId: string):
|
||||
return;
|
||||
}
|
||||
|
||||
// Collect attachment filenames before deletion
|
||||
const dmAttachmentRows = db.select({ filename: schema.attachments.filename })
|
||||
.from(schema.attachments).where(eq(schema.attachments.dmMessageId, messageId)).all();
|
||||
|
||||
// Delete attachments linked to this DM message
|
||||
db.delete(schema.attachments)
|
||||
.where(eq(schema.attachments.dmMessageId, messageId))
|
||||
@@ -829,6 +883,9 @@ function handleDmMessageDelete(event: Record<string, unknown>, userId: string):
|
||||
.where(eq(schema.dmMessages.id, messageId))
|
||||
.run();
|
||||
|
||||
// Clean up files from disk
|
||||
deleteAttachmentFiles(dmAttachmentRows);
|
||||
|
||||
const dmMembers = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, msg.dmChannelId))
|
||||
|
||||
@@ -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