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