Files
calendar/packages/lib/ssrfProtection.ts
Benny JooGitHubDevin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
ab21c7f805 refactor: Cal.diy (#28903)
* feat: Cal.diy — community-driven MIT-licensed fork of Cal.com

This squashed commit contains all Cal.diy changes applied on top of calcom/cal.com main:

- Rebrand Cal.com to Cal.diy across the entire codebase
- Remove Enterprise Edition (EE) features, license checks, and AGPL restrictions
- Switch license from AGPL-3.0 to MIT
- Remove docs/ directory (migrated to Nextra at cal.diy)
- Remove dead code: org tests, EE tips, platform nav, premium username, SAML/SSO, etc.
- Clean up .env.example for self-hosted Cal.diy
- Update Docker image references to calcom/cal.diy
- Update README, CONTRIBUTING.md, and issue templates for Cal.diy community fork
- Add PR welcome bot for Cal.diy contributors
- Fix API v2 breaking changes oasdiff ignore entries
- Replace Blacksmith CI runners with default GitHub Actions

3893 files changed, 20789 insertions(+), 411020 deletions(-)

Co-Authored-By: benny@cal.com <sldisek783@gmail.com>

* refactor: remove org-specific /organizations/:orgId endpoints from API v2 atoms controllers (#1701)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* fix: revert Cal.diy Inc to Cal.com, Inc. in license files, copyright notices, and package metadata (#1702)

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>

* rip out org related comments in api v2

---------

Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-04-15 09:52:36 -03:00

237 lines
7.2 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
];
const LOOPBACK_HOSTNAMES: string[] = ["localhost", "127.0.0.1", "::1", "[::1]", "0.0.0.0"];
// Hostnames blocked on Cal.diy SaaS (includes metadata + loopback)
const BLOCKED_HOSTNAMES: string[] = [...CLOUD_METADATA_ENDPOINTS, ...LOOPBACK_HOSTNAMES];
const CAL_AVATAR_PATH_REGEX = /^\/api\/avatar\/.+\.png$/;
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 };
}
if (CAL_AVATAR_PATH_REGEX.test(urlString)) {
return { isValid: true };
}
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,
});
}