feat(security): enhance input validation and security measures across various routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m38s

This commit is contained in:
2026-03-04 08:39:29 +01:00
parent ba096a31a2
commit e22a895672
13 changed files with 222 additions and 29 deletions

View File

@@ -4,6 +4,9 @@ import path from 'path';
import { fileURLToPath } from 'url';
import { log } from './logger.js';
import dns from 'dns';
import net from 'net';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
@@ -93,13 +96,69 @@ export function verifyPayload(payload, signature, remotePublicKeyPem) {
}
}
/**
* Check if a domain resolves to a private/internal IP address (SSRF protection).
* Blocks RFC 1918, loopback, link-local, and cloud metadata IPs.
* @param {string} domain
* @returns {Promise<void>} throws if domain resolves to a blocked IP
*/
async function assertPublicDomain(domain) {
// Allow localhost only in development
if (domain === 'localhost' || domain === '127.0.0.1' || domain === '::1') {
if (process.env.NODE_ENV === 'production') {
throw new Error('Federation to localhost is blocked in production');
}
return; // allow in dev
}
// If domain is a raw IP, check it directly
if (net.isIP(domain)) {
if (isPrivateIP(domain)) {
throw new Error(`Federation blocked: ${domain} resolves to a private IP`);
}
return;
}
// Resolve domain and check all resulting IPs
const { resolve4, resolve6 } = dns.promises;
const ips = [];
try { ips.push(...await resolve4(domain)); } catch {}
try { ips.push(...await resolve6(domain)); } catch {}
if (ips.length === 0) {
throw new Error(`Federation blocked: could not resolve ${domain}`);
}
for (const ip of ips) {
if (isPrivateIP(ip)) {
throw new Error(`Federation blocked: ${domain} resolves to a private IP (${ip})`);
}
}
}
function isPrivateIP(ip) {
// IPv4 private ranges
if (/^10\./.test(ip)) return true;
if (/^172\.(1[6-9]|2\d|3[01])\./.test(ip)) return true;
if (/^192\.168\./.test(ip)) return true;
if (/^127\./.test(ip)) return true;
if (/^0\./.test(ip)) return true;
if (/^169\.254\./.test(ip)) return true; // link-local
if (ip === '::1' || ip === '::' || ip.startsWith('fe80:') || ip.startsWith('fc') || ip.startsWith('fd')) return true;
return false;
}
/**
* Discover a remote Redlight instance's federation API base URL.
* Fetches https://{domain}/.well-known/redlight and caches the result.
* Includes SSRF protection: blocks private/internal IPs.
* @param {string} domain
* @returns {Promise<{ baseUrl: string, publicKey: string }>}
*/
export async function discoverInstance(domain) {
// SSRF protection: validate domain doesn't resolve to internal IP
await assertPublicDomain(domain);
const cached = discoveryCache.get(domain);
if (cached && (Date.now() - cached.cachedAt) < DISCOVERY_TTL_MS) {
return cached;
@@ -112,7 +171,8 @@ export async function discoverInstance(domain) {
try {
response = await fetch(wellKnownUrl, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} catch (e) {
if (e.message.includes('fetch') && wellKnownUrl.startsWith('https://localhost')) {
// HTTP fallback only allowed in development for localhost
if (e.message.includes('fetch') && domain === 'localhost' && process.env.NODE_ENV !== 'production') {
response = await fetch(`http://${domain}/.well-known/redlight`, { signal: AbortSignal.timeout(TIMEOUT_MS) });
} else throw e;
}
@@ -128,7 +188,9 @@ export async function discoverInstance(domain) {
const baseUrl = `https://${domain}${data.federation_api || '/api/federation'}`;
const result = {
baseUrl: baseUrl.replace('https://localhost', 'http://localhost'),
baseUrl: (domain === 'localhost' && process.env.NODE_ENV !== 'production')
? baseUrl.replace('https://localhost', 'http://localhost')
: baseUrl,
publicKey: data.public_key,
cachedAt: Date.now(),
};