mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-05 18:39:20 -06:00
Compare commits
5 Commits
codex/agpl
...
fix/webhoo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e2df25c587 | ||
|
|
fc303970b9 | ||
|
|
805f3f0a93 | ||
|
|
0584a7df78 | ||
|
|
cff8368f87 |
@@ -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") {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<Webhook> => {
|
||||
validateInputs([webhookInput, ZWebhookInput]);
|
||||
await validateWebhookUrl(webhookInput.url);
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
278
apps/web/lib/utils/validate-webhook-url.test.ts
Normal file
278
apps/web/lib/utils/validate-webhook-url.test.ts
Normal file
@@ -0,0 +1,278 @@
|
||||
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"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("error type", () => {
|
||||
test("throws InvalidInputError (not generic Error)", async () => {
|
||||
await expect(validateWebhookUrl("http://127.0.0.1/")).rejects.toMatchObject({
|
||||
name: "InvalidInputError",
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
156
apps/web/lib/utils/validate-webhook-url.ts
Normal file
156
apps/web/lib/utils/validate-webhook-url.ts
Normal file
@@ -0,0 +1,156 @@
|
||||
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 resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
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) {
|
||||
reject(new Error(`DNS resolution failed for hostname: ${hostname}`));
|
||||
} else {
|
||||
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<void> => {
|
||||
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 {
|
||||
throw new InvalidInputError(`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");
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -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,30 @@ 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 not_found if record does not exist", async () => {
|
||||
vi.mocked(prisma.webhook.update).mockRejectedValueOnce(prismaNotFoundError);
|
||||
const result = await updateWebhook("999", mockedWebhookUpdateReturn);
|
||||
|
||||
@@ -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,22 @@ export const updateWebhook = async (
|
||||
webhookId: string,
|
||||
webhookInput: z.infer<typeof ZWebhookUpdateSchema>
|
||||
): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
if (webhookInput.url) {
|
||||
try {
|
||||
await validateWebhookUrl(webhookInput.url);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{
|
||||
field: "url",
|
||||
issue: error instanceof InvalidInputError ? error.message : "Invalid webhook URL",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const updatedWebhook = await prisma.webhook.update({
|
||||
where: {
|
||||
|
||||
@@ -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,30 @@ 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 error when creation fails", async () => {
|
||||
vi.mocked(prisma.webhook.create).mockRejectedValueOnce(new Error("Creation failed"));
|
||||
|
||||
|
||||
@@ -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,20 @@ export const getWebhooks = async (
|
||||
export const createWebhook = async (webhook: TWebhookInput): Promise<Result<Webhook, ApiErrorResponseV2>> => {
|
||||
const { environmentId, name, url, source, triggers, surveyIds } = webhook;
|
||||
|
||||
try {
|
||||
await validateWebhookUrl(url);
|
||||
} catch (error) {
|
||||
return err({
|
||||
type: "bad_request",
|
||||
details: [
|
||||
{
|
||||
field: "url",
|
||||
issue: error instanceof InvalidInputError ? error.message : "Invalid webhook URL",
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const secret = generateWebhookSecret();
|
||||
|
||||
|
||||
@@ -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<TWebhookInput>
|
||||
): Promise<boolean> => {
|
||||
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<Webhook> => {
|
||||
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<Webhook[]> =>
|
||||
};
|
||||
|
||||
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
|
||||
await validateWebhookUrl(url);
|
||||
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timeout = setTimeout(() => controller.abort(), 5000);
|
||||
|
||||
Reference in New Issue
Block a user