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))
|
||||
|
||||
Reference in New Issue
Block a user