Files
calendar/packages/lib/ssrfProtection.ts
T
Pedro CastroGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
cb36fc201f fix: add URL validation to webhook endpoints (#26593)
Validates webhook URLs on create and update:
- HTTPS required (HTTP allowed for self-hosted and E2E)
- Blocks private IP ranges and localhost
- Blocks cloud metadata endpoints

Existing webhooks are preserved: validation only applies when URL is created or changed.

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-02-05 16:18:10 -03:00

229 lines
7.0 KiB
TypeScript

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<typeof logger.getSubLogger> = 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<SSRFValidationResult> {
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<string, unknown>): void {
log.warn("SSRF attempt blocked", {
url: sanitizeUrlForLog(url),
reason,
...context,
});
}