feat(security): enhance input validation and security measures across various routes
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m38s
All checks were successful
Build & Push Docker Image / build (push) Successful in 6m38s
This commit is contained in:
@@ -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(),
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user