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:
@@ -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