From cb36fc201fd8d577da34ab425d256c40dc59fb30 Mon Sep 17 00:00:00 2001 From: Pedro Castro Date: Thu, 5 Feb 2026 16:18:10 -0300 Subject: [PATCH] fix: add URL validation to webhook endpoints (#26593) Validates webhook URLs on create and update: - HTTPS required (HTTP allowed for self-hosted and E2E) - Blocks private IP ranges and localhost - Blocks cloud metadata endpoints Existing webhooks are preserved: validation only applies when URL is created or changed. Co-authored-by: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> --- .../organizations-webhooks.service.ts | 5 + .../services/event-type-webhooks.service.ts | 3 + .../oauth-clients-webhooks.service.ts | 3 + .../team-event-type-webhooks.service.ts | 3 + .../services/user-webhooks.service.ts | 3 + .../webhooks/services/webhooks.service.ts | 3 + .../utils/validate-webhook-url.spec.ts | 95 +++++++++++++ .../webhooks/utils/validate-webhook-url.ts | 18 +++ .../modules/webhooks/webhooks.repository.ts | 8 ++ packages/lib/package.json | 1 + packages/lib/ssrfProtection.test.ts | 95 ++++++++++++- packages/lib/ssrfProtection.ts | 134 +++++++++++------- packages/lib/zod/ssrfSafeUrl.ts | 47 +++--- .../routers/viewer/webhook/create.handler.ts | 10 ++ .../routers/viewer/webhook/edit.handler.ts | 12 ++ .../viewer/webhook/testTrigger.handler.ts | 13 +- yarn.lock | 8 ++ 17 files changed, 386 insertions(+), 75 deletions(-) create mode 100644 apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.spec.ts create mode 100644 apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.ts diff --git a/apps/api/v2/src/modules/organizations/webhooks/services/organizations-webhooks.service.ts b/apps/api/v2/src/modules/organizations/webhooks/services/organizations-webhooks.service.ts index 492b3ab135..22d145ba16 100644 --- a/apps/api/v2/src/modules/organizations/webhooks/services/organizations-webhooks.service.ts +++ b/apps/api/v2/src/modules/organizations/webhooks/services/organizations-webhooks.service.ts @@ -1,6 +1,7 @@ import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhooks/organizations-webhooks.repository"; import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { validateWebhookUrl, validateWebhookUrlIfChanged } from "@/modules/webhooks/utils/validate-webhook-url"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; import { ConflictException, Injectable, NotFoundException } from "@nestjs/common"; @@ -12,6 +13,8 @@ export class OrganizationsWebhooksService { ) {} async createWebhook(orgId: number, body: PipedInputWebhookType) { + validateWebhookUrl(body.subscriberUrl); + const existingWebhook = await this.organizationsWebhooksRepository.findWebhookByUrl( orgId, body.subscriberUrl @@ -40,6 +43,8 @@ export class OrganizationsWebhooksService { } async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) { + const existingSubscriberUrl = await this.webhooksRepository.getWebhookSubscriberUrl(webhookId); + validateWebhookUrlIfChanged(body.subscriberUrl, existingSubscriberUrl); return this.webhooksRepository.updateWebhook(webhookId, body); } } diff --git a/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts index 8ec91644b4..90430e9a4a 100644 --- a/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts +++ b/apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts @@ -1,4 +1,5 @@ import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; @@ -9,6 +10,8 @@ export class EventTypeWebhooksService { constructor(private readonly webhooksRepository: WebhooksRepository) {} async createEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) { + validateWebhookUrl(body.subscriberUrl); + if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) { throw new BadRequestException( "DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks" diff --git a/apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts index 1e995d8677..014fcb8663 100644 --- a/apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts +++ b/apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts @@ -1,4 +1,5 @@ import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; import { ConflictException, Injectable } from "@nestjs/common"; @@ -7,6 +8,8 @@ export class OAuthClientWebhooksService { constructor(private readonly webhooksRepository: WebhooksRepository) {} async createOAuthClientWebhook(platformOAuthClientId: string, body: PipedInputWebhookType) { + validateWebhookUrl(body.subscriberUrl); + const existingWebhook = await this.webhooksRepository.getOAuthClientWebhookByUrl( platformOAuthClientId, body.subscriberUrl diff --git a/apps/api/v2/src/modules/webhooks/services/team-event-type-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/team-event-type-webhooks.service.ts index 6bee517af5..e34cd9c966 100644 --- a/apps/api/v2/src/modules/webhooks/services/team-event-type-webhooks.service.ts +++ b/apps/api/v2/src/modules/webhooks/services/team-event-type-webhooks.service.ts @@ -1,6 +1,7 @@ import { WebhookTriggerEvents } from "@calcom/prisma/enums"; import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; import type { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; @Injectable() @@ -8,6 +9,8 @@ export class TeamEventTypeWebhooksService { constructor(private readonly webhooksRepository: WebhooksRepository) {} async createTeamEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) { + validateWebhookUrl(body.subscriberUrl); + if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) { throw new BadRequestException( "DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks" diff --git a/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts index dd1616736d..8c66ba677a 100644 --- a/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts +++ b/apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts @@ -1,4 +1,5 @@ import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe"; +import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; import { BadRequestException, ConflictException, Injectable } from "@nestjs/common"; @@ -9,6 +10,8 @@ export class UserWebhooksService { constructor(private readonly webhooksRepository: WebhooksRepository) {} async createUserWebhook(userId: number, body: PipedInputWebhookType) { + validateWebhookUrl(body.subscriberUrl); + if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) { throw new BadRequestException( "DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks" diff --git a/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts b/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts index 087fe31221..10c8cc060e 100644 --- a/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts +++ b/apps/api/v2/src/modules/webhooks/services/webhooks.service.ts @@ -1,4 +1,5 @@ import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input"; +import { validateWebhookUrlIfChanged } from "@/modules/webhooks/utils/validate-webhook-url"; import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository"; import { Injectable, NotFoundException } from "@nestjs/common"; @@ -7,6 +8,8 @@ export class WebhooksService { constructor(private readonly webhooksRepository: WebhooksRepository) {} async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) { + const existingSubscriberUrl = await this.webhooksRepository.getWebhookSubscriberUrl(webhookId); + validateWebhookUrlIfChanged(body.subscriberUrl, existingSubscriberUrl); return this.webhooksRepository.updateWebhook(webhookId, body); } diff --git a/apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.spec.ts b/apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.spec.ts new file mode 100644 index 0000000000..ff83176e7f --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.spec.ts @@ -0,0 +1,95 @@ +import { BadRequestException } from "@nestjs/common"; + +// Mock the platform-libraries module +const mockValidateUrlForSSRFSync: jest.Mock = jest.fn(); +jest.mock("@calcom/platform-libraries", () => ({ + validateUrlForSSRFSync: (url: string) => mockValidateUrlForSSRFSync(url), +})); + +import { validateWebhookUrl, validateWebhookUrlIfChanged } from "./validate-webhook-url"; + +describe("validateWebhookUrl", () => { + beforeEach(() => { + mockValidateUrlForSSRFSync.mockReset(); + }); + + it("does not throw when URL is valid", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: true }); + + expect(() => validateWebhookUrl("https://example.com/webhook")).not.toThrow(); + expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("https://example.com/webhook"); + }); + + it("throws BadRequestException when URL is invalid", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false, error: "Private IP address" }); + + expect(() => validateWebhookUrl("https://127.0.0.1/webhook")).toThrow(BadRequestException); + expect(() => validateWebhookUrl("https://127.0.0.1/webhook")).toThrow( + "Webhook URL is not allowed: Private IP address" + ); + }); + + it("includes error message from validation result", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false, error: "Blocked hostname" }); + + expect(() => validateWebhookUrl("https://localhost/webhook")).toThrow( + "Webhook URL is not allowed: Blocked hostname" + ); + }); + + it("handles validation result without error message", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false }); + + expect(() => validateWebhookUrl("invalid-url")).toThrow( + "Webhook URL is not allowed: undefined" + ); + }); +}); + +describe("validateWebhookUrlIfChanged", () => { + beforeEach(() => { + mockValidateUrlForSSRFSync.mockReset(); + }); + + it("validates URL when it is different from existing", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: true }); + + validateWebhookUrlIfChanged("https://new.com/webhook", "https://old.com/webhook"); + + expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("https://new.com/webhook"); + }); + + it("throws when new URL is different and invalid", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false, error: "Only HTTPS URLs are allowed" }); + + expect(() => + validateWebhookUrlIfChanged("http://new.com/webhook", "https://old.com/webhook") + ).toThrow(BadRequestException); + }); + + it("does not validate when URL is unchanged", () => { + validateWebhookUrlIfChanged("https://same.com/webhook", "https://same.com/webhook"); + + expect(mockValidateUrlForSSRFSync).not.toHaveBeenCalled(); + }); + + it("does not validate when new URL is undefined", () => { + validateWebhookUrlIfChanged(undefined, "https://existing.com/webhook"); + + expect(mockValidateUrlForSSRFSync).not.toHaveBeenCalled(); + }); + + it("validates when existing URL is undefined and new URL is provided", () => { + mockValidateUrlForSSRFSync.mockReturnValue({ isValid: true }); + + validateWebhookUrlIfChanged("https://new.com/webhook", undefined); + + expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("https://new.com/webhook"); + }); + + it("does not validate when both URLs are undefined", () => { + validateWebhookUrlIfChanged(undefined, undefined); + + expect(mockValidateUrlForSSRFSync).not.toHaveBeenCalled(); + }); +}); \ No newline at end of file diff --git a/apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.ts b/apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.ts new file mode 100644 index 0000000000..47f1bc5248 --- /dev/null +++ b/apps/api/v2/src/modules/webhooks/utils/validate-webhook-url.ts @@ -0,0 +1,18 @@ +import { BadRequestException } from "@nestjs/common"; +import { validateUrlForSSRFSync } from "@calcom/platform-libraries"; + +export function validateWebhookUrl(subscriberUrl: string): void { + const validation = validateUrlForSSRFSync(subscriberUrl); + if (!validation.isValid) { + throw new BadRequestException(`Webhook URL is not allowed: ${validation.error}`); + } +} + +export function validateWebhookUrlIfChanged( + newSubscriberUrl: string | undefined, + existingSubscriberUrl: string | undefined +): void { + if (newSubscriberUrl && newSubscriberUrl !== existingSubscriberUrl) { + validateWebhookUrl(newSubscriberUrl); + } +} diff --git a/apps/api/v2/src/modules/webhooks/webhooks.repository.ts b/apps/api/v2/src/modules/webhooks/webhooks.repository.ts index d59ad1bfdf..e44739b414 100644 --- a/apps/api/v2/src/modules/webhooks/webhooks.repository.ts +++ b/apps/api/v2/src/modules/webhooks/webhooks.repository.ts @@ -49,6 +49,14 @@ export class WebhooksRepository { }); } + async getWebhookSubscriberUrl(webhookId: string): Promise { + const webhook = await this.dbRead.prisma.webhook.findFirst({ + where: { id: webhookId }, + select: { subscriberUrl: true }, + }); + return webhook?.subscriberUrl ?? undefined; + } + async getUserWebhooksPaginated(userId: number, skip: number, take: number) { return this.dbRead.prisma.webhook.findMany({ where: { userId }, diff --git a/packages/lib/package.json b/packages/lib/package.json index 3dd71f49d4..263a97e21c 100644 --- a/packages/lib/package.json +++ b/packages/lib/package.json @@ -21,6 +21,7 @@ "i18next": "23.2.3", "ical.js": "1.5.0", "ics": "2.37.0", + "ipaddr.js": "2.3.0", "jimp": "0.16.1", "lingo.dev": "0.117.14", "rrule": "2.7.1", diff --git a/packages/lib/ssrfProtection.test.ts b/packages/lib/ssrfProtection.test.ts index 8b4fcf28fb..f3000383ff 100644 --- a/packages/lib/ssrfProtection.test.ts +++ b/packages/lib/ssrfProtection.test.ts @@ -1,4 +1,10 @@ -import { describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +// Default mock for Cal.com SaaS (IS_SELF_HOSTED = false) +vi.mock("@calcom/lib/constants", () => ({ + IS_SELF_HOSTED: false, + IS_PRODUCTION: false, +})); import { isBlockedHostname, @@ -25,8 +31,8 @@ describe("isPrivateIP", () => { 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", + "8.8.8.8", // Google DNS + "1.1.1.1", // Cloudflare DNS "100.63.255.255", // just below RFC 6598 "100.128.0.0", // just above RFC 6598 ])("allows public IPv4 %s", (ip) => { @@ -86,6 +92,20 @@ describe("validateUrlForSSRFSync", () => { const result = validateUrlForSSRFSync(url); expect(result).toEqual({ isValid: false, error: expectedError }); }); + + it.each([ + ["https://[::1]/", "Private IP address"], + ["https://[fe80::1]/path", "Private IP address"], + ["https://[fc00::1]:8080/", "Private IP address"], + ["https://[::ffff:127.0.0.1]/", "Private IP address"], + ])("blocks IPv6 private addresses with brackets %s", (url, expectedError) => { + const result = validateUrlForSSRFSync(url); + expect(result).toEqual({ isValid: false, error: expectedError }); + }); + + it("allows public IPv6 addresses", () => { + expect(validateUrlForSSRFSync("https://[2001:4860:4860::8888]/").isValid).toBe(true); + }); }); describe("isTrustedInternalUrl", () => { @@ -101,3 +121,72 @@ describe("isTrustedInternalUrl", () => { expect(isTrustedInternalUrl("not-a-url", webappUrl)).toBe(false); }); }); + +describe("HTTP webhook exceptions", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("allows localhost HTTP when NEXT_PUBLIC_IS_E2E=1", () => { + vi.stubEnv("NEXT_PUBLIC_IS_E2E", "1"); + expect(validateUrlForSSRFSync("http://localhost:3000/webhook").isValid).toBe(true); + expect(validateUrlForSSRFSync("http://127.0.0.1:4000/webhook").isValid).toBe(true); + }); + + it("still blocks non-localhost URLs in E2E environment", () => { + vi.stubEnv("NEXT_PUBLIC_IS_E2E", "1"); + expect(validateUrlForSSRFSync("http://evil.com/webhook").isValid).toBe(false); + expect(validateUrlForSSRFSync("http://192.168.1.1/webhook").isValid).toBe(false); + }); +}); + +// Test self-hosted behavior with separate describe block using vi.doMock +describe("Self-hosted environment behavior", () => { + beforeEach(async () => { + vi.resetModules(); + vi.doMock("@calcom/lib/constants", () => ({ + IS_SELF_HOSTED: true, + IS_PRODUCTION: false, + })); + }); + + afterEach(() => { + vi.doUnmock("@calcom/lib/constants"); + }); + + it("allows private IPs for self-hosted (internal webhooks)", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("http://192.168.1.1/webhook").isValid).toBe(true); + expect(validateSelfHosted("http://10.0.0.1/webhook").isValid).toBe(true); + expect(validateSelfHosted("http://172.16.0.1/webhook").isValid).toBe(true); + }); + + it("allows HTTP URLs for self-hosted", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("http://internal-service.local/webhook").isValid).toBe(true); + expect(validateSelfHosted("http://localhost:3000/webhook").isValid).toBe(true); + }); + + it("still blocks cloud metadata endpoints even on self-hosted", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + // AWS/Azure/DigitalOcean/Oracle metadata + expect(validateSelfHosted("http://169.254.169.254/latest/meta-data/").isValid).toBe(false); + // GCP metadata + expect(validateSelfHosted("http://metadata.google.internal/computeMetadata/v1/").isValid).toBe(false); + expect(validateSelfHosted("http://metadata.google.com/computeMetadata/v1/").isValid).toBe(false); + // Azure alternate + expect(validateSelfHosted("http://169.254.169.253/metadata/instance").isValid).toBe(false); + }); + + it("allows HTTPS URLs for self-hosted", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("https://example.com/webhook").isValid).toBe(true); + }); + + it("blocks non-HTTP protocols for self-hosted", async () => { + const { validateUrlForSSRFSync: validateSelfHosted } = await import("./ssrfProtection"); + expect(validateSelfHosted("file:///etc/passwd").isValid).toBe(false); + expect(validateSelfHosted("ftp://internal-server/file").isValid).toBe(false); + expect(validateSelfHosted("javascript:alert(1)").isValid).toBe(false); + }); +}); \ No newline at end of file diff --git a/packages/lib/ssrfProtection.ts b/packages/lib/ssrfProtection.ts index b046d8ba71..ef174e18a3 100644 --- a/packages/lib/ssrfProtection.ts +++ b/packages/lib/ssrfProtection.ts @@ -1,6 +1,6 @@ import dns from "node:dns/promises"; -import net from "node:net"; - +import ipaddr from "ipaddr.js"; +import { IS_SELF_HOSTED } from "@calcom/lib/constants"; import logger from "@calcom/lib/logger"; const log: ReturnType = logger.getSubLogger({ prefix: ["ssrf-protection"] }); @@ -12,39 +12,31 @@ const log: ReturnType = logger.getSubLogger({ prefix * 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 -]; +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; -// 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", +// 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 ]; +// Hostnames blocked on Cal.com SaaS (includes metadata + localhost) +const BLOCKED_HOSTNAMES: string[] = [...CLOUD_METADATA_ENDPOINTS, "localhost"]; + 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", @@ -52,44 +44,54 @@ const ERRORS = { 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]; +function stripIPv6Brackets(hostname: string): string { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + return hostname.slice(1, -1); } - return null; + return hostname; } -/** 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); - } + const cleanIp = stripIPv6Brackets(ip); - if (PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip))) { + if (!ipaddr.isValid(cleanIp)) { return true; } - if (PRIVATE_IPV6_PATTERNS.some((pattern) => pattern.test(ip))) { + 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; } - - return false; } -/** Check if hostname is a blocked cloud metadata endpoint or localhost */ +// 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; @@ -116,6 +118,28 @@ function validateUrlCore(urlString: string): SSRFValidationResult | { url: URL } 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 }; } @@ -124,7 +148,9 @@ function validateUrlCore(urlString: string): SSRFValidationResult | { url: URL } return { isValid: false, error: ERRORS.BLOCKED_HOSTNAME }; } - if (net.isIP(url.hostname) !== 0 && isPrivateIP(url.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 }; } @@ -142,7 +168,7 @@ export async function validateUrlForSSRF(urlString: string): Promise): void { log.warn("SSRF attempt blocked", { - url: url.substring(0, 100), // Truncate for log safety + url: sanitizeUrlForLog(url), reason, ...context, }); diff --git a/packages/lib/zod/ssrfSafeUrl.ts b/packages/lib/zod/ssrfSafeUrl.ts index 671fe7c34e..4cd45968b7 100644 --- a/packages/lib/zod/ssrfSafeUrl.ts +++ b/packages/lib/zod/ssrfSafeUrl.ts @@ -3,36 +3,37 @@ 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" } -); +const SSRF_ERROR = "URL is not allowed for security reasons"; +const ssrfRefineOptions: { message: string } = { message: SSRF_ERROR }; + +// Validates URL for SSRF, allowing null/undefined/empty to pass through +const validateSsrfUrl = (url: string | null | undefined): boolean => { + if (url == null || url === "") return true; + return validateUrlForSSRFSync(url).isValid; +}; + /** - * Optional nullable variant for update schemas - * Allows null/undefined while validating provided values + * Zod schema for validating user-provided URLs before server-side fetching + * Applies synchronous SSRF checks only (no DNS resolution) */ +export const ssrfSafeUrlSchema: z.ZodEffects = zod + .string() + .refine((url) => validateUrlForSSRFSync(url).isValid, ssrfRefineOptions); + +// Optional nullable variant for update schemas export const optionalSsrfSafeUrlSchema: z.ZodEffects< z.ZodOptional>, string | null | undefined, string | null | undefined +> = zod.string().nullable().optional().refine(validateSsrfUrl, ssrfRefineOptions); + +// Optional variant for webhook edit schemas (non-nullable, rejects empty string) +export const optionalSsrfSafeUrlSchemaNotNullable: z.ZodEffects< + z.ZodOptional, + string | undefined, + string | 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" } - ); + .refine((url) => url === undefined || validateUrlForSSRFSync(url).isValid, ssrfRefineOptions); diff --git a/packages/trpc/server/routers/viewer/webhook/create.handler.ts b/packages/trpc/server/routers/viewer/webhook/create.handler.ts index 5fbfd4db8f..387e79b607 100644 --- a/packages/trpc/server/routers/viewer/webhook/create.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/create.handler.ts @@ -2,6 +2,7 @@ import { v4 } from "uuid"; import { PermissionCheckService } from "@calcom/features/pbac/services/permission-check.service"; import { updateTriggerForExistingBookings } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { validateUrlForSSRFSync } from "@calcom/lib/ssrfProtection"; import { prisma } from "@calcom/prisma"; import type { Webhook } from "@calcom/prisma/client"; import type { Prisma } from "@calcom/prisma/client"; @@ -23,6 +24,15 @@ type CreateOptions = { export const createHandler = async ({ ctx, input }: CreateOptions) => { const { user } = ctx; + // SSRF validation for webhook URL + const validation = validateUrlForSSRFSync(input.subscriberUrl); + if (!validation.isValid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Webhook URL is not allowed: ${validation.error}`, + }); + } + const webhookData: Prisma.WebhookCreateInput = { id: v4(), ...input, diff --git a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts index f80e16961e..88925d7c7f 100644 --- a/packages/trpc/server/routers/viewer/webhook/edit.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/edit.handler.ts @@ -4,6 +4,7 @@ import { deleteWebhookScheduledTriggers, cancelNoShowTasksForBooking, } from "@calcom/features/webhooks/lib/scheduleTrigger"; +import { validateUrlForSSRFSync } from "@calcom/lib/ssrfProtection"; import { prisma } from "@calcom/prisma"; import { MembershipRole } from "@calcom/prisma/enums"; import type { TrpcSessionUser } from "@calcom/trpc/server/types"; @@ -32,6 +33,17 @@ export const editHandler = async ({ input, ctx }: EditOptions) => { return null; } + // SSRF validation: only validate if URL is being changed + if (data.subscriberUrl && data.subscriberUrl !== webhook.subscriberUrl) { + const validation = validateUrlForSSRFSync(data.subscriberUrl); + if (!validation.isValid) { + throw new TRPCError({ + code: "BAD_REQUEST", + message: `Webhook URL is not allowed: ${validation.error}`, + }); + } + } + if (webhook.platform) { const { user } = ctx; if (user?.role !== "ADMIN") { diff --git a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts index d19cd8e736..a12cb6fb97 100644 --- a/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts +++ b/packages/trpc/server/routers/viewer/webhook/testTrigger.handler.ts @@ -1,7 +1,7 @@ import { DEFAULT_WEBHOOK_VERSION } from "@calcom/features/webhooks/lib/interface/IWebhookRepository"; import type { EventPayloadType } from "@calcom/features/webhooks/lib/sendPayload"; import sendPayload from "@calcom/features/webhooks/lib/sendPayload"; - +import { validateUrlForSSRFSync } from "@calcom/lib/ssrfProtection"; import { getTranslation } from "@calcom/lib/server/i18n"; import type { TTestTriggerInputSchema } from "./testTrigger.schema"; @@ -13,6 +13,17 @@ type TestTriggerOptions = { export const testTriggerHandler = async ({ ctx: _ctx, input }: TestTriggerOptions) => { const { url, type, payloadTemplate = null, secret = null } = input; + + // SSRF validation for webhook URL + const validation = validateUrlForSSRFSync(url); + if (!validation.isValid) { + return { + ok: false, + status: 400, + message: `Webhook URL is not allowed: ${validation.error}`, + }; + } + const translation = await getTranslation("en", "common"); const language = { locale: "en", diff --git a/yarn.lock b/yarn.lock index e071bb8f16..665e9f2e46 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2856,6 +2856,7 @@ __metadata: i18next: "npm:23.2.3" ical.js: "npm:1.5.0" ics: "npm:2.37.0" + ipaddr.js: "npm:2.3.0" jimp: "npm:0.16.1" lingo.dev: "npm:0.117.14" rrule: "npm:2.7.1" @@ -25484,6 +25485,13 @@ __metadata: languageName: node linkType: hard +"ipaddr.js@npm:2.3.0": + version: 2.3.0 + resolution: "ipaddr.js@npm:2.3.0" + checksum: 10/be3d01bc2e20fc2dc5349b489ea40883954b816ce3e57aa48ad943d4e7c4ace501f28a7a15bde4b96b6b97d0fbb28d599ff2f87399f3cda7bd728889402eed3b + languageName: node + linkType: hard + "is-absolute@npm:^1.0.0": version: 1.0.0 resolution: "is-absolute@npm:1.0.0"