From d9aef5b6a6c971f89ef4cf360f05257297a37417 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Thu, 8 Jan 2026 19:03:54 -0300 Subject: [PATCH] fix: add URL validation for logo fields (#26522) * fix(auth): add URL validation for organization logo fields - Add URL validation utility for server-side fetched URLs - Validate logo URLs in tRPC organization schema - Validate logo URLs in API v2 team DTOs - Add graceful fallback in logo route for invalid URLs - Only allow HTTPS and image data URLs - Add unit tests for URL validation * fix: add missing IP range and type validation - Add RFC 6598 CGNAT range (100.64.0.0/10) to blocked IPs - Add @IsString() decorator before custom URL validators - Add boundary tests for new IP range * fix: address review feedback - Export validateUrlForSSRFSync via @calcom/platform-libraries - Fix nullable/optional order in Zod schema * fix: block localhost hostname and explicit null check - Add localhost to BLOCKED_HOSTNAMES for sync validation - Use explicit null check (url == null) instead of falsy check * fix: allow empty string in URL validation * refactor: extract shared validation logic - Extract validateUrlCore() to eliminate duplication between sync/async versions - Add JSDoc to all exported functions for better discoverability - Remove redundant comments that repeated function names - Simplify existing comments to be more concise * fix: reject empty strings in URL validator Previously if (!url) allowed empty strings to bypass validation. Now explicitly checks for null/undefined only --- .../teams/teams/inputs/create-team.input.ts | 19 +- .../teams/teams/inputs/update-team.input.ts | 19 +- .../validators/ssrfSafeUrlValidator.spec.ts | 58 ++++++ .../teams/validators/ssrfSafeUrlValidator.ts | 25 +++ apps/web/app/api/logo/route.ts | 22 +- packages/lib/ssrfProtection.test.ts | 103 ++++++++++ packages/lib/ssrfProtection.ts | 190 ++++++++++++++++++ packages/lib/zod/ssrfSafeUrl.ts | 38 ++++ packages/platform/libraries/index.ts | 3 + .../viewer/organizations/update.schema.ts | 31 ++- 10 files changed, 488 insertions(+), 20 deletions(-) create mode 100644 apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.spec.ts create mode 100644 apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.ts create mode 100644 packages/lib/ssrfProtection.test.ts create mode 100644 packages/lib/ssrfProtection.ts create mode 100644 packages/lib/zod/ssrfSafeUrl.ts diff --git a/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts index d82602ece3..3ec5fd4adc 100644 --- a/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts +++ b/apps/api/v2/src/modules/teams/teams/inputs/create-team.input.ts @@ -1,8 +1,10 @@ import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { IsBoolean, IsObject, IsOptional, IsString, Length, Validate } from "class-validator"; import { Metadata, METADATA_DOCS, ValidateMetadata } from "@calcom/platform-types"; +import { SSRFSafeUrlValidator } from "../validators/ssrfSafeUrlValidator"; + export class CreateTeamInput { @IsString() @Length(1) @@ -19,7 +21,8 @@ export class CreateTeamInput { readonly slug?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional({ type: String, example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", @@ -28,17 +31,20 @@ export class CreateTeamInput { readonly logoUrl?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional() readonly calVideoLogo?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional() readonly appLogo?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional() readonly appIconLogo?: string; @@ -88,7 +94,8 @@ export class CreateTeamInput { readonly darkBrandColor?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional({ type: String, example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", diff --git a/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts index 6bc32450c5..00e5fd3c59 100644 --- a/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts +++ b/apps/api/v2/src/modules/teams/teams/inputs/update-team.input.ts @@ -1,8 +1,10 @@ import { ApiPropertyOptional } from "@nestjs/swagger"; -import { IsBoolean, IsObject, IsOptional, IsString, IsUrl, Length } from "class-validator"; +import { IsBoolean, IsObject, IsOptional, IsString, Length, Validate } from "class-validator"; import { Metadata, METADATA_DOCS, ValidateMetadata } from "@calcom/platform-types"; +import { SSRFSafeUrlValidator } from "../validators/ssrfSafeUrlValidator"; + export class UpdateTeamDto { @IsString() @Length(1) @@ -15,7 +17,8 @@ export class UpdateTeamDto { readonly slug?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional({ type: String, example: "https://i.cal.com/api/avatar/b0b58752-68ad-4c0d-8024-4fa382a77752.png", @@ -24,17 +27,20 @@ export class UpdateTeamDto { readonly logoUrl?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional() readonly calVideoLogo?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional() readonly appLogo?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional() readonly appIconLogo?: string; @@ -84,7 +90,8 @@ export class UpdateTeamDto { readonly darkBrandColor?: string; @IsOptional() - @IsUrl() + @IsString() + @Validate(SSRFSafeUrlValidator) @ApiPropertyOptional({ type: String, example: "https://i.cal.com/api/avatar/949be534-7a88-4185-967c-c020b0c0bef3.png", diff --git a/apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.spec.ts b/apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.spec.ts new file mode 100644 index 0000000000..6d7a4e4a3d --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.spec.ts @@ -0,0 +1,58 @@ +import type { ValidationError } from "class-validator"; +import { IsOptional, Validate, validateSync } from "class-validator"; + +import { SSRFSafeUrlValidator } from "./ssrfSafeUrlValidator"; + +const mockValidateUrlForSSRFSync: jest.Mock = jest.fn(); + +// Mock with minimal logic - actual validation tested in packages/lib +jest.mock("@calcom/platform-libraries", () => ({ + validateUrlForSSRFSync: (url: string) => mockValidateUrlForSSRFSync(url), +})); + +class TestDto { + @IsOptional() + @Validate(SSRFSafeUrlValidator) + url?: string; +} + +describe("SSRFSafeUrlValidator", () => { + const validate = (url?: string): ValidationError[] => { + const dto = new TestDto(); + dto.url = url; + return validateSync(dto); + }; + + beforeEach(() => { + mockValidateUrlForSSRFSync.mockReset(); + }); + + it("accepts undefined values (works with @IsOptional)", () => { + expect(validate(undefined)).toHaveLength(0); + expect(mockValidateUrlForSSRFSync).not.toHaveBeenCalled(); + }); + + it("validates empty strings (does not skip validation)", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false }); + + const errors = validate(""); + expect(errors).toHaveLength(1); + expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith(""); + }); + + it("delegates to validateUrlForSSRFSync and accepts valid URLs", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: true }); + + expect(validate("https://example.com/logo.png")).toHaveLength(0); + expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("https://example.com/logo.png"); + }); + + it("delegates to validateUrlForSSRFSync and rejects invalid URLs", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false }); + + const errors = validate("blocked-url"); + expect(errors).toHaveLength(1); + expect(errors[0].constraints).toHaveProperty("ssrfSafeUrl"); + expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("blocked-url"); + }); +}); diff --git a/apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.ts b/apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.ts new file mode 100644 index 0000000000..75c753e602 --- /dev/null +++ b/apps/api/v2/src/modules/teams/teams/validators/ssrfSafeUrlValidator.ts @@ -0,0 +1,25 @@ +import { ValidatorConstraint } from "class-validator"; +import type { ValidatorConstraintInterface } from "class-validator"; + +import { validateUrlForSSRFSync } from "@calcom/platform-libraries"; + +/** + * Validates that a URL is safe for server-side fetching + * + * Allows: HTTPS URLs, image data URLs + * Blocks: HTTP, private IPs, metadata endpoints, unsafe schemes + */ +@ValidatorConstraint({ name: "ssrfSafeUrl", async: false }) +export class SSRFSafeUrlValidator implements ValidatorConstraintInterface { + validate(url: string): boolean { + // Allow null/undefined (handled by @IsOptional), but not empty strings + if (url === undefined || url === null) return true; + + const result = validateUrlForSSRFSync(url); + return result.isValid; + } + + defaultMessage(): string { + return "URL is not allowed for security reasons (blocked: private IPs, metadata endpoints)"; + } +} diff --git a/apps/web/app/api/logo/route.ts b/apps/web/app/api/logo/route.ts index 8745295492..ed5915ec44 100644 --- a/apps/web/app/api/logo/route.ts +++ b/apps/web/app/api/logo/route.ts @@ -18,6 +18,7 @@ import { WEBAPP_URL, } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; +import { isTrustedInternalUrl, logBlockedSSRFAttempt, validateUrlForSSRF } from "@calcom/lib/ssrfProtection"; import { buildLegacyRequest } from "@lib/buildLegacyCtx"; @@ -190,7 +191,26 @@ async function getHandler(request: NextRequest) { const filteredLogo = teamLogos[logoDefinition.source] ?? logoDefinition.fallback; try { - const response = await fetch(filteredLogo); + let response: Response; + + // Internal URLs (fallbacks from WEBAPP_URL) are trusted + if (isTrustedInternalUrl(filteredLogo, WEBAPP_URL)) { + response = await fetch(filteredLogo); + } + // External URLs (including data URLs) need SSRF validation + else { + const validation = await validateUrlForSSRF(filteredLogo); + if (!validation.isValid) { + logBlockedSSRFAttempt(filteredLogo, validation.error || "Unknown", { subdomain }); + // Graceful degradation: use default logo instead of error + response = await fetch(logoDefinition.fallback); + } else { + response = await fetch(filteredLogo, { + signal: AbortSignal.timeout(10000), // 10s conservative timeout + }); + } + } + const arrayBuffer = await response.arrayBuffer(); let buffer: Buffer = Buffer.from(arrayBuffer); let contentType = response.headers.get("content-type") || "image/png"; diff --git a/packages/lib/ssrfProtection.test.ts b/packages/lib/ssrfProtection.test.ts new file mode 100644 index 0000000000..8b4fcf28fb --- /dev/null +++ b/packages/lib/ssrfProtection.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from "vitest"; + +import { + isBlockedHostname, + isPrivateIP, + isTrustedInternalUrl, + validateUrlForSSRFSync, +} from "./ssrfProtection"; + +describe("isPrivateIP", () => { + it.each([ + "127.0.0.1", + "10.0.0.1", + "172.16.0.1", + "172.31.255.255", + "192.168.0.1", + "169.254.169.254", // AWS metadata + "0.0.0.0", + "100.64.0.1", // RFC 6598 CGNAT start + "100.127.255.254", // RFC 6598 CGNAT end + ])("blocks private IPv4 %s", (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + + it.each([ + "172.15.255.255", // just outside 172.16.0.0/12 + "172.32.0.0", // just outside 172.16.0.0/12 + "8.8.8.8", + "203.0.113.1", + "100.63.255.255", // just below RFC 6598 + "100.128.0.0", // just above RFC 6598 + ])("allows public IPv4 %s", (ip) => { + expect(isPrivateIP(ip)).toBe(false); + }); + + it.each(["::1", "::", "fc00::1", "fd00::1", "fe80::1"])("blocks private IPv6 %s", (ip) => { + expect(isPrivateIP(ip)).toBe(true); + }); + + it("allows public IPv6 addresses", () => { + expect(isPrivateIP("2001:4860:4860::8888")).toBe(false); // Google DNS + }); + + it("blocks IPv4-mapped IPv6 addresses", () => { + expect(isPrivateIP("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateIP("::ffff:10.0.0.1")).toBe(true); + expect(isPrivateIP("::ffff:169.254.169.254")).toBe(true); + expect(isPrivateIP("::ffff:8.8.8.8")).toBe(false); + }); +}); + +describe("isBlockedHostname", () => { + it.each([ + "localhost", // loopback hostname + "169.254.169.254", // AWS/Azure/DigitalOcean + "metadata.google.internal", // GCP + "169.254.169.254.", // trailing dot normalization + "METADATA.GOOGLE.INTERNAL", // case insensitive + ])("blocks cloud metadata endpoint %s", (hostname) => { + expect(isBlockedHostname(hostname)).toBe(true); + }); + + it("allows regular hostnames", () => { + expect(isBlockedHostname("example.com")).toBe(false); + }); +}); + +describe("validateUrlForSSRFSync", () => { + it("allows HTTPS URLs", () => { + expect(validateUrlForSSRFSync("https://example.com/logo.png").isValid).toBe(true); + }); + + it("allows image data URLs", () => { + expect(validateUrlForSSRFSync("data:image/png;base64,iVBORw0KGgo=").isValid).toBe(true); + }); + + it.each([ + ["http://example.com/logo.png", "Only HTTPS URLs are allowed"], + ["ftp://example.com/file", "Only HTTPS URLs are allowed"], + ["data:text/html,", "Non-image data URL"], + ["https://127.0.0.1/logo.png", "Private IP address"], + ["https://169.254.169.254/latest/meta-data/", "Blocked hostname"], + ["https://localhost/logo.png", "Blocked hostname"], + ["not-a-url", "Invalid URL format"], + ])("blocks %s", (url, expectedError) => { + const result = validateUrlForSSRFSync(url); + expect(result).toEqual({ isValid: false, error: expectedError }); + }); +}); + +describe("isTrustedInternalUrl", () => { + const webappUrl = "https://app.cal.com"; + + it("returns true for same origin", () => { + expect(isTrustedInternalUrl("https://app.cal.com/logo.png", webappUrl)).toBe(true); + }); + + it("returns false for different origins and invalid URLs", () => { + expect(isTrustedInternalUrl("https://evil.com/logo.png", webappUrl)).toBe(false); + expect(isTrustedInternalUrl("https://app.cal.com.evil.com/x", webappUrl)).toBe(false); + expect(isTrustedInternalUrl("not-a-url", webappUrl)).toBe(false); + }); +}); diff --git a/packages/lib/ssrfProtection.ts b/packages/lib/ssrfProtection.ts new file mode 100644 index 0000000000..b046d8ba71 --- /dev/null +++ b/packages/lib/ssrfProtection.ts @@ -0,0 +1,190 @@ +import dns from "node:dns/promises"; +import net from "node:net"; + +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 + */ + +// Private IPv4 ranges (RFC1918 + special ranges) +const PRIVATE_IPV4_PATTERNS: RegExp[] = [ + /^127\./, // 127.0.0.0/8 loopback + /^10\./, // 10.0.0.0/8 private + /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 private + /^192\.168\./, // 192.168.0.0/16 private + /^169\.254\./, // 169.254.0.0/16 link-local + /^0\./, // 0.0.0.0/8 + /^100\.(6[4-9]|[7-9]\d|1[0-1]\d|12[0-7])\./, // 100.64.0.0/10 shared +]; + +// Private IPv6 patterns +const PRIVATE_IPV6_PATTERNS: RegExp[] = [ + /^::1$/i, // loopback + /^::$/i, // unspecified address + /^::ffff:/i, // IPv4-mapped (e.g., ::ffff:127.0.0.1) + /^fc/i, // unique local fc00::/7 + /^fd/i, // unique local + /^fe80:/i, // link-local + /^2001:db8:/i, // documentation range +]; + +// Cloud metadata endpoints +const BLOCKED_HOSTNAMES: string[] = [ + "localhost", + "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 ERRORS = { + HTTPS_ONLY: "Only HTTPS URLs 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; + +/** Normalize hostname: lowercase and remove trailing dot (FQDN format) */ +function normalizeHostname(hostname: string): string { + return hostname.toLowerCase().replace(/\.$/, ""); +} + +// Extracts IPv4 from mapped address (e.g., ::ffff:127.0.0.1 -> 127.0.0.1) +function extractIPv4FromMappedIPv6(ip: string): string | null { + const match = ip.match(/^::ffff:(\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3})$/i); + if (match) { + return match[1]; + } + return null; +} + +/** Check if an IP address belongs to private/internal ranges (RFC1918, link-local, etc.) */ +export function isPrivateIP(ip: string): boolean { + const mappedIPv4 = extractIPv4FromMappedIPv6(ip); + if (mappedIPv4) { + return isPrivateIP(mappedIPv4); + } + + if (PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip))) { + return true; + } + + if (PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(ip))) { + return true; + } + + return false; +} + +/** 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); +} + +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 }; + } + + if (url.protocol !== "https:") { + return { isValid: false, error: ERRORS.HTTPS_ONLY }; + } + + if (isBlockedHostname(url.hostname)) { + return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME }; + } + + if (net.isIP(url.hostname) !== 0 && isPrivateIP(url.hostname)) { + 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 ALL 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; + } +} + +/** 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: url.substring(0, 100), // Truncate for log safety + reason, + ...context, + }); +} diff --git a/packages/lib/zod/ssrfSafeUrl.ts b/packages/lib/zod/ssrfSafeUrl.ts new file mode 100644 index 0000000000..671fe7c34e --- /dev/null +++ b/packages/lib/zod/ssrfSafeUrl.ts @@ -0,0 +1,38 @@ +import type { z } from "zod"; +import { z as zod } from "zod"; + +import { validateUrlForSSRFSync } from "../ssrfProtection"; + +/** + * Zod schema for validating user-provided URLs before server-side fetching + * + * Applies synchronous SSRF checks only (no DNS resolution or rebinding) + * The logo route adds async DNS validation as defense-in-depth + */ +export const ssrfSafeUrlSchema: z.ZodEffects = zod.string().refine( + (url) => { + const result = validateUrlForSSRFSync(url); + return result.isValid; + }, + { message: "URL is not allowed for security reasons" } +); + +/** + * Optional nullable variant for update schemas + * Allows null/undefined while validating provided values + */ +export const optionalSsrfSafeUrlSchema: z.ZodEffects< + z.ZodOptional>, + string | null | undefined, + string | null | undefined +> = zod + .string() + .nullable() + .optional() + .refine( + (url) => { + if (url == null || url === "") return true; // null/undefined/empty allowed + return validateUrlForSSRFSync(url).isValid; + }, + { message: "URL is not allowed for security reasons" } + ); diff --git a/packages/platform/libraries/index.ts b/packages/platform/libraries/index.ts index ada43662c5..ea9d923d22 100644 --- a/packages/platform/libraries/index.ts +++ b/packages/platform/libraries/index.ts @@ -135,6 +135,9 @@ export { TeamService } from "@calcom/features/ee/teams/services/teamService"; export { BookingAccessService } from "@calcom/features/bookings/services/BookingAccessService"; export { getTasker } from "@calcom/features/tasker/tasker-factory"; export type { Tasker } from "@calcom/features/tasker/tasker"; + +export { validateUrlForSSRFSync } from "@calcom/lib/ssrfProtection"; + export { verifyCodeChallenge } from "@calcom/lib/pkce"; export { generateSecret } from "@calcom/features/oauth/utils/generateSecret"; export { OAuthService } from "@calcom/features/oauth/services/OAuthService"; diff --git a/packages/trpc/server/routers/viewer/organizations/update.schema.ts b/packages/trpc/server/routers/viewer/organizations/update.schema.ts index 6ec5fbeaab..588e8b0722 100644 --- a/packages/trpc/server/routers/viewer/organizations/update.schema.ts +++ b/packages/trpc/server/routers/viewer/organizations/update.schema.ts @@ -1,8 +1,29 @@ import { z } from "zod"; import { timeZoneSchema } from "@calcom/lib/dayjs/timeZone.schema"; +import { validateUrlForSSRFSync } from "@calcom/lib/ssrfProtection"; import { teamMetadataStrictSchema } from "@calcom/prisma/zod-utils"; +/** + * Optional nullable URL schema with SSRF protections + * Rejects internal/private networks and cloud metadata endpoints + */ +const ssrfSafeOptionalUrl: z.ZodEffects< + z.ZodOptional>, + string | null | undefined, + string | null | undefined +> = z + .string() + .nullable() + .optional() + .refine( + (url) => { + if (url == null || url === "") return true; + return validateUrlForSSRFSync(url).isValid; + }, + { message: "URL is not allowed for security reasons" } + ); + // Note: orgId has .transform() that converts string to number, so input accepts string | number but output is always number export type TUpdateInputSchemaInput = { @@ -86,13 +107,9 @@ export const ZUpdateInputSchema: z.ZodType v || null), - banner: z.string().nullable().optional(), + logoUrl: ssrfSafeOptionalUrl, + calVideoLogo: ssrfSafeOptionalUrl.transform((v) => v || null), + banner: ssrfSafeOptionalUrl, slug: z.string().optional(), hideBranding: z.boolean().optional(), hideBookATeamMember: z.boolean().optional(),