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
This commit is contained in:
Pedro Castro authored and GitHub committed 2026-01-08 22:03:54 +00:00
1 parent a5387bbffe
commit d9aef5b6a6
10 files changed
+488 -20

No files matched your search

@@ -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",
@@ -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",
@@ -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");
});
});
@@ -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)";
}
}
+21 -1
View File
@@ -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";
+103
View File
@@ -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,<script>alert(1)</script>", "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);
});
});
+190
View File
@@ -0,0 +1,190 @@
import dns from "node:dns/promises";
import net from "node:net";
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
*/
// 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<SSRFValidationResult> {
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<string, unknown>): void {
log.warn("SSRF attempt blocked", {
url: url.substring(0, 100), // Truncate for log safety
reason,
...context,
});
}
+38
View File
@@ -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<z.ZodString, string, string> = 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<z.ZodNullable<z.ZodString>>,
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" }
);
+3
View File
@@ -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";
@@ -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<z.ZodNullable<z.ZodString>>,
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<TUpdateInputSchema, z.ZodTypeDef, TUp
.or(z.number())
.optional(),
bio: z.string().optional(),
logoUrl: z.string().optional().nullable(),
calVideoLogo: z
.string()
.optional()
.nullable()
.transform((v) => 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(),