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:
+5
@@ -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 },
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user