Skip to content

Commit cb36fc2

Browse files
fix: add URL validation to webhook endpoints (calcom#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>
1 parent c57b517 commit cb36fc2

17 files changed

Lines changed: 386 additions & 75 deletions

apps/api/v2/src/modules/organizations/webhooks/services/organizations-webhooks.service.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { OrganizationsWebhooksRepository } from "@/modules/organizations/webhooks/organizations-webhooks.repository";
22
import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
33
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
4+
import { validateWebhookUrl, validateWebhookUrlIfChanged } from "@/modules/webhooks/utils/validate-webhook-url";
45
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
56
import { ConflictException, Injectable, NotFoundException } from "@nestjs/common";
67

@@ -12,6 +13,8 @@ export class OrganizationsWebhooksService {
1213
) {}
1314

1415
async createWebhook(orgId: number, body: PipedInputWebhookType) {
16+
validateWebhookUrl(body.subscriberUrl);
17+
1518
const existingWebhook = await this.organizationsWebhooksRepository.findWebhookByUrl(
1619
orgId,
1720
body.subscriberUrl
@@ -40,6 +43,8 @@ export class OrganizationsWebhooksService {
4043
}
4144

4245
async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) {
46+
const existingSubscriberUrl = await this.webhooksRepository.getWebhookSubscriberUrl(webhookId);
47+
validateWebhookUrlIfChanged(body.subscriberUrl, existingSubscriberUrl);
4348
return this.webhooksRepository.updateWebhook(webhookId, body);
4449
}
4550
}

apps/api/v2/src/modules/webhooks/services/event-type-webhooks.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
2+
import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url";
23
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
34
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
45

@@ -9,6 +10,8 @@ export class EventTypeWebhooksService {
910
constructor(private readonly webhooksRepository: WebhooksRepository) {}
1011

1112
async createEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) {
13+
validateWebhookUrl(body.subscriberUrl);
14+
1215
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
1316
throw new BadRequestException(
1417
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"

apps/api/v2/src/modules/webhooks/services/oauth-clients-webhooks.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
2+
import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url";
23
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
34
import { ConflictException, Injectable } from "@nestjs/common";
45

@@ -7,6 +8,8 @@ export class OAuthClientWebhooksService {
78
constructor(private readonly webhooksRepository: WebhooksRepository) {}
89

910
async createOAuthClientWebhook(platformOAuthClientId: string, body: PipedInputWebhookType) {
11+
validateWebhookUrl(body.subscriberUrl);
12+
1013
const existingWebhook = await this.webhooksRepository.getOAuthClientWebhookByUrl(
1114
platformOAuthClientId,
1215
body.subscriberUrl

apps/api/v2/src/modules/webhooks/services/team-event-type-webhooks.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,16 @@
11
import { WebhookTriggerEvents } from "@calcom/prisma/enums";
22
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
33
import type { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
4+
import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url";
45
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
56

67
@Injectable()
78
export class TeamEventTypeWebhooksService {
89
constructor(private readonly webhooksRepository: WebhooksRepository) {}
910

1011
async createTeamEventTypeWebhook(eventTypeId: number, body: PipedInputWebhookType) {
12+
validateWebhookUrl(body.subscriberUrl);
13+
1114
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
1215
throw new BadRequestException(
1316
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"

apps/api/v2/src/modules/webhooks/services/user-webhooks.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { PipedInputWebhookType } from "@/modules/webhooks/pipes/WebhookInputPipe";
2+
import { validateWebhookUrl } from "@/modules/webhooks/utils/validate-webhook-url";
23
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
34
import { BadRequestException, ConflictException, Injectable } from "@nestjs/common";
45

@@ -9,6 +10,8 @@ export class UserWebhooksService {
910
constructor(private readonly webhooksRepository: WebhooksRepository) {}
1011

1112
async createUserWebhook(userId: number, body: PipedInputWebhookType) {
13+
validateWebhookUrl(body.subscriberUrl);
14+
1215
if (body.eventTriggers.includes(WebhookTriggerEvents.DELEGATION_CREDENTIAL_ERROR)) {
1316
throw new BadRequestException(
1417
"DELEGATION_CREDENTIAL_ERROR trigger is only available for organization webhooks"

apps/api/v2/src/modules/webhooks/services/webhooks.service.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { UpdateWebhookInputDto } from "@/modules/webhooks/inputs/webhook.input";
2+
import { validateWebhookUrlIfChanged } from "@/modules/webhooks/utils/validate-webhook-url";
23
import { WebhooksRepository } from "@/modules/webhooks/webhooks.repository";
34
import { Injectable, NotFoundException } from "@nestjs/common";
45

@@ -7,6 +8,8 @@ export class WebhooksService {
78
constructor(private readonly webhooksRepository: WebhooksRepository) {}
89

910
async updateWebhook(webhookId: string, body: UpdateWebhookInputDto) {
11+
const existingSubscriberUrl = await this.webhooksRepository.getWebhookSubscriberUrl(webhookId);
12+
validateWebhookUrlIfChanged(body.subscriberUrl, existingSubscriberUrl);
1013
return this.webhooksRepository.updateWebhook(webhookId, body);
1114
}
1215

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
import { BadRequestException } from "@nestjs/common";
2+
3+
// Mock the platform-libraries module
4+
const mockValidateUrlForSSRFSync: jest.Mock = jest.fn();
5+
jest.mock("@calcom/platform-libraries", () => ({
6+
validateUrlForSSRFSync: (url: string) => mockValidateUrlForSSRFSync(url),
7+
}));
8+
9+
import { validateWebhookUrl, validateWebhookUrlIfChanged } from "./validate-webhook-url";
10+
11+
describe("validateWebhookUrl", () => {
12+
beforeEach(() => {
13+
mockValidateUrlForSSRFSync.mockReset();
14+
});
15+
16+
it("does not throw when URL is valid", () => {
17+
mockValidateUrlForSSRFSync.mockReturnValue({ isValid: true });
18+
19+
expect(() => validateWebhookUrl("https://example.com/webhook")).not.toThrow();
20+
expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("https://example.com/webhook");
21+
});
22+
23+
it("throws BadRequestException when URL is invalid", () => {
24+
mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false, error: "Private IP address" });
25+
26+
expect(() => validateWebhookUrl("https://127.0.0.1/webhook")).toThrow(BadRequestException);
27+
expect(() => validateWebhookUrl("https://127.0.0.1/webhook")).toThrow(
28+
"Webhook URL is not allowed: Private IP address"
29+
);
30+
});
31+
32+
it("includes error message from validation result", () => {
33+
mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false, error: "Blocked hostname" });
34+
35+
expect(() => validateWebhookUrl("https://localhost/webhook")).toThrow(
36+
"Webhook URL is not allowed: Blocked hostname"
37+
);
38+
});
39+
40+
it("handles validation result without error message", () => {
41+
mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false });
42+
43+
expect(() => validateWebhookUrl("invalid-url")).toThrow(
44+
"Webhook URL is not allowed: undefined"
45+
);
46+
});
47+
});
48+
49+
describe("validateWebhookUrlIfChanged", () => {
50+
beforeEach(() => {
51+
mockValidateUrlForSSRFSync.mockReset();
52+
});
53+
54+
it("validates URL when it is different from existing", () => {
55+
mockValidateUrlForSSRFSync.mockReturnValue({ isValid: true });
56+
57+
validateWebhookUrlIfChanged("https://new.com/webhook", "https://old.com/webhook");
58+
59+
expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("https://new.com/webhook");
60+
});
61+
62+
it("throws when new URL is different and invalid", () => {
63+
mockValidateUrlForSSRFSync.mockReturnValue({ isValid: false, error: "Only HTTPS URLs are allowed" });
64+
65+
expect(() =>
66+
validateWebhookUrlIfChanged("http://new.com/webhook", "https://old.com/webhook")
67+
).toThrow(BadRequestException);
68+
});
69+
70+
it("does not validate when URL is unchanged", () => {
71+
validateWebhookUrlIfChanged("https://same.com/webhook", "https://same.com/webhook");
72+
73+
expect(mockValidateUrlForSSRFSync).not.toHaveBeenCalled();
74+
});
75+
76+
it("does not validate when new URL is undefined", () => {
77+
validateWebhookUrlIfChanged(undefined, "https://existing.com/webhook");
78+
79+
expect(mockValidateUrlForSSRFSync).not.toHaveBeenCalled();
80+
});
81+
82+
it("validates when existing URL is undefined and new URL is provided", () => {
83+
mockValidateUrlForSSRFSync.mockReturnValue({ isValid: true });
84+
85+
validateWebhookUrlIfChanged("https://new.com/webhook", undefined);
86+
87+
expect(mockValidateUrlForSSRFSync).toHaveBeenCalledWith("https://new.com/webhook");
88+
});
89+
90+
it("does not validate when both URLs are undefined", () => {
91+
validateWebhookUrlIfChanged(undefined, undefined);
92+
93+
expect(mockValidateUrlForSSRFSync).not.toHaveBeenCalled();
94+
});
95+
});
Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
import { BadRequestException } from "@nestjs/common";
2+
import { validateUrlForSSRFSync } from "@calcom/platform-libraries";
3+
4+
export function validateWebhookUrl(subscriberUrl: string): void {
5+
const validation = validateUrlForSSRFSync(subscriberUrl);
6+
if (!validation.isValid) {
7+
throw new BadRequestException(`Webhook URL is not allowed: ${validation.error}`);
8+
}
9+
}
10+
11+
export function validateWebhookUrlIfChanged(
12+
newSubscriberUrl: string | undefined,
13+
existingSubscriberUrl: string | undefined
14+
): void {
15+
if (newSubscriberUrl && newSubscriberUrl !== existingSubscriberUrl) {
16+
validateWebhookUrl(newSubscriberUrl);
17+
}
18+
}

apps/api/v2/src/modules/webhooks/webhooks.repository.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@ export class WebhooksRepository {
4949
});
5050
}
5151

52+
async getWebhookSubscriberUrl(webhookId: string): Promise<string | undefined> {
53+
const webhook = await this.dbRead.prisma.webhook.findFirst({
54+
where: { id: webhookId },
55+
select: { subscriberUrl: true },
56+
});
57+
return webhook?.subscriberUrl ?? undefined;
58+
}
59+
5260
async getUserWebhooksPaginated(userId: number, skip: number, take: number) {
5361
return this.dbRead.prisma.webhook.findMany({
5462
where: { userId },

packages/lib/package.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"i18next": "23.2.3",
2222
"ical.js": "1.5.0",
2323
"ics": "2.37.0",
24+
"ipaddr.js": "2.3.0",
2425
"jimp": "0.16.1",
2526
"lingo.dev": "0.117.14",
2627
"rrule": "2.7.1",

0 commit comments

Comments
 (0)