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>
This commit is contained in:
Pedro Castro
2026-02-05 16:18:10 -03:00
committed by GitHub
co-authored by Devin AI
parent c57b517555
commit cb36fc201f
17 changed files with 386 additions and 75 deletions
@@ -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);
}
}
@@ -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"
@@ -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
@@ -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"
@@ -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"
@@ -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);
}
@@ -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();
});
});
@@ -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);
}
}
@@ -49,6 +49,14 @@ export class WebhooksRepository {
});
}
async getWebhookSubscriberUrl(webhookId: string): Promise<string | undefined> {
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 },
+1
View File
@@ -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",
+92 -3
View File
@@ -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);
});
});
+86 -48
View File
@@ -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<typeof logger.getSubLogger> = logger.getSubLogger({ prefix: ["ssrf-protection"] });
@@ -12,39 +12,31 @@ const log: ReturnType<typeof logger.getSubLogger> = 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<SSRFValidat
return result;
}
// DNS rebinding protection: resolve ALL IPs and check each one
// DNS rebinding protection: resolve IPs and check each one
try {
const addresses = await dns.lookup(result.url.hostname, { all: true });
for (const { address } of addresses) {
@@ -171,7 +197,7 @@ export function validateUrlForSSRFSync(urlString: string): SSRFValidationResult
return { isValid: true };
}
/** Check if URL belongs to the same origin as the webapp (trusted internal URL) */
// 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;
@@ -180,10 +206,22 @@ export function isTrustedInternalUrl(url: string, webappUrl: string): boolean {
}
}
/** Log blocked SSRF attempts for security monitoring and incident response */
// Sanitize URL for logging - removes query params and credentials that may contain secrets
function sanitizeUrlForLog(urlString: string): string {
try {
const url = new URL(urlString);
// Only log origin + pathname, exclude query params, hash, and credentials
return `${url.origin}${url.pathname}`.substring(0, 100);
} catch {
// If URL parsing fails, truncate and redact potential secrets
return `${urlString.substring(0, 50).replace(/[?#].*$/, "")}...`;
}
}
// 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
url: sanitizeUrlForLog(url),
reason,
...context,
});
+24 -23
View File
@@ -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<z.ZodString, string, string> = 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<z.ZodString, string, string> = zod
.string()
.refine((url) => validateUrlForSSRFSync(url).isValid, ssrfRefineOptions);
// Optional nullable variant for update schemas
export const optionalSsrfSafeUrlSchema: z.ZodEffects<
z.ZodOptional<z.ZodNullable<z.ZodString>>,
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<z.ZodString>,
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);
@@ -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,
@@ -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") {
@@ -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",
+8
View File
@@ -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"