/** * SSRF Guard — prevents tasks from targeting internal/private addresses. * Used when creating scheduled tasks to ensure webhooks only hit allowed targets. */ const BLOCKED_HOSTS = new Set([ 'localhost', 'nftcache', 'schedy', 'ntfy', 'frontend', 'web', 'redis', 'db', 'postgres', 'mysql', 'mongo', ]); const BLOCKED_HEADERS = new Set([ 'host', 'content-length', 'transfer-encoding', 'connection', 'upgrade', 'proxy-authorization', 'proxy-authenticate', ]); export function isAllowedUrl(urlStr: string): { ok: true } | { ok: false; reason: string } { let parsed: URL; try { parsed = new URL(urlStr); } catch { return { ok: false, reason: 'Invalid URL' }; } // Enforce HTTPS in production; allow HTTP only for localhost dev const isDev = process.env.NODE_ENV === 'development'; if (parsed.protocol !== 'https:' && !(isDev && parsed.hostname === 'localhost')) { return { ok: false, reason: 'URL must use HTTPS' }; } const hostname = parsed.hostname.toLowerCase(); // Block internal Docker / service hostnames if (BLOCKED_HOSTS.has(hostname)) { return { ok: false, reason: 'Blocked internal hostname' }; } // Block IPv4 private/reserved ranges const ipv4Match = hostname.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); if (ipv4Match) { const octets = ipv4Match.slice(1).map(Number); if (octets.some((o) => o > 255)) { return { ok: false, reason: 'Invalid IP address' }; } const [a, b, c, d] = octets; // 10.0.0.0/8 if (a === 10) return { ok: false, reason: 'Private IP range' }; // 172.16.0.0/12 if (a === 172 && b >= 16 && b <= 31) return { ok: false, reason: 'Private IP range' }; // 192.168.0.0/16 if (a === 192 && b === 168) return { ok: false, reason: 'Private IP range' }; // 127.0.0.0/8 if (a === 127) return { ok: false, reason: 'Loopback IP' }; // 169.254.0.0/16 (link-local) if (a === 169 && b === 254) return { ok: false, reason: 'Link-local IP' }; // 0.0.0.0/8 if (a === 0) return { ok: false, reason: 'Reserved IP' }; } // Block IPv6 loopback / private indicators if (hostname === '::1' || hostname === '::' || hostname.startsWith('fe80:') || hostname.startsWith('fc') || hostname.startsWith('fd')) { return { ok: false, reason: 'IPv6 private/loopback' }; } // Block metadata endpoints if (hostname === '169.254.169.254') { return { ok: false, reason: 'Cloud metadata endpoint' }; } return { ok: true }; } export function sanitizeHeaders(headers: Record): Record { const out: Record = {}; for (const [k, v] of Object.entries(headers)) { const lower = k.toLowerCase(); if (!BLOCKED_HEADERS.has(lower)) { out[k] = v; } } return out; }