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
+29 -6
View File
@@ -5,12 +5,13 @@ import { authenticate } from '../utils/auth.js';
import { generateSnowflake } from '../utils/snowflake.js';
import { hasPermission, getChannelSpaceId, PermissionBits } from '../utils/permissions.js';
import { connectionManager } from '../ws/handler.js';
import type {
CreateMessageRequest,
UpdateMessageRequest,
PaginatedQuery,
MessageWithUser,
Reaction,
import {
MAX_MESSAGE_LENGTH,
type CreateMessageRequest,
type UpdateMessageRequest,
type PaginatedQuery,
type MessageWithUser,
type Reaction,
} from '@backspace/shared';
import { sanitizeUser } from '../utils/sanitize.js';
import { deleteAttachmentFiles } from '../utils/fileCleanup.js';
@@ -272,10 +273,28 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Message must have content or attachments', statusCode: 400 });
}
if (content && content.length > MAX_MESSAGE_LENGTH) {
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
}
const db = getDb();
const messageId = generateSnowflake();
const now = Date.now();
// Verify attachment ownership before linking
if (attachmentIds && attachmentIds.length > 0) {
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) {
return reply.code(400).send({ error: 'Invalid or already-used attachment', statusCode: 400 });
}
// Skip ownership check for legacy uploads (null uploaderId)
if (att.uploaderId && att.uploaderId !== request.userId) {
return reply.code(400).send({ error: 'You do not own this attachment', statusCode: 400 });
}
}
}
// Insert message and link attachments atomically
db.transaction((tx) => {
tx.insert(schema.messages).values({
@@ -341,6 +360,10 @@ export async function messageRoutes(app: FastifyInstance): Promise<void> {
return reply.code(400).send({ error: 'Content is required', statusCode: 400 });
}
if (content.length > MAX_MESSAGE_LENGTH) {
return reply.code(400).send({ error: `Message content must be ${MAX_MESSAGE_LENGTH} characters or less`, statusCode: 400 });
}
const db = getDb();
const message = db.select().from(schema.messages).where(eq(schema.messages.id, id)).get();
if (!message) {