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:
@@ -188,7 +188,7 @@ export async function adminRoutes(app: FastifyInstance): Promise<void> {
|
||||
const hash = await hashPassword(temporaryPassword);
|
||||
|
||||
db.update(schema.users)
|
||||
.set({ passwordHash: hash })
|
||||
.set({ passwordHash: hash, passwordChangedAt: Date.now() })
|
||||
.where(eq(schema.users.id, targetId))
|
||||
.run();
|
||||
|
||||
|
||||
@@ -68,8 +68,8 @@ export async function authRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
if (password.length < 6) {
|
||||
return reply.code(400).send({ error: 'Password must be at least 6 characters', statusCode: 400 });
|
||||
if (password.length < 8) {
|
||||
return reply.code(400).send({ error: 'Password must be at least 8 characters', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
|
||||
@@ -1,20 +1,21 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, or, desc, lt, inArray } from 'drizzle-orm';
|
||||
import { eq, and, or, desc, lt, inArray, sql } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { isDmMember } from '../utils/permissions.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import type {
|
||||
DmChannel,
|
||||
DmMessage,
|
||||
DmMessageWithUser,
|
||||
CreateDmRequest,
|
||||
CreateDmMessageRequest,
|
||||
AddDmMemberRequest,
|
||||
PaginatedQuery,
|
||||
Attachment,
|
||||
Reaction,
|
||||
import {
|
||||
MAX_MESSAGE_LENGTH,
|
||||
type DmChannel,
|
||||
type DmMessage,
|
||||
type DmMessageWithUser,
|
||||
type CreateDmRequest,
|
||||
type CreateDmMessageRequest,
|
||||
type AddDmMemberRequest,
|
||||
type PaginatedQuery,
|
||||
type Attachment,
|
||||
type Reaction,
|
||||
} from '@backspace/shared';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import { deleteUploadFile, deleteAttachmentFiles } from '../utils/fileCleanup.js';
|
||||
@@ -210,47 +211,90 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
))
|
||||
.all();
|
||||
|
||||
const dmChannels: DmChannel[] = [];
|
||||
if (memberships.length === 0) {
|
||||
return reply.code(200).send([]);
|
||||
}
|
||||
|
||||
for (const membership of memberships) {
|
||||
const dmChannel = db.select()
|
||||
.from(schema.dmChannels)
|
||||
.where(eq(schema.dmChannels.id, membership.dmChannelId))
|
||||
.get();
|
||||
const dmChannelIds = memberships.map(m => m.dmChannelId);
|
||||
|
||||
if (!dmChannel) continue;
|
||||
// Batch fetch all DM channels
|
||||
const channelRows = db.select().from(schema.dmChannels)
|
||||
.where(inArray(schema.dmChannels.id, dmChannelIds)).all();
|
||||
const channelMap = new Map(channelRows.map(c => [c.id, c]));
|
||||
|
||||
const dmMemberRows = db.select()
|
||||
.from(schema.dmMembers)
|
||||
.where(eq(schema.dmMembers.dmChannelId, membership.dmChannelId))
|
||||
.all();
|
||||
// Batch fetch all DM members
|
||||
const allMemberRows = db.select().from(schema.dmMembers)
|
||||
.where(inArray(schema.dmMembers.dmChannelId, dmChannelIds)).all();
|
||||
const membersByChannel = new Map<string, string[]>();
|
||||
for (const m of allMemberRows) {
|
||||
if (!membersByChannel.has(m.dmChannelId)) membersByChannel.set(m.dmChannelId, []);
|
||||
membersByChannel.get(m.dmChannelId)!.push(m.userId);
|
||||
}
|
||||
|
||||
const memberUserIds = dmMemberRows.map(m => m.userId);
|
||||
const users = memberUserIds.length > 0
|
||||
? db.select().from(schema.users).where(inArray(schema.users.id, memberUserIds)).all()
|
||||
: [];
|
||||
// Batch fetch all unique users
|
||||
const allUserIds = [...new Set(allMemberRows.map(m => m.userId))];
|
||||
const userRows = allUserIds.length > 0
|
||||
? db.select().from(schema.users).where(inArray(schema.users.id, allUserIds)).all()
|
||||
: [];
|
||||
const userMap = new Map(userRows.map(u => [u.id, u]));
|
||||
|
||||
// Get last message
|
||||
const allMessages = db.select()
|
||||
// Batch fetch last message per DM channel:
|
||||
// Get the max created_at per channel, then fetch matching messages
|
||||
const maxTimestamps = db.select({
|
||||
dmChannelId: schema.dmMessages.dmChannelId,
|
||||
maxCreatedAt: sql<number>`MAX(${schema.dmMessages.createdAt})`.as('max_created_at'),
|
||||
})
|
||||
.from(schema.dmMessages)
|
||||
.where(inArray(schema.dmMessages.dmChannelId, dmChannelIds))
|
||||
.groupBy(schema.dmMessages.dmChannelId)
|
||||
.all();
|
||||
|
||||
const lastMessageMap = new Map<string, { id: string; dmChannelId: string; userId: string; content: string | null; createdAt: number }>();
|
||||
if (maxTimestamps.length > 0) {
|
||||
// Build conditions to fetch the actual message rows matching max timestamps
|
||||
const conditions = maxTimestamps.map(t =>
|
||||
and(eq(schema.dmMessages.dmChannelId, t.dmChannelId), eq(schema.dmMessages.createdAt, t.maxCreatedAt!))
|
||||
);
|
||||
const lastMessages = db.select()
|
||||
.from(schema.dmMessages)
|
||||
.where(eq(schema.dmMessages.dmChannelId, membership.dmChannelId))
|
||||
.orderBy(desc(schema.dmMessages.createdAt))
|
||||
.limit(1)
|
||||
.where(or(...conditions))
|
||||
.all();
|
||||
for (const m of lastMessages) {
|
||||
// In case of ties, keep the first one per channel
|
||||
if (!lastMessageMap.has(m.dmChannelId)) {
|
||||
lastMessageMap.set(m.dmChannelId, {
|
||||
id: m.id, dmChannelId: m.dmChannelId, userId: m.userId,
|
||||
content: m.content, createdAt: m.createdAt,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const lastMessage = allMessages[0] ?? null;
|
||||
// Assemble results
|
||||
const dmChannels: DmChannel[] = [];
|
||||
for (const channelId of dmChannelIds) {
|
||||
const channel = channelMap.get(channelId);
|
||||
if (!channel) continue;
|
||||
|
||||
const memberIds = membersByChannel.get(channelId) ?? [];
|
||||
const members = memberIds
|
||||
.map(id => userMap.get(id))
|
||||
.filter((u): u is NonNullable<typeof u> => u !== undefined)
|
||||
.map(sanitizeUser);
|
||||
|
||||
const lastMsg = lastMessageMap.get(channelId) ?? null;
|
||||
|
||||
dmChannels.push({
|
||||
id: dmChannel.id,
|
||||
ownerId: dmChannel.ownerId ?? null,
|
||||
createdAt: dmChannel.createdAt,
|
||||
members: users.map(sanitizeUser),
|
||||
lastMessage: lastMessage ? {
|
||||
id: lastMessage.id,
|
||||
dmChannelId: lastMessage.dmChannelId,
|
||||
userId: lastMessage.userId,
|
||||
content: lastMessage.content,
|
||||
createdAt: lastMessage.createdAt,
|
||||
id: channel.id,
|
||||
ownerId: channel.ownerId ?? null,
|
||||
createdAt: channel.createdAt,
|
||||
members,
|
||||
lastMessage: lastMsg ? {
|
||||
id: lastMsg.id,
|
||||
dmChannelId: lastMsg.dmChannelId,
|
||||
userId: lastMsg.userId,
|
||||
content: lastMsg.content,
|
||||
createdAt: lastMsg.createdAt,
|
||||
} : null,
|
||||
});
|
||||
}
|
||||
@@ -848,10 +892,27 @@ export async function dmRoutes(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 });
|
||||
}
|
||||
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.dmMessages).values({
|
||||
@@ -895,6 +956,10 @@ export async function dmRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'Message 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 msg = db.select().from(schema.dmMessages).where(eq(schema.dmMessages.id, id)).get();
|
||||
|
||||
@@ -3,7 +3,7 @@ import { eq, and, sql, inArray } from 'drizzle-orm';
|
||||
import { getDb, getRawDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
import { isMember, isSpaceOwner, hasPermission, computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
|
||||
import { isMember, isBanned, isSpaceOwner, hasPermission, computePermissions, PermissionBits, permissionsToString } from '../utils/permissions.js';
|
||||
import { connectionManager } from '../ws/handler.js';
|
||||
import { sanitizeUser } from '../utils/sanitize.js';
|
||||
import type {
|
||||
@@ -282,6 +282,10 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'This space does not allow public joins', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isBanned(id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isMember(id, request.userId)) {
|
||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||
}
|
||||
@@ -345,6 +349,10 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(403).send({ error: 'This space does not accept join requests', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isBanned(id, request.userId)) {
|
||||
return reply.code(403).send({ error: 'You are banned from this space', statusCode: 403 });
|
||||
}
|
||||
|
||||
if (isMember(id, request.userId)) {
|
||||
return reply.code(409).send({ error: 'You are already a member of this space', statusCode: 409 });
|
||||
}
|
||||
@@ -410,6 +418,9 @@ export async function exploreRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
|
||||
const statusFilter = request.query.status ?? 'pending';
|
||||
if (!['pending', 'accepted', 'declined'].includes(statusFilter)) {
|
||||
return reply.code(400).send({ error: 'Status must be one of: pending, accepted, declined', statusCode: 400 });
|
||||
}
|
||||
const rows = db.select().from(schema.joinRequests)
|
||||
.where(and(
|
||||
eq(schema.joinRequests.spaceId, id),
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import { eq, and, or, ne, like, sql } from 'drizzle-orm';
|
||||
import { eq, and, or, ne, like, sql, inArray } from 'drizzle-orm';
|
||||
import { getDb, schema } from '../db/index.js';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
@@ -284,6 +284,16 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
||||
const { id } = request.params;
|
||||
const db = getDb();
|
||||
|
||||
// Check friendship exists before deleting
|
||||
const existing = db.select().from(schema.friends).where(or(
|
||||
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)),
|
||||
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
|
||||
)).get();
|
||||
|
||||
if (!existing) {
|
||||
return reply.code(404).send({ error: 'You are not friends with this user', statusCode: 404 });
|
||||
}
|
||||
|
||||
db.delete(schema.friends).where(or(
|
||||
and(eq(schema.friends.userId, request.userId), eq(schema.friends.friendId, id)),
|
||||
and(eq(schema.friends.userId, id), eq(schema.friends.friendId, request.userId))
|
||||
@@ -301,6 +311,7 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/social/discover - Discover users on this instance
|
||||
app.get<{ Querystring: { q?: string; limit?: string; offset?: string } }>('/api/social/discover', {
|
||||
preHandler: authenticate,
|
||||
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
||||
}, async (request, reply) => {
|
||||
const db = getDb();
|
||||
const q = request.query.q?.trim() || '';
|
||||
@@ -368,23 +379,58 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
||||
.offset(offset)
|
||||
.all();
|
||||
|
||||
// Batch fetch friends and space memberships for all page users
|
||||
const pageUserIds = userRows.map(r => r.id);
|
||||
|
||||
// Batch fetch all friends for page users
|
||||
const pageFriendRows = pageUserIds.length > 0
|
||||
? db.select().from(schema.friends).where(
|
||||
or(
|
||||
inArray(schema.friends.userId, pageUserIds),
|
||||
inArray(schema.friends.friendId, pageUserIds),
|
||||
)
|
||||
).all()
|
||||
: [];
|
||||
|
||||
// Build Map<userId, Set<friendId>> for page users
|
||||
const friendIdsByUser = new Map<string, Set<string>>();
|
||||
for (const f of pageFriendRows) {
|
||||
// Map both directions
|
||||
if (pageUserIds.includes(f.userId)) {
|
||||
if (!friendIdsByUser.has(f.userId)) friendIdsByUser.set(f.userId, new Set());
|
||||
friendIdsByUser.get(f.userId)!.add(f.friendId);
|
||||
}
|
||||
if (pageUserIds.includes(f.friendId)) {
|
||||
if (!friendIdsByUser.has(f.friendId)) friendIdsByUser.set(f.friendId, new Set());
|
||||
friendIdsByUser.get(f.friendId)!.add(f.userId);
|
||||
}
|
||||
}
|
||||
|
||||
// Batch fetch all space memberships for page users
|
||||
const pageSpaceMemberRows = pageUserIds.length > 0
|
||||
? db.select({ userId: schema.spaceMembers.userId, spaceId: schema.spaceMembers.spaceId })
|
||||
.from(schema.spaceMembers)
|
||||
.where(inArray(schema.spaceMembers.userId, pageUserIds))
|
||||
.all()
|
||||
: [];
|
||||
|
||||
// Build Map<userId, Set<spaceId>> for page users
|
||||
const spaceIdsByUser = new Map<string, Set<string>>();
|
||||
for (const sm of pageSpaceMemberRows) {
|
||||
if (!spaceIdsByUser.has(sm.userId)) spaceIdsByUser.set(sm.userId, new Set());
|
||||
spaceIdsByUser.get(sm.userId)!.add(sm.spaceId);
|
||||
}
|
||||
|
||||
// Compute mutual counts + relationship for each user
|
||||
const discoverUsers: DiscoverUser[] = userRows.map(row => {
|
||||
const u = sanitizeUser(row);
|
||||
|
||||
// Mutual friends
|
||||
const theirFriendRows = db.select().from(schema.friends).where(
|
||||
or(eq(schema.friends.userId, row.id), eq(schema.friends.friendId, row.id))
|
||||
).all();
|
||||
const theirFriendIds = new Set(theirFriendRows.map(f => f.userId === row.id ? f.friendId : f.userId));
|
||||
// Mutual friends (using batch-fetched data)
|
||||
const theirFriendIds = friendIdsByUser.get(row.id) ?? new Set();
|
||||
const mutualFriendCount = [...myFriendIds].filter(id => theirFriendIds.has(id)).length;
|
||||
|
||||
// Mutual spaces
|
||||
const theirSpaceRows = db.select({ spaceId: schema.spaceMembers.spaceId })
|
||||
.from(schema.spaceMembers)
|
||||
.where(eq(schema.spaceMembers.userId, row.id))
|
||||
.all();
|
||||
const theirSpaceIds = new Set(theirSpaceRows.map(s => s.spaceId));
|
||||
// Mutual spaces (using batch-fetched data)
|
||||
const theirSpaceIds = spaceIdsByUser.get(row.id) ?? new Set();
|
||||
const mutualSpaceCount = [...mySpaceIds].filter(id => theirSpaceIds.has(id)).length;
|
||||
|
||||
// Relationship
|
||||
@@ -432,6 +478,7 @@ export async function socialRoutes(app: FastifyInstance): Promise<void> {
|
||||
// GET /api/social/search?q=... - Search for users to add as friends
|
||||
app.get<{ Querystring: { q: string } }>('/api/social/search', {
|
||||
preHandler: authenticate,
|
||||
config: { rateLimit: { max: 30, timeWindow: '1 minute' } },
|
||||
}, async (request, reply) => {
|
||||
const { q } = request.query;
|
||||
const db = getDb();
|
||||
|
||||
@@ -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 });
|
||||
});
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
||||
// Save attachment record
|
||||
db.insert(schema.attachments).values({
|
||||
id,
|
||||
uploaderId: request.userId,
|
||||
filename,
|
||||
originalName,
|
||||
mimetype,
|
||||
@@ -120,12 +121,16 @@ export async function uploadRoutes(app: FastifyInstance): Promise<void> {
|
||||
?? EXT_MIMETYPES[path.extname(safeName).toLowerCase()]
|
||||
?? 'application/octet-stream';
|
||||
|
||||
// Set caching headers
|
||||
// Set caching and security headers
|
||||
reply.header('Cache-Control', 'public, max-age=31536000, immutable');
|
||||
reply.header('Content-Type', mimetype);
|
||||
reply.header('X-Content-Type-Options', 'nosniff');
|
||||
reply.header('Content-Security-Policy', "default-src 'none'; style-src 'unsafe-inline'; img-src 'self'");
|
||||
reply.header('X-Frame-Options', 'DENY');
|
||||
|
||||
// For non-image files, set Content-Disposition to download
|
||||
if (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/')) {
|
||||
// For non-media files and SVGs, force download instead of inline rendering
|
||||
const isSvg = mimetype === 'image/svg+xml';
|
||||
if (isSvg || (!mimetype.startsWith('image/') && !mimetype.startsWith('video/') && !mimetype.startsWith('audio/'))) {
|
||||
reply.header('Content-Disposition', `attachment; filename="${encodeURIComponent(originalName)}"`);
|
||||
}
|
||||
|
||||
|
||||
@@ -10,6 +10,15 @@ import { deleteUploadFile } from '../utils/fileCleanup.js';
|
||||
import { tombstoneUser } from '../utils/userDeletion.js';
|
||||
import { generateSnowflake } from '../utils/snowflake.js';
|
||||
|
||||
/** Validates that a URL is a safe asset URL (relative upload path or http/https) */
|
||||
function isValidAssetUrl(url: string | null | undefined): boolean {
|
||||
if (!url || url.trim().length === 0) return true; // empty/null = clearing
|
||||
const trimmed = url.trim();
|
||||
if (trimmed.startsWith('/api/uploads/')) return true;
|
||||
if (trimmed.startsWith('https://') || trimmed.startsWith('http://')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get('/api/users/@me', { preHandler: authenticate }, async (request, reply) => {
|
||||
const db = getDb();
|
||||
@@ -48,8 +57,8 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
}, async (request, reply) => {
|
||||
const { currentPassword, newPassword } = request.body;
|
||||
|
||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 6) {
|
||||
return reply.code(400).send({ error: 'New password must be at least 6 characters', statusCode: 400 });
|
||||
if (!newPassword || typeof newPassword !== 'string' || newPassword.length < 8) {
|
||||
return reply.code(400).send({ error: 'New password must be at least 8 characters', statusCode: 400 });
|
||||
}
|
||||
|
||||
const db = getDb();
|
||||
@@ -58,20 +67,17 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||
}
|
||||
|
||||
// Native users (no homeInstance) must provide current password
|
||||
if (!user.homeInstance) {
|
||||
if (!currentPassword || typeof currentPassword !== 'string') {
|
||||
return reply.code(400).send({ error: 'Current password is required', statusCode: 400 });
|
||||
}
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return reply.code(403).send({ error: 'Incorrect password', statusCode: 403 });
|
||||
}
|
||||
// All users must provide current password (federated users have a local password hash)
|
||||
if (!currentPassword || typeof currentPassword !== 'string') {
|
||||
return reply.code(400).send({ error: 'Current password is required', statusCode: 400 });
|
||||
}
|
||||
const valid = await verifyPassword(currentPassword, user.passwordHash);
|
||||
if (!valid) {
|
||||
return reply.code(403).send({ error: 'Incorrect password', statusCode: 403 });
|
||||
}
|
||||
// Federated users: JWT auth is sufficient — skip old password verification
|
||||
|
||||
const newHash = await hashPassword(newPassword);
|
||||
db.update(schema.users).set({ passwordHash: newHash }).where(eq(schema.users.id, request.userId)).run();
|
||||
db.update(schema.users).set({ passwordHash: newHash, passwordChangedAt: Date.now() }).where(eq(schema.users.id, request.userId)).run();
|
||||
|
||||
// Issue fresh JWT
|
||||
const token = signJwt({ userId: user.id, username: user.username });
|
||||
@@ -157,11 +163,27 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
}
|
||||
}
|
||||
|
||||
// Track old files for cleanup after update
|
||||
let oldAvatar: string | null = null;
|
||||
let oldBanner: string | null = null;
|
||||
if (avatar !== undefined || banner !== undefined) {
|
||||
const current = db.select({ avatar: schema.users.avatar, banner: schema.users.banner })
|
||||
.from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
oldAvatar = current?.avatar ?? null;
|
||||
oldBanner = current?.banner ?? null;
|
||||
}
|
||||
|
||||
if (avatar !== undefined) {
|
||||
if (!isValidAssetUrl(avatar)) {
|
||||
return reply.code(400).send({ error: 'Avatar URL must be a relative upload path or http/https URL', statusCode: 400 });
|
||||
}
|
||||
updateData.avatar = avatar;
|
||||
}
|
||||
|
||||
if (banner !== undefined) {
|
||||
if (!isValidAssetUrl(banner)) {
|
||||
return reply.code(400).send({ error: 'Banner URL must be a relative upload path or http/https URL', statusCode: 400 });
|
||||
}
|
||||
if (banner && typeof banner === 'string' && banner.trim().length > 0) {
|
||||
updateData.banner = banner.trim();
|
||||
} else {
|
||||
@@ -228,17 +250,36 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
if (!Array.isArray(replicatedInstances)) {
|
||||
return reply.code(400).send({ error: 'replicatedInstances must be an array', statusCode: 400 });
|
||||
}
|
||||
// Validate each entry has (origin or domain) and username strings
|
||||
if (replicatedInstances.length > 20) {
|
||||
return reply.code(400).send({ error: 'Maximum 20 replicated instances', statusCode: 400 });
|
||||
}
|
||||
const domainRegex = /^[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?(\.[a-zA-Z0-9]([a-zA-Z0-9-]*[a-zA-Z0-9])?)*$/;
|
||||
for (const inst of replicatedInstances) {
|
||||
if (!inst || typeof inst.username !== 'string') {
|
||||
return reply.code(400).send({ error: 'Each replicated instance must have username string', statusCode: 400 });
|
||||
if (!inst || typeof inst.username !== 'string' || inst.username.trim().length === 0) {
|
||||
return reply.code(400).send({ error: 'Each replicated instance must have a non-empty username string', statusCode: 400 });
|
||||
}
|
||||
if (inst.username.length > 255) {
|
||||
return reply.code(400).send({ error: 'Instance username must be 255 characters or less', statusCode: 400 });
|
||||
}
|
||||
if (typeof inst.origin !== 'string' && typeof inst.domain !== 'string') {
|
||||
return reply.code(400).send({ error: 'Each replicated instance must have origin or domain string', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
if (replicatedInstances.length > 50) {
|
||||
return reply.code(400).send({ error: 'Maximum 50 replicated instances', statusCode: 400 });
|
||||
if (typeof inst.origin === 'string') {
|
||||
if (inst.origin.length > 512) {
|
||||
return reply.code(400).send({ error: 'Instance origin must be 512 characters or less', statusCode: 400 });
|
||||
}
|
||||
if (!inst.origin.startsWith('https://') && !inst.origin.startsWith('http://')) {
|
||||
return reply.code(400).send({ error: 'Instance origin must start with https:// or http://', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
if (typeof inst.domain === 'string') {
|
||||
if (inst.domain.length > 253) {
|
||||
return reply.code(400).send({ error: 'Instance domain must be 253 characters or less', statusCode: 400 });
|
||||
}
|
||||
if (!domainRegex.test(inst.domain)) {
|
||||
return reply.code(400).send({ error: 'Instance domain contains invalid characters', statusCode: 400 });
|
||||
}
|
||||
}
|
||||
}
|
||||
updateData.replicatedInstances = JSON.stringify(replicatedInstances);
|
||||
}
|
||||
@@ -286,6 +327,14 @@ export async function userRoutes(app: FastifyInstance): Promise<void> {
|
||||
|
||||
db.update(schema.users).set(updateData).where(eq(schema.users.id, request.userId)).run();
|
||||
|
||||
// Clean up old avatar/banner files that were replaced
|
||||
if (avatar !== undefined && oldAvatar && oldAvatar !== (avatar || null) && !oldAvatar.startsWith('http')) {
|
||||
deleteUploadFile(oldAvatar);
|
||||
}
|
||||
if (banner !== undefined && oldBanner && oldBanner !== (updateData.banner ?? null) && !oldBanner.startsWith('http')) {
|
||||
deleteUploadFile(oldBanner);
|
||||
}
|
||||
|
||||
const updatedUser = db.select().from(schema.users).where(eq(schema.users.id, request.userId)).get();
|
||||
if (!updatedUser) {
|
||||
return reply.code(404).send({ error: 'User not found', statusCode: 404 });
|
||||
|
||||
@@ -1,7 +1,23 @@
|
||||
import type { FastifyInstance } from 'fastify';
|
||||
import dns from 'dns';
|
||||
import { authenticate } from '../utils/auth.js';
|
||||
import * as cheerio from 'cheerio';
|
||||
|
||||
function isPrivateIp(ip: string): boolean {
|
||||
// IPv4
|
||||
if (ip.startsWith('127.') || ip.startsWith('0.') || ip === '0.0.0.0') return true;
|
||||
if (ip.startsWith('10.')) return true;
|
||||
if (ip.startsWith('192.168.')) return true;
|
||||
if (ip.startsWith('169.254.')) return true;
|
||||
if (ip.startsWith('172.')) {
|
||||
const second = parseInt(ip.split('.')[1] ?? '', 10);
|
||||
if (second >= 16 && second <= 31) return true;
|
||||
}
|
||||
// IPv6
|
||||
if (ip === '::1' || ip.startsWith('fc') || ip.startsWith('fd') || ip.startsWith('fe80')) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
export async function utilRoutes(app: FastifyInstance): Promise<void> {
|
||||
app.get<{ Querystring: { url: string } }>('/api/utils/metadata', {
|
||||
preHandler: authenticate,
|
||||
@@ -12,19 +28,57 @@ export async function utilRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(400).send({ error: 'URL is required' });
|
||||
}
|
||||
|
||||
// Validate URL scheme
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(url);
|
||||
} catch {
|
||||
return reply.code(400).send({ error: 'Invalid URL' });
|
||||
}
|
||||
|
||||
if (!['http:', 'https:'].includes(parsed.protocol)) {
|
||||
return reply.code(400).send({ error: 'Only HTTP(S) URLs are supported' });
|
||||
}
|
||||
|
||||
// Resolve hostname and block private/internal IPs
|
||||
let address: string;
|
||||
try {
|
||||
const result = await dns.promises.lookup(parsed.hostname);
|
||||
address = result.address;
|
||||
} catch {
|
||||
return reply.code(200).send({});
|
||||
}
|
||||
|
||||
if (isPrivateIp(address)) {
|
||||
return reply.code(400).send({ error: 'URLs pointing to private/internal addresses are not allowed' });
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'User-Agent': 'BackspaceBot/1.0',
|
||||
},
|
||||
signal: controller.signal,
|
||||
redirect: 'follow',
|
||||
});
|
||||
clearTimeout(timeout);
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch URL');
|
||||
return reply.code(200).send({});
|
||||
}
|
||||
|
||||
// Reject oversized responses
|
||||
const contentLength = parseInt(response.headers.get('content-length') ?? '0', 10);
|
||||
if (contentLength > 512_000) {
|
||||
return reply.code(200).send({});
|
||||
}
|
||||
|
||||
const html = await response.text();
|
||||
const $ = cheerio.load(html);
|
||||
const safeHtml = html.length > 512_000 ? html.slice(0, 512_000) : html;
|
||||
|
||||
const $ = cheerio.load(safeHtml);
|
||||
|
||||
const metadata = {
|
||||
title: $('meta[property="og:title"]').attr('content') || $('title').text(),
|
||||
@@ -37,6 +91,8 @@ export async function utilRoutes(app: FastifyInstance): Promise<void> {
|
||||
return reply.code(200).send(metadata);
|
||||
} catch (err) {
|
||||
return reply.code(200).send({}); // Fail silently with empty object
|
||||
} finally {
|
||||
clearTimeout(timeout);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user