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(),