diff --git a/apps/web/app/api/(internal)/pipeline/route.ts b/apps/web/app/api/(internal)/pipeline/route.ts index 995b6aca49..aee4c1354f 100644 --- a/apps/web/app/api/(internal)/pipeline/route.ts +++ b/apps/web/app/api/(internal)/pipeline/route.ts @@ -15,6 +15,7 @@ import { getOrganizationByEnvironmentId } from "@/lib/organization/service"; import { getResponseCountBySurveyId } from "@/lib/response/service"; import { getSurvey, updateSurvey } from "@/lib/survey/service"; import { convertDatesInObject } from "@/lib/time"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { queueAuditEvent } from "@/modules/ee/audit-logs/lib/handler"; import { TAuditStatus, UNKNOWN_DATA } from "@/modules/ee/audit-logs/types/audit-log"; import { sendResponseFinishedEmail } from "@/modules/email"; @@ -135,13 +136,17 @@ export const POST = async (request: Request) => { ); } - return fetchWithTimeout(webhook.url, { - method: "POST", - headers: requestHeaders, - body, - }).catch((error) => { - logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`); - }); + return validateWebhookUrl(webhook.url) + .then(() => + fetchWithTimeout(webhook.url, { + method: "POST", + headers: requestHeaders, + body, + }) + ) + .catch((error) => { + logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`); + }); }); if (event === "responseFinished") { diff --git a/apps/web/app/api/v1/webhooks/lib/webhook.test.ts b/apps/web/app/api/v1/webhooks/lib/webhook.test.ts index b9b1653a76..bf9b60b059 100644 --- a/apps/web/app/api/v1/webhooks/lib/webhook.test.ts +++ b/apps/web/app/api/v1/webhooks/lib/webhook.test.ts @@ -2,10 +2,11 @@ import { Prisma, WebhookSource } from "@prisma/client"; import { cleanup } from "@testing-library/react"; import { afterEach, describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; -import { DatabaseError, ValidationError } from "@formbricks/types/errors"; +import { DatabaseError, InvalidInputError, ValidationError } from "@formbricks/types/errors"; import { createWebhook } from "@/app/api/v1/webhooks/lib/webhook"; import { TWebhookInput } from "@/app/api/v1/webhooks/types/webhooks"; import { validateInputs } from "@/lib/utils/validate"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; vi.mock("@formbricks/database", () => ({ prisma: { @@ -23,6 +24,10 @@ vi.mock("@/lib/crypto", () => ({ generateWebhookSecret: vi.fn(() => "whsec_test_secret_1234567890"), })); +vi.mock("@/lib/utils/validate-webhook-url", () => ({ + validateWebhookUrl: vi.fn().mockResolvedValue(undefined), +})); + describe("createWebhook", () => { afterEach(() => { cleanup(); @@ -75,6 +80,41 @@ describe("createWebhook", () => { expect(result).toEqual(createdWebhook); }); + test("should call validateWebhookUrl with the provided URL", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "https://example.com", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1"], + }; + + vi.mocked(prisma.webhook.create).mockResolvedValueOnce({} as any); + + await createWebhook(webhookInput); + + expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com"); + }); + + test("should throw InvalidInputError and skip Prisma create when URL fails SSRF validation", async () => { + const webhookInput: TWebhookInput = { + environmentId: "test-env-id", + name: "Test Webhook", + url: "http://169.254.169.254/latest/meta-data/", + source: "user", + triggers: ["responseCreated"], + surveyIds: ["survey1"], + }; + + vi.mocked(validateWebhookUrl).mockRejectedValueOnce( + new InvalidInputError("Webhook URL must not point to private or internal IP addresses") + ); + + await expect(createWebhook(webhookInput)).rejects.toThrow(InvalidInputError); + expect(prisma.webhook.create).not.toHaveBeenCalled(); + }); + test("should throw a ValidationError if the input data does not match the ZWebhookInput schema", async () => { const invalidWebhookInput = { environmentId: "test-env-id", diff --git a/apps/web/app/api/v1/webhooks/lib/webhook.ts b/apps/web/app/api/v1/webhooks/lib/webhook.ts index 0173b324ab..bbc9e26d70 100644 --- a/apps/web/app/api/v1/webhooks/lib/webhook.ts +++ b/apps/web/app/api/v1/webhooks/lib/webhook.ts @@ -6,9 +6,11 @@ import { TWebhookInput, ZWebhookInput } from "@/app/api/v1/webhooks/types/webhoo import { ITEMS_PER_PAGE } from "@/lib/constants"; import { generateWebhookSecret } from "@/lib/crypto"; import { validateInputs } from "@/lib/utils/validate"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; export const createWebhook = async (webhookInput: TWebhookInput): Promise => { validateInputs([webhookInput, ZWebhookInput]); + await validateWebhookUrl(webhookInput.url); try { const secret = generateWebhookSecret(); diff --git a/apps/web/lib/utils/validate-webhook-url.test.ts b/apps/web/lib/utils/validate-webhook-url.test.ts new file mode 100644 index 0000000000..ca2233c0a1 --- /dev/null +++ b/apps/web/lib/utils/validate-webhook-url.test.ts @@ -0,0 +1,297 @@ +import dns from "node:dns"; +import { afterEach, describe, expect, test, vi } from "vitest"; +import { validateWebhookUrl } from "./validate-webhook-url"; + +vi.mock("node:dns", () => ({ + default: { + resolve: vi.fn(), + resolve6: vi.fn(), + }, +})); + +const mockResolve = vi.mocked(dns.resolve); +const mockResolve6 = vi.mocked(dns.resolve6); + +type DnsCallback = (err: NodeJS.ErrnoException | null, addresses: string[]) => void; + +const setupDnsResolution = (ipv4: string[] | null, ipv6: string[] | null = null): void => { + // dns.resolve/resolve6 have overloaded signatures; we only mock the (hostname, callback) form + mockResolve.mockImplementation(((_hostname: string, callback: DnsCallback) => { + if (ipv4) { + callback(null, ipv4); + } else { + callback(new Error("ENOTFOUND"), []); + } + }) as never); + + mockResolve6.mockImplementation(((_hostname: string, callback: DnsCallback) => { + if (ipv6) { + callback(null, ipv6); + } else { + callback(new Error("ENOTFOUND"), []); + } + }) as never); +}; + +afterEach(() => { + vi.clearAllMocks(); +}); + +describe("validateWebhookUrl", () => { + describe("valid public URLs", () => { + test("accepts HTTPS URL resolving to a public IPv4 address", async () => { + setupDnsResolution(["93.184.216.34"]); + await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined(); + }); + + test("accepts HTTP URL resolving to a public IPv4 address", async () => { + setupDnsResolution(["93.184.216.34"]); + await expect(validateWebhookUrl("http://example.com/webhook")).resolves.toBeUndefined(); + }); + + test("accepts URL with port and path segments", async () => { + setupDnsResolution(["93.184.216.34"]); + await expect(validateWebhookUrl("https://example.com:8443/api/v1/webhook")).resolves.toBeUndefined(); + }); + + test("accepts URL resolving to a public IPv6 address", async () => { + setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]); + await expect(validateWebhookUrl("https://example.com/webhook")).resolves.toBeUndefined(); + }); + + test("accepts a public IPv4 address as hostname", async () => { + await expect(validateWebhookUrl("https://93.184.216.34/webhook")).resolves.toBeUndefined(); + }); + }); + + describe("URL format validation", () => { + test("rejects a completely malformed string", async () => { + await expect(validateWebhookUrl("not-a-url")).rejects.toThrow("Invalid webhook URL format"); + }); + + test("rejects an empty string", async () => { + await expect(validateWebhookUrl("")).rejects.toThrow("Invalid webhook URL format"); + }); + }); + + describe("protocol validation", () => { + test("rejects FTP protocol", async () => { + await expect(validateWebhookUrl("ftp://example.com/file")).rejects.toThrow( + "Webhook URL must use HTTPS or HTTP protocol" + ); + }); + + test("rejects file:// protocol", async () => { + await expect(validateWebhookUrl("file:///etc/passwd")).rejects.toThrow( + "Webhook URL must use HTTPS or HTTP protocol" + ); + }); + + test("rejects javascript: protocol", async () => { + await expect(validateWebhookUrl("javascript:alert(1)")).rejects.toThrow( + "Webhook URL must use HTTPS or HTTP protocol" + ); + }); + }); + + describe("blocked hostname validation", () => { + test("rejects localhost", async () => { + await expect(validateWebhookUrl("http://localhost/admin")).rejects.toThrow( + "Webhook URL must not point to localhost or internal services" + ); + }); + + test("rejects localhost.localdomain", async () => { + await expect(validateWebhookUrl("https://localhost.localdomain/path")).rejects.toThrow( + "Webhook URL must not point to localhost or internal services" + ); + }); + + test("rejects metadata.google.internal", async () => { + await expect(validateWebhookUrl("http://metadata.google.internal/computeMetadata/v1/")).rejects.toThrow( + "Webhook URL must not point to localhost or internal services" + ); + }); + }); + + describe("private IPv4 literal blocking", () => { + test("rejects 127.0.0.1 (loopback)", async () => { + await expect(validateWebhookUrl("http://127.0.0.1/metadata")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 127.0.0.53 (loopback range)", async () => { + await expect(validateWebhookUrl("http://127.0.0.53/")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 10.0.0.1 (Class A private)", async () => { + await expect(validateWebhookUrl("http://10.0.0.1/internal")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 172.16.0.1 (Class B private)", async () => { + await expect(validateWebhookUrl("http://172.16.0.1/internal")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 172.31.255.255 (Class B private upper bound)", async () => { + await expect(validateWebhookUrl("http://172.31.255.255/internal")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 192.168.1.1 (Class C private)", async () => { + await expect(validateWebhookUrl("http://192.168.1.1/internal")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 169.254.169.254 (AWS/GCP/Azure metadata endpoint)", async () => { + await expect(validateWebhookUrl("http://169.254.169.254/latest/meta-data/")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 0.0.0.0 ('this' network)", async () => { + await expect(validateWebhookUrl("http://0.0.0.0/")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects 100.64.0.1 (CGNAT / shared address space)", async () => { + await expect(validateWebhookUrl("http://100.64.0.1/")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + }); + + describe("DNS resolution with private IP results", () => { + test("rejects hostname resolving to loopback address", async () => { + setupDnsResolution(["127.0.0.1"]); + await expect(validateWebhookUrl("https://evil.com/steal")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to cloud metadata endpoint IP", async () => { + setupDnsResolution(["169.254.169.254"]); + await expect(validateWebhookUrl("https://attacker.com/ssrf")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to Class A private network", async () => { + setupDnsResolution(["10.0.0.5"]); + await expect(validateWebhookUrl("https://internal.service/api")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to Class C private network", async () => { + setupDnsResolution(["192.168.0.1"]); + await expect(validateWebhookUrl("https://sneaky.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to IPv6 loopback", async () => { + setupDnsResolution(null, ["::1"]); + await expect(validateWebhookUrl("https://sneaky.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to IPv6 link-local", async () => { + setupDnsResolution(null, ["fe80::1"]); + await expect(validateWebhookUrl("https://link-local.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to IPv6 unique local address", async () => { + setupDnsResolution(null, ["fd12:3456:789a::1"]); + await expect(validateWebhookUrl("https://ula.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to IPv4-mapped IPv6 private address (dotted)", async () => { + setupDnsResolution(null, ["::ffff:192.168.1.1"]); + await expect(validateWebhookUrl("https://mapped.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hostname resolving to IPv4-mapped IPv6 private address (hex-encoded)", async () => { + setupDnsResolution(null, ["::ffff:c0a8:0101"]); // 192.168.1.1 in hex + await expect(validateWebhookUrl("https://hex-mapped.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hex-encoded IPv4-mapped loopback (::ffff:7f00:0001)", async () => { + setupDnsResolution(null, ["::ffff:7f00:0001"]); // 127.0.0.1 in hex + await expect(validateWebhookUrl("https://hex-loopback.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects hex-encoded IPv4-mapped metadata endpoint (::ffff:a9fe:a9fe)", async () => { + setupDnsResolution(null, ["::ffff:a9fe:a9fe"]); // 169.254.169.254 in hex + await expect(validateWebhookUrl("https://hex-metadata.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("accepts hex-encoded IPv4-mapped public address", async () => { + setupDnsResolution(null, ["::ffff:5db8:d822"]); // 93.184.216.34 in hex + await expect(validateWebhookUrl("https://hex-public.example.com/webhook")).resolves.toBeUndefined(); + }); + + test("rejects when any resolved IP is private (mixed public + private)", async () => { + setupDnsResolution(["93.184.216.34", "192.168.1.1"]); + await expect(validateWebhookUrl("https://dual.example.com/webhook")).rejects.toThrow( + "Webhook URL must not point to private or internal IP addresses" + ); + }); + + test("rejects unresolvable hostname", async () => { + setupDnsResolution(null, null); + await expect(validateWebhookUrl("https://nonexistent.invalid/path")).rejects.toThrow( + "Could not resolve webhook URL hostname" + ); + }); + + test("rejects with timeout error when DNS resolution hangs", async () => { + vi.useFakeTimers(); + + mockResolve.mockImplementation((() => { + // never calls callback — simulates a hanging DNS server + }) as never); + + const promise = validateWebhookUrl("https://slow-dns.example.com/webhook"); + + const assertion = expect(promise).rejects.toThrow( + "DNS resolution timed out for webhook URL hostname: slow-dns.example.com" + ); + + await vi.advanceTimersByTimeAsync(3000); + await assertion; + + vi.useRealTimers(); + }); + }); + + describe("error type", () => { + test("throws InvalidInputError (not generic Error)", async () => { + await expect(validateWebhookUrl("http://127.0.0.1/")).rejects.toMatchObject({ + name: "InvalidInputError", + }); + }); + }); +}); diff --git a/apps/web/lib/utils/validate-webhook-url.ts b/apps/web/lib/utils/validate-webhook-url.ts new file mode 100644 index 0000000000..3bca19f3a7 --- /dev/null +++ b/apps/web/lib/utils/validate-webhook-url.ts @@ -0,0 +1,176 @@ +import "server-only"; +import dns from "node:dns"; +import { InvalidInputError } from "@formbricks/types/errors"; + +const BLOCKED_HOSTNAMES = new Set([ + "localhost", + "localhost.localdomain", + "ip6-localhost", + "ip6-loopback", + "metadata.google.internal", +]); + +const PRIVATE_IPV4_PATTERNS: RegExp[] = [ + /^127\./, // 127.0.0.0/8 – Loopback + /^10\./, // 10.0.0.0/8 – Class A private + /^172\.(1[6-9]|2\d|3[01])\./, // 172.16.0.0/12 – Class B private + /^192\.168\./, // 192.168.0.0/16 – Class C private + /^169\.254\./, // 169.254.0.0/16 – Link-local (AWS/GCP/Azure metadata) + /^0\./, // 0.0.0.0/8 – "This" network + /^100\.(6[4-9]|[7-9]\d|1[0-2]\d)\./, // 100.64.0.0/10 – Shared address space (RFC 6598) + /^192\.0\.0\./, // 192.0.0.0/24 – IETF protocol assignments + /^192\.0\.2\./, // 192.0.2.0/24 – TEST-NET-1 (documentation) + /^198\.51\.100\./, // 198.51.100.0/24 – TEST-NET-2 (documentation) + /^203\.0\.113\./, // 203.0.113.0/24 – TEST-NET-3 (documentation) + /^198\.1[89]\./, // 198.18.0.0/15 – Benchmarking + /^224\./, // 224.0.0.0/4 – Multicast + /^240\./, // 240.0.0.0/4 – Reserved for future use + /^255\.255\.255\.255$/, // Limited broadcast +]; + +const PRIVATE_IPV6_PREFIXES = [ + "::1", // Loopback + "fe80:", // Link-local + "fc", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::) + "fd", // Unique local address (ULA, fc00::/7 — covers fc00:: through fdff::) +]; + +const isPrivateIPv4 = (ip: string): boolean => { + return PRIVATE_IPV4_PATTERNS.some((pattern) => pattern.test(ip)); +}; + +const hexMappedToIPv4 = (hexPart: string): string | null => { + const groups = hexPart.split(":"); + if (groups.length !== 2) return null; + const high = Number.parseInt(groups[0], 16); + const low = Number.parseInt(groups[1], 16); + if (Number.isNaN(high) || Number.isNaN(low) || high > 0xffff || low > 0xffff) return null; + return `${(high >> 8) & 0xff}.${high & 0xff}.${(low >> 8) & 0xff}.${low & 0xff}`; +}; + +const isIPv4Mapped = (normalized: string): boolean => { + if (!normalized.startsWith("::ffff:")) return false; + const suffix = normalized.slice(7); // strip "::ffff:" + + if (suffix.includes(".")) { + return isPrivateIPv4(suffix); + } + const dotted = hexMappedToIPv4(suffix); + return dotted !== null && isPrivateIPv4(dotted); +}; + +const isPrivateIPv6 = (ip: string): boolean => { + const normalized = ip.toLowerCase(); + if (normalized === "::") return true; + if (isIPv4Mapped(normalized)) return true; + return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix)); +}; + +const isPrivateIP = (ip: string): boolean => { + return isPrivateIPv4(ip) || isPrivateIPv6(ip); +}; + +const DNS_TIMEOUT_MS = 3000; + +const resolveHostnameToIPs = (hostname: string): Promise => { + return new Promise((resolve, reject) => { + let settled = false; + + const settle = (fn: (value: T) => void, value: T): void => { + if (settled) return; + settled = true; + clearTimeout(timer); + fn(value); + }; + + const timer = setTimeout(() => { + settle(reject, new Error(`DNS resolution timed out for hostname: ${hostname}`)); + }, DNS_TIMEOUT_MS); + + dns.resolve(hostname, (errV4, ipv4Addresses) => { + const ipv4 = errV4 ? [] : ipv4Addresses; + + dns.resolve6(hostname, (errV6, ipv6Addresses) => { + const ipv6 = errV6 ? [] : ipv6Addresses; + const allAddresses = [...ipv4, ...ipv6]; + + if (allAddresses.length === 0) { + settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`)); + } else { + settle(resolve, allAddresses); + } + }); + }); + }); +}; + +const stripIPv6Brackets = (hostname: string): string => { + if (hostname.startsWith("[") && hostname.endsWith("]")) { + return hostname.slice(1, -1); + } + return hostname; +}; + +const IPV4_LITERAL = /^\d{1,3}(?:\.\d{1,3}){3}$/; + +/** + * Validates a webhook URL to prevent Server-Side Request Forgery (SSRF). + * + * Checks performed: + * 1. URL must be well-formed + * 2. Protocol must be HTTPS or HTTP + * 3. Hostname must not be a known internal name (localhost, metadata endpoints) + * 4. IP literal hostnames are checked directly against private ranges + * 5. Domain hostnames are resolved via DNS; all resulting IPs must be public + * + * @throws {InvalidInputError} when the URL fails any validation check + */ +export const validateWebhookUrl = async (url: string): Promise => { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new InvalidInputError("Invalid webhook URL format"); + } + + if (parsed.protocol !== "https:" && parsed.protocol !== "http:") { + throw new InvalidInputError("Webhook URL must use HTTPS or HTTP protocol"); + } + + const hostname = parsed.hostname; + + if (BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) { + throw new InvalidInputError("Webhook URL must not point to localhost or internal services"); + } + + // Direct IP literal — validate without DNS resolution + const isIPv4Literal = IPV4_LITERAL.test(hostname); + const isIPv6Literal = hostname.startsWith("["); + + if (isIPv4Literal || isIPv6Literal) { + const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname; + if (isPrivateIP(ip)) { + throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses"); + } + return; + } + + // Domain name — resolve DNS and validate every resolved IP + let resolvedIPs: string[]; + try { + resolvedIPs = await resolveHostnameToIPs(hostname); + } catch (error) { + const isTimeout = error instanceof Error && error.message.includes("timed out"); + throw new InvalidInputError( + isTimeout + ? `DNS resolution timed out for webhook URL hostname: ${hostname}` + : `Could not resolve webhook URL hostname: ${hostname}` + ); + } + + for (const ip of resolvedIPs) { + if (isPrivateIP(ip)) { + throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses"); + } + } +}; diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts index bca20af319..21686d99af 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/tests/webhook.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test, vi } from "vitest"; import { z } from "zod"; import { prisma } from "@formbricks/database"; +import { InvalidInputError } from "@formbricks/types/errors"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { mockedPrismaWebhookUpdateReturn, prismaNotFoundError, @@ -18,6 +20,10 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/utils/validate-webhook-url", () => ({ + validateWebhookUrl: vi.fn().mockResolvedValue(undefined), +})); + describe("getWebhook", () => { test("returns ok if webhook is found", async () => { vi.mocked(prisma.webhook.findUnique).mockResolvedValueOnce({ id: "123" }); @@ -63,6 +69,44 @@ describe("updateWebhook", () => { } }); + test("calls validateWebhookUrl when URL is provided", async () => { + vi.mocked(prisma.webhook.update).mockResolvedValueOnce(mockedPrismaWebhookUpdateReturn); + + await updateWebhook("123", mockedWebhookUpdateReturn); + + expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com"); + }); + + test("returns bad_request and skips Prisma update when URL fails SSRF validation", async () => { + vi.mocked(validateWebhookUrl).mockRejectedValueOnce( + new InvalidInputError("Webhook URL must not point to private or internal IP addresses") + ); + + const result = await updateWebhook("123", mockedWebhookUpdateReturn); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error.type).toBe("bad_request"); + expect(result.error.details[0].field).toBe("url"); + } + + expect(prisma.webhook.update).not.toHaveBeenCalled(); + }); + + test("returns internal_server_error when validateWebhookUrl throws an unexpected error", async () => { + vi.mocked(validateWebhookUrl).mockRejectedValueOnce(new Error("unexpected DNS failure")); + + const result = await updateWebhook("123", mockedWebhookUpdateReturn); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error.type).toBe("internal_server_error"); + expect(result.error.details[0].field).toBe("url"); + } + + expect(prisma.webhook.update).not.toHaveBeenCalled(); + }); + test("returns not_found if record does not exist", async () => { vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError); const result = await updateWebhook("999", mockedWebhookUpdateReturn); diff --git a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts index 438cd50810..812b46f84d 100644 --- a/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/[webhookId]/lib/webhook.ts @@ -3,6 +3,8 @@ import { z } from "zod"; import { prisma } from "@formbricks/database"; import { PrismaErrorType } from "@formbricks/database/types/error"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { InvalidInputError } from "@formbricks/types/errors"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { ZWebhookUpdateSchema } from "@/modules/api/v2/management/webhooks/[webhookId]/types/webhooks"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; @@ -34,6 +36,23 @@ export const updateWebhook = async ( webhookId: string, webhookInput: z.infer ): Promise> => { + if (webhookInput.url) { + try { + await validateWebhookUrl(webhookInput.url); + } catch (error) { + if (error instanceof InvalidInputError) { + return err({ + type: "bad_request", + details: [{ field: "url", issue: error.message }], + }); + } + return err({ + type: "internal_server_error", + details: [{ field: "url", issue: "Webhook URL validation failed unexpectedly" }], + }); + } + } + try { const updatedWebhook = await prisma.webhook.update({ where: { diff --git a/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts index 8ea78540b6..49f89ae356 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/tests/webhook.test.ts @@ -1,6 +1,8 @@ import { WebhookSource } from "@prisma/client"; import { describe, expect, test, vi } from "vitest"; import { prisma } from "@formbricks/database"; +import { InvalidInputError } from "@formbricks/types/errors"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { createWebhook, getWebhooks } from "../webhook"; @@ -15,6 +17,10 @@ vi.mock("@formbricks/database", () => ({ }, })); +vi.mock("@/lib/utils/validate-webhook-url", () => ({ + validateWebhookUrl: vi.fn().mockResolvedValue(undefined), +})); + describe("getWebhooks", () => { const environmentId = "env1"; const params = { @@ -89,6 +95,44 @@ describe("createWebhook", () => { } }); + test("calls validateWebhookUrl with the provided URL", async () => { + vi.mocked(prisma.webhook.create).mockResolvedValueOnce(createdWebhook); + + await createWebhook(inputWebhook); + + expect(validateWebhookUrl).toHaveBeenCalledWith("http://example.com"); + }); + + test("returns bad_request and skips Prisma create when URL fails SSRF validation", async () => { + vi.mocked(validateWebhookUrl).mockRejectedValueOnce( + new InvalidInputError("Webhook URL must not point to private or internal IP addresses") + ); + + const result = await createWebhook(inputWebhook); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error.type).toEqual("bad_request"); + expect(result.error.details[0].field).toEqual("url"); + } + + expect(prisma.webhook.create).not.toHaveBeenCalled(); + }); + + test("returns internal_server_error when validateWebhookUrl throws an unexpected error", async () => { + vi.mocked(validateWebhookUrl).mockRejectedValueOnce(new Error("unexpected DNS failure")); + + const result = await createWebhook(inputWebhook); + expect(result.ok).toBe(false); + + if (!result.ok) { + expect(result.error.type).toEqual("internal_server_error"); + expect(result.error.details[0].field).toEqual("url"); + } + + expect(prisma.webhook.create).not.toHaveBeenCalled(); + }); + test("returns error when creation fails", async () => { vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed")); diff --git a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts index 468fd2f085..ed2997d00c 100644 --- a/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts +++ b/apps/web/modules/api/v2/management/webhooks/lib/webhook.ts @@ -1,7 +1,9 @@ import { Prisma, Webhook } from "@prisma/client"; import { prisma } from "@formbricks/database"; import { Result, err, ok } from "@formbricks/types/error-handlers"; +import { InvalidInputError } from "@formbricks/types/errors"; import { generateWebhookSecret } from "@/lib/crypto"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { getWebhooksQuery } from "@/modules/api/v2/management/webhooks/lib/utils"; import { TGetWebhooksFilter, TWebhookInput } from "@/modules/api/v2/management/webhooks/types/webhooks"; import { ApiErrorResponseV2 } from "@/modules/api/v2/types/api-error"; @@ -49,6 +51,21 @@ export const getWebhooks = async ( export const createWebhook = async (webhook: TWebhookInput): Promise> => { const { environmentId, name, url, source, triggers, surveyIds } = webhook; + try { + await validateWebhookUrl(url); + } catch (error) { + if (error instanceof InvalidInputError) { + return err({ + type: "bad_request", + details: [{ field: "url", issue: error.message }], + }); + } + return err({ + type: "internal_server_error", + details: [{ field: "url", issue: "Webhook URL validation failed unexpectedly" }], + }); + } + try { const secret = generateWebhookSecret(); diff --git a/apps/web/modules/integrations/webhooks/lib/webhook.ts b/apps/web/modules/integrations/webhooks/lib/webhook.ts index 7491881218..6ca8d1e610 100644 --- a/apps/web/modules/integrations/webhooks/lib/webhook.ts +++ b/apps/web/modules/integrations/webhooks/lib/webhook.ts @@ -11,6 +11,7 @@ import { } from "@formbricks/types/errors"; import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto"; import { validateInputs } from "@/lib/utils/validate"; +import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url"; import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils"; import { TWebhookInput } from "../types/webhooks"; @@ -18,6 +19,10 @@ export const updateWebhook = async ( webhookId: string, webhookInput: Partial ): Promise => { + if (webhookInput.url) { + await validateWebhookUrl(webhookInput.url); + } + try { await prisma.webhook.update({ where: { @@ -66,6 +71,8 @@ export const createWebhook = async ( webhookInput: TWebhookInput, secret?: string ): Promise => { + await validateWebhookUrl(webhookInput.url); + try { if (isDiscordWebhook(webhookInput.url)) { throw new UnknownError("Discord webhooks are currently not supported."); @@ -123,6 +130,8 @@ export const getWebhooks = async (environmentId: string): Promise => }; export const testEndpoint = async (url: string, secret?: string): Promise => { + await validateWebhookUrl(url); + try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), 5000); diff --git a/apps/web/playwright/api/management/webhook.spec.ts b/apps/web/playwright/api/management/webhook.spec.ts index 388dd141c3..2dc59d3925 100644 --- a/apps/web/playwright/api/management/webhook.spec.ts +++ b/apps/web/playwright/api/management/webhook.spec.ts @@ -64,7 +64,7 @@ test.describe("API Tests for Webhooks", () => { const webhookBody = { environmentId, name: "New Webhook", - url: "https://examplewebhook.com", + url: "https://example.com/webhook", source: "user", triggers: ["responseFinished"], surveyIds: [surveyId], @@ -104,7 +104,7 @@ test.describe("API Tests for Webhooks", () => { const updatedBody = { environmentId, name: "Updated Webhook", - url: "https://updated-webhook-url.com", + url: "https://example.com/updated-webhook", source: "zapier", triggers: ["responseCreated"], surveyIds: [surveyId],