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
+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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();
+107 -42
View File
@@ -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();
+12 -1
View File
@@ -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),
+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) {
+59 -12
View File
@@ -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();
+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 });
});
+8 -3
View File
@@ -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)}"`);
}
+68 -19
View File
@@ -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 });
+58 -2
View File
@@ -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);
}
});
}