import dns from "node:dns/promises"; import ipaddr from "ipaddr.js"; import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; const log: ReturnType = logger.getSubLogger({ prefix: ["ssrf-protection"] }); /** * SSRF protection helpers for server-side URL fetching * * Use when fetching user-controlled URLs (logos, avatars, webhooks) to prevent * access to internal networks and cloud metadata services */ const BLOCKED_IP_RANGES: readonly string[] = [ "unspecified", // 0.0.0.0/8, ::/128 "loopback", // 127.0.0.0/8, ::1/128 "private", // 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 "linkLocal", // 169.254.0.0/16, fe80::/10 "uniqueLocal", // fc00::/7 "carrierGradeNat", // 100.64.0.0/10 (RFC 6598) "reserved", // Documentation ranges (RFC 5737), etc. "benchmarking", // 198.18.0.0/15 (RFC 2544) ] as const; // Cloud metadata endpoints (blocked even on self-hosted) const CLOUD_METADATA_ENDPOINTS: string[] = [ "169.254.169.254", // AWS/Azure/DigitalOcean/Oracle metadata "169.254.169.253", // Azure alternate "metadata.google.internal", // GCP metadata "metadata.google.com", // GCP alternate ]; // Hostnames blocked on Cal.com SaaS (includes metadata + localhost) const BLOCKED_HOSTNAMES: string[] = [...CLOUD_METADATA_ENDPOINTS, "localhost"]; const ERRORS = { HTTPS_ONLY: "Only HTTPS URLs are allowed", INVALID_PROTOCOL: "Only HTTP and HTTPS protocols are allowed", PRIVATE_IP: "Private IP address", PRIVATE_IP_DNS: "Hostname resolves to private IP", BLOCKED_HOSTNAME: "Blocked hostname", INVALID_URL: "Invalid URL format", NON_IMAGE_DATA_URL: "Non-image data URL", } as const; function normalizeHostname(hostname: string): string { return hostname.toLowerCase().replace(/\.$/, ""); } function stripIPv6Brackets(hostname: string): string { if (hostname.startsWith("[") && hostname.endsWith("]")) { return hostname.slice(1, -1); } return hostname; } export function isPrivateIP(ip: string): boolean { const cleanIp = stripIPv6Brackets(ip); if (!ipaddr.isValid(cleanIp)) { return true; } try { const addr = ipaddr.parse(cleanIp); if (addr.kind() === "ipv6") { const ipv6 = addr as ipaddr.IPv6; if (ipv6.isIPv4MappedAddress()) { const ipv4 = ipv6.toIPv4Address(); return BLOCKED_IP_RANGES.includes(ipv4.range()); } } return BLOCKED_IP_RANGES.includes(addr.range()); } catch { // If parsing fails, treat as blocked for safety return true; } } // Check if hostname is a blocked cloud metadata endpoint or localhost export function isBlockedHostname(hostname: string): boolean { const normalized = normalizeHostname(hostname); return BLOCKED_HOSTNAMES.includes(normalized); } // Check if hostname is a cloud metadata endpoint (blocked even on self-hosted) function isCloudMetadataEndpoint(hostname: string): boolean { const normalized = normalizeHostname(hostname); return CLOUD_METADATA_ENDPOINTS.includes(normalized); } export interface SSRFValidationResult { isValid: boolean; error?: string; } /** * Core validation logic shared by sync and async versions * Returns SSRFValidationResult if validation completes, or { url } if DNS check is needed */ function validateUrlCore(urlString: string): SSRFValidationResult | { url: URL } { // Data URLs with image/* are safe (no network fetch) if (urlString.startsWith("data:image/")) { return { isValid: true }; } if (urlString.startsWith("data:")) { return { isValid: false, error: ERRORS.NON_IMAGE_DATA_URL }; } let url: URL; try { url = new URL(urlString); } catch { return { isValid: false, error: ERRORS.INVALID_URL }; } // E2E tests: allow localhost only if (process.env.NEXT_PUBLIC_IS_E2E === "1") { const isLocalhost = url.hostname === "localhost" || url.hostname === "127.0.0.1"; if (isLocalhost) { return { isValid: true }; } } // Always block cloud metadata endpoints (even self-hosted may run on AWS/GCP/Azure) if (isCloudMetadataEndpoint(url.hostname)) { return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME }; } // Self-hosted: allow HTTP and private IPs (for internal webhooks) // Still restrict to HTTP/HTTPS protocols only (no file://, ftp://, etc.) if (IS_SELF_HOSTED) { if (url.protocol !== "http:" && url.protocol !== "https:") { return { isValid: false, error: ERRORS.INVALID_PROTOCOL }; } return { isValid: true }; } if (url.protocol !== "https:") { return { isValid: false, error: ERRORS.HTTPS_ONLY }; } if (isBlockedHostname(url.hostname)) { return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME }; } // Check if hostname is an IP address and if it's private const hostnameForIPCheck = stripIPv6Brackets(url.hostname); if (ipaddr.isValid(hostnameForIPCheck) && isPrivateIP(hostnameForIPCheck)) { return { isValid: false, error: ERRORS.PRIVATE_IP }; } return { url }; } /** * Async SSRF validation with DNS rebinding protection * Resolves hostname and checks all IPs against private ranges */ export async function validateUrlForSSRF(urlString: string): Promise { const result = validateUrlCore(urlString); if ("isValid" in result) { return result; } // DNS rebinding protection: resolve IPs and check each one try { const addresses = await dns.lookup(result.url.hostname, { all: true }); for (const { address } of addresses) { if (isPrivateIP(address)) { return { isValid: false, error: ERRORS.PRIVATE_IP_DNS }; } } } catch { // Allow DNS failures to avoid breaking legitimate hosts with flaky DNS } return { isValid: true }; } /** * Sync SSRF validation for Zod schemas (no DNS check) * Does not protect against DNS rebinding - use async version when possible */ export function validateUrlForSSRFSync(urlString: string): SSRFValidationResult { const result = validateUrlCore(urlString); if ("isValid" in result) { return result; } return { isValid: true }; } // Check if URL belongs to the same origin as the webapp (trusted internal URL) export function isTrustedInternalUrl(url: string, webappUrl: string): boolean { try { return new URL(url).origin === new URL(webappUrl).origin; } catch { return false; } } // Sanitize URL for logging - removes query params and credentials that may contain secrets function sanitizeUrlForLog(urlString: string): string { try { const url = new URL(urlString); // Only log origin + pathname, exclude query params, hash, and credentials return `${url.origin}${url.pathname}`.substring(0, 100); } catch { // If URL parsing fails, truncate and redact potential secrets return `${urlString.substring(0, 50).replace(/[?#].*$/, "")}...`; } } // Log blocked SSRF attempts for security monitoring and incident response export function logBlockedSSRFAttempt(url: string, reason: string, context?: Record): void { log.warn("SSRF attempt blocked", { url: sanitizeUrlForLog(url), reason, ...context, }); }