* 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>
237 lines
7.2 KiB
TypeScript
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,
|
|
});
|
|
}
|