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:
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)";
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
@@ -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" }
|
||||
);
|
||||
@@ -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(),
|
||||
|
||||
Reference in new issue
Block a user