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
+38
View File
@@ -7,6 +7,7 @@ import { isMember, isSpaceOwner, isBanned, hasPermission, computePermissions, Pe
import { DEFAULT_EVERYONE_PERMISSIONS, ALL_PERMISSIONS, permissionsToString } from '@backspace/shared/src/permissions.js';
import crypto from 'crypto';
import { connectionManager } from '../ws/handler.js';
import { deleteAttachmentFiles, deleteUploadFile } from '../utils/fileCleanup.js';
import type {
CreateSpaceRequest,
UpdateSpaceRequest,
@@ -367,6 +368,10 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
updates.name = trimmedName;
}
// Track old files for cleanup after update
const oldIcon = server.icon;
const oldBanner = server.banner;
if (icon !== undefined) {
updates.icon = icon || null;
}
@@ -404,6 +409,14 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
db.update(schema.spaces).set(updates).where(eq(schema.spaces.id, id)).run();
// Clean up old icon/banner files that were replaced
if (icon !== undefined && oldIcon && oldIcon !== (icon || null) && !oldIcon.startsWith('http')) {
deleteUploadFile(oldIcon);
}
if (banner !== undefined && oldBanner && oldBanner !== (banner || null) && !oldBanner.startsWith('http')) {
deleteUploadFile(oldBanner);
}
const updated = db.select().from(schema.spaces).where(eq(schema.spaces.id, id)).get();
if (!updated) {
return reply.code(500).send({ error: 'Failed to update space', statusCode: 500 });
@@ -436,6 +449,24 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
return reply.code(403).send({ error: 'Only the space owner can delete the space', statusCode: 403 });
}
// Collect all attachment files before cascade-deleting DB records
const channelIds = db.select({ id: schema.channels.id })
.from(schema.channels).where(eq(schema.channels.spaceId, id)).all().map(c => c.id);
let attachmentRows: { filename: string }[] = [];
if (channelIds.length > 0) {
const messageIds = db.select({ id: schema.messages.id })
.from(schema.messages).where(inArray(schema.messages.channelId, channelIds)).all().map(m => m.id);
if (messageIds.length > 0) {
attachmentRows = db.select({ filename: schema.attachments.filename })
.from(schema.attachments).where(inArray(schema.attachments.messageId, messageIds)).all();
}
}
// Capture space icon/banner before deletion
const spaceIcon = server.icon;
const spaceBanner = server.banner;
// Delete all channels (messages cascade), members, folder refs, then space atomically
db.transaction((tx) => {
tx.delete(schema.channels).where(eq(schema.channels.spaceId, id)).run();
@@ -444,6 +475,13 @@ export async function spaceRoutes(app: FastifyInstance): Promise<void> {
tx.delete(schema.spaces).where(eq(schema.spaces.id, id)).run();
});
// Clean up all attachment files from disk
deleteAttachmentFiles(attachmentRows);
// Clean up space icon/banner files
if (spaceIcon && !spaceIcon.startsWith('http')) deleteUploadFile(spaceIcon);
if (spaceBanner && !spaceBanner.startsWith('http')) deleteUploadFile(spaceBanner);
return reply.code(200).send({ success: true });
});