Compare commits

...

2 Commits

Author SHA1 Message Date
pandeymangg 2884fc9f1e fixes coderabbit feedback 2026-05-08 10:49:22 +05:30
pandeymangg 29d4f23520 fix/webhook-ssrf-toctou-dns-pin 2026-05-07 18:37:46 +05:30
6 changed files with 356 additions and 64 deletions
+27 -11
View File
@@ -1,5 +1,6 @@
import { PipelineTriggers, Webhook } from "@prisma/client";
import { headers } from "next/headers";
import type { Agent } from "undici";
import { v7 as uuidv7 } from "uuid";
import { prisma } from "@formbricks/database";
import { logger } from "@formbricks/logger";
@@ -16,7 +17,7 @@ import { getResponseCountBySurveyId } from "@/lib/response/service";
import { getSurvey, updateSurvey } from "@/lib/survey/service";
import { convertDatesInObject } from "@/lib/time";
import { getProjectIdFromEnvironmentId } from "@/lib/utils/helper";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import { createPinnedDispatcher, validateAndResolveWebhookUrl } 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 { recordResponseCreatedMeterEvent } from "@/modules/ee/billing/lib/metering";
@@ -98,9 +99,13 @@ export const POST = async (request: Request) => {
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
const redirectMode: RequestRedirect = DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS ? "follow" : "manual";
const fetchWithTimeout = (url: string, options: RequestInit, timeout: number = 5000): Promise<Response> => {
const fetchWithTimeout = (
url: string,
options: RequestInit & { dispatcher?: Agent },
timeout: number = 5000
): Promise<Response> => {
return Promise.race([
fetch(url, { ...options, redirect: redirectMode }),
fetch(url, { ...options, redirect: redirectMode } as RequestInit & { dispatcher?: Agent }),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
@@ -144,14 +149,25 @@ export const POST = async (request: Request) => {
);
}
return validateWebhookUrl(webhook.url)
.then(() =>
fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
})
)
return validateAndResolveWebhookUrl(webhook.url)
.then(async (address) => {
// Pin TCP connect to the validated IP. Without this, undici resolves DNS
// again at fetch time and an attacker-controlled domain can rebind to a
// private/internal IP after validation passed (TOCTOU SSRF).
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
try {
return await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
dispatcher,
});
} finally {
// Each pinned Agent owns a keep-alive socket pool; close it so we
// don't leak sockets across the per-webhook fan-out on every response.
await dispatcher?.close();
}
})
.catch((error) => {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
});
+127 -1
View File
@@ -1,6 +1,11 @@
import dns from "node:dns";
import type { Agent } from "undici";
import { afterEach, describe, expect, test, vi } from "vitest";
import { validateWebhookUrl } from "./validate-webhook-url";
import {
createPinnedDispatcher,
validateAndResolveWebhookUrl,
validateWebhookUrl,
} from "./validate-webhook-url";
vi.mock("node:dns", () => ({
default: {
@@ -372,4 +377,125 @@ describe("validateWebhookUrl", () => {
);
});
});
describe("validateAndResolveWebhookUrl returns pinned address", () => {
test("returns IPv4 literal as { ip, family: 4 }", async () => {
await expect(validateAndResolveWebhookUrl("https://93.184.216.34/webhook")).resolves.toEqual({
ip: "93.184.216.34",
family: 4,
});
});
test("returns IPv6 literal stripped of brackets as { ip, family: 6 }", async () => {
await expect(
validateAndResolveWebhookUrl("https://[2606:2800:220:1:248:1893:25c8:1946]/webhook")
).resolves.toEqual({
ip: "2606:2800:220:1:248:1893:25c8:1946",
family: 6,
});
});
test("returns first resolved IPv4 for hostnames", async () => {
setupDnsResolution(["93.184.216.34", "23.23.23.23"]);
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
ip: "93.184.216.34",
family: 4,
});
});
test("returns IPv6 when only IPv6 is resolvable", async () => {
setupDnsResolution(null, ["2606:2800:220:1:248:1893:25c8:1946"]);
await expect(validateAndResolveWebhookUrl("https://example.com/webhook")).resolves.toEqual({
ip: "2606:2800:220:1:248:1893:25c8:1946",
family: 6,
});
});
test("returns null for blocked hostname when DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS is enabled", async () => {
vi.doMock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: true,
}));
const { validateAndResolveWebhookUrl: fn } = await import("./validate-webhook-url");
await expect(fn("http://localhost/webhook")).resolves.toBeNull();
});
});
describe("createPinnedDispatcher", () => {
test("returns an undici Agent instance", async () => {
const { Agent } = await import("undici");
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
expect(dispatcher).toBeInstanceOf(Agent);
await dispatcher.close();
});
// Reach into the Agent's connect options to grab the lookup function we
// installed. This is implementation-coupled but the only way to assert the
// pinning behavior without spinning up a real socket. If undici changes
// internals and this stops finding the lookup, the integration-style test
// below still verifies the end-to-end behavior.
const extractLookup = (
agent: Agent
):
| ((
host: string,
opts: { all?: boolean },
cb: (
err: NodeJS.ErrnoException | null,
address: string | { address: string; family: number }[],
family?: number
) => void
) => void)
| undefined => {
const symbols = Object.getOwnPropertySymbols(agent);
for (const sym of symbols) {
const value = (agent as unknown as Record<symbol, unknown>)[sym];
if (value && typeof value === "object" && "connect" in value) {
const connect = (value as { connect?: { lookup?: unknown } }).connect;
if (connect && typeof connect.lookup === "function") {
return connect.lookup as never;
}
}
}
return undefined;
};
test("lookup returns the pinned IP regardless of which hostname is queried (all=true)", async () => {
const dispatcher = createPinnedDispatcher({ ip: "93.184.216.34", family: 4 });
const lookup = extractLookup(dispatcher);
// If we couldn't reach into the Agent, skip the deep assertion — the
// integration test still covers the contract.
if (!lookup) {
await dispatcher.close();
return;
}
const result = await new Promise<{ address: string; family: number }[]>((resolve, reject) => {
lookup("attacker-rebound.example.com", { all: true }, (err, addresses) => {
if (err) reject(err);
else resolve(addresses as { address: string; family: number }[]);
});
});
expect(result).toEqual([{ address: "93.184.216.34", family: 4 }]);
await dispatcher.close();
});
test("lookup honours legacy (err, address, family) form when all is not set", async () => {
const dispatcher = createPinnedDispatcher({ ip: "2606:4700::1", family: 6 });
const lookup = extractLookup(dispatcher);
if (!lookup) {
await dispatcher.close();
return;
}
const result = await new Promise<{ address: string; family: number }>((resolve, reject) => {
lookup("anything.example", {}, (err, address, family) => {
if (err) reject(err);
else resolve({ address: address as string, family: family ?? -1 });
});
});
expect(result).toEqual({ address: "2606:4700::1", family: 6 });
await dispatcher.close();
});
});
});
@@ -0,0 +1,67 @@
import http from "node:http";
import type { AddressInfo } from "node:net";
import { afterAll, beforeAll, describe, expect, test, vi } from "vitest";
import { createPinnedDispatcher } from "./validate-webhook-url";
// Real DNS, no node:dns mock. The whole point of this file is to prove that
// the pinned dispatcher bypasses DNS entirely — so the hostname we use must
// genuinely fail to resolve in real life.
vi.unmock("node:dns");
vi.mock("../constants", () => ({
DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS: false,
}));
describe("DNS rebinding TOCTOU — pinned dispatcher", () => {
let server: http.Server;
let port: number;
const visited: string[] = [];
beforeAll(async () => {
server = http.createServer((req, res) => {
visited.push(req.headers.host ?? "");
res.writeHead(200, { "content-type": "text/plain" });
res.end("hit-pinned-target");
});
await new Promise<void>((resolve) => {
server.listen(0, "127.0.0.1", () => resolve());
});
port = (server.address() as AddressInfo).port;
});
afterAll(async () => {
await new Promise<void>((resolve) => server.close(() => resolve()));
});
test("baseline: fetch to *.invalid hostname fails (real DNS cannot resolve it)", async () => {
// RFC 2606 reserves the .invalid TLD — guaranteed to never resolve.
// This proves DNS is what fetch normally relies on.
await expect(
fetch(`http://attacker-rebind.invalid:${port}/`).catch((e: Error) => {
throw new Error(e.message);
})
).rejects.toThrow(/fetch failed/i);
});
test("with pinned dispatcher: connection lands on pinned IP even though hostname is unresolvable", async () => {
// Simulates: validate resolved attacker.com to a public IP (here represented
// by 127.0.0.1 — the local test server). Attacker then rebinds DNS so a
// second lookup would return something different (or nothing). The pinned
// dispatcher means there *is* no second lookup — undici uses our IP.
const dispatcher = createPinnedDispatcher({ ip: "127.0.0.1", family: 4 });
try {
const response = await fetch(`http://attacker-rebind.invalid:${port}/`, {
// RequestInit doesn't type `dispatcher` — undici accepts it at runtime.
dispatcher,
} as RequestInit & { dispatcher: typeof dispatcher });
expect(response.status).toBe(200);
await expect(response.text()).resolves.toBe("hit-pinned-target");
// The Host header preserves the original hostname (TLS SNI parity);
// only the TCP target was rerouted via the pin.
expect(visited.at(-1)).toContain("attacker-rebind.invalid");
} finally {
await dispatcher.close();
}
});
});
+108 -47
View File
@@ -1,5 +1,6 @@
import "server-only";
import dns from "node:dns";
import { Agent } from "undici";
import { InvalidInputError } from "@formbricks/types/errors";
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "../constants";
@@ -67,13 +68,15 @@ const isPrivateIPv6 = (ip: string): boolean => {
return PRIVATE_IPV6_PREFIXES.some((prefix) => normalized.startsWith(prefix));
};
const isPrivateIP = (ip: string): boolean => {
return isPrivateIPv4(ip) || isPrivateIPv6(ip);
const isPrivateIP = (ip: string, family: 4 | 6): boolean => {
return family === 4 ? isPrivateIPv4(ip) : isPrivateIPv6(ip);
};
const DNS_TIMEOUT_MS = 3000;
const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
export type ResolvedAddress = { ip: string; family: 4 | 6 };
const resolveHostnameToAddresses = (hostname: string): Promise<ResolvedAddress[]> => {
return new Promise((resolve, reject) => {
let settled = false;
@@ -89,16 +92,16 @@ const resolveHostnameToIPs = (hostname: string): Promise<string[]> => {
}, DNS_TIMEOUT_MS);
dns.resolve(hostname, (errV4, ipv4Addresses) => {
const ipv4 = errV4 ? [] : ipv4Addresses;
const ipv4: ResolvedAddress[] = errV4 ? [] : ipv4Addresses.map((ip) => ({ ip, family: 4 as const }));
dns.resolve6(hostname, (errV6, ipv6Addresses) => {
const ipv6 = errV6 ? [] : ipv6Addresses;
const allAddresses = [...ipv4, ...ipv6];
const ipv6: ResolvedAddress[] = errV6 ? [] : ipv6Addresses.map((ip) => ({ ip, family: 6 as const }));
const all = [...ipv4, ...ipv6];
if (allAddresses.length === 0) {
if (all.length === 0) {
settle(reject, new Error(`DNS resolution failed for hostname: ${hostname}`));
} else {
settle(resolve, allAddresses);
settle(resolve, all);
}
});
});
@@ -114,59 +117,35 @@ const stripIPv6Brackets = (hostname: string): string => {
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> => {
const parseWebhookUrl = (url: string): URL => {
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");
}
return parsed;
};
const hostname = parsed.hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
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 validateIpLiteral = (hostname: string): ResolvedAddress | null => {
const isIPv4Literal = IPV4_LITERAL.test(hostname);
const isIPv6Literal = hostname.startsWith("[");
if (!isIPv4Literal && !isIPv6Literal) return null;
if (isIPv4Literal || isIPv6Literal) {
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return;
const ip = isIPv6Literal ? stripIPv6Brackets(hostname) : hostname;
const family: 4 | 6 = isIPv4Literal ? 4 : 6;
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isPrivateIP(ip, family)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
return { ip, family };
};
// Skip DNS resolution for localhost-like hostnames when internal URLs are allowed since these are resolved via /etc/hosts and not DNS
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && BLOCKED_HOSTNAMES.has(hostname.toLowerCase())) {
return;
}
// Domain name — resolve DNS and validate every resolved IP
let resolvedIPs: string[];
const resolveHostnameOrThrow = async (hostname: string): Promise<ResolvedAddress[]> => {
try {
resolvedIPs = await resolveHostnameToIPs(hostname);
return await resolveHostnameToAddresses(hostname);
} catch (error) {
const isTimeout = error instanceof Error && error.message.includes("timed out");
throw new InvalidInputError(
@@ -175,12 +154,94 @@ export const validateWebhookUrl = async (url: string): Promise<void> => {
: `Could not resolve webhook URL hostname: ${hostname}`
);
}
};
/**
* Validates a webhook URL and returns a resolved address pinned for delivery.
*
* Returns the IP literal or first DNS-resolved address. Returns `null` only when
* `DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS` is enabled for a known internal hostname
* (localhost etc.) — in that case the caller skips IP pinning so /etc/hosts works.
*
* Pinning the returned address into the fetch dispatcher closes the TOCTOU window
* where DNS could rebind between this validation and the subsequent HTTP request.
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateAndResolveWebhookUrl = async (url: string): Promise<ResolvedAddress | null> => {
const parsed = parseWebhookUrl(url);
const hostname = parsed.hostname;
const isBlockedName = BLOCKED_HOSTNAMES.has(hostname.toLowerCase());
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
throw new InvalidInputError("Webhook URL must not point to localhost or internal services");
}
const literal = validateIpLiteral(hostname);
if (literal) return literal;
// Skip DNS for localhost-like hostnames when internal URLs are allowed (resolved via /etc/hosts)
if (DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS && isBlockedName) {
return null;
}
const resolved = await resolveHostnameOrThrow(hostname);
if (!DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS) {
for (const ip of resolvedIPs) {
if (isPrivateIP(ip)) {
for (const addr of resolved) {
if (isPrivateIP(addr.ip, addr.family)) {
throw new InvalidInputError("Webhook URL must not point to private or internal IP addresses");
}
}
}
// Pin to the first resolved address. All addresses already passed the public-IP
// check above, so any choice is safe.
return resolved[0];
};
/**
* Validates a webhook URL to prevent Server-Side Request Forgery (SSRF).
* Thin wrapper around {@link validateAndResolveWebhookUrl} for callers that only
* need validation (e.g. webhook create/update) and discard the resolved address.
*
* @throws {InvalidInputError} when the URL fails any validation check
*/
export const validateWebhookUrl = async (url: string): Promise<void> => {
await validateAndResolveWebhookUrl(url);
};
/**
* Builds an undici Agent that pins outgoing TCP connections to the given IP/family,
* regardless of what hostname the URL resolves to at fetch time. Use the address
* returned by {@link validateAndResolveWebhookUrl} so the IP that was validated is
* the IP that gets connected to — closes the DNS-rebinding TOCTOU window.
*
* TLS SNI/cert validation still uses the original hostname from the URL.
*/
export const createPinnedDispatcher = (address: ResolvedAddress): Agent => {
return new Agent({
connect: {
// undici calls `lookup(host, { all: true, ... }, cb)`, so honor both forms:
// when `all` is true we must return an array; otherwise the legacy
// (err, address, family) signature. Returning the wrong form yields
// "Invalid IP address: undefined" at connect time.
lookup: (_hostname, options, callback) => {
if (options && typeof options === "object" && (options as { all?: boolean }).all) {
(
callback as (
err: NodeJS.ErrnoException | null,
addresses: { address: string; family: number }[]
) => void
)(null, [{ address: address.ip, family: address.family }]);
return;
}
(callback as (err: NodeJS.ErrnoException | null, address: string, family: number) => void)(
null,
address.ip,
address.family
);
},
},
});
};
@@ -1,7 +1,11 @@
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
import { InvalidInputError } from "@formbricks/types/errors";
import { generateStandardWebhookSignature } from "@/lib/crypto";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import {
createPinnedDispatcher,
validateAndResolveWebhookUrl,
validateWebhookUrl,
} from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { testEndpoint } from "./webhook";
@@ -32,6 +36,8 @@ vi.mock("@/lib/crypto", () => ({
vi.mock("@/lib/utils/validate-webhook-url", () => ({
validateWebhookUrl: vi.fn(async () => undefined),
validateAndResolveWebhookUrl: vi.fn(async () => ({ ip: "93.184.216.34", family: 4 })),
createPinnedDispatcher: vi.fn(() => ({ __pinned: true, close: vi.fn(async () => undefined) })),
}));
vi.mock("@/lingodotdev/server", () => ({
@@ -52,6 +58,11 @@ describe("testEndpoint", () => {
constantsMock.dangerouslyAllow = false;
vi.mocked(generateStandardWebhookSignature).mockReturnValue("signed-payload");
vi.mocked(validateWebhookUrl).mockResolvedValue(undefined);
vi.mocked(validateAndResolveWebhookUrl).mockResolvedValue({ ip: "93.184.216.34", family: 4 });
vi.mocked(createPinnedDispatcher).mockReturnValue({
__pinned: true,
close: vi.fn(async () => undefined),
} as never);
vi.mocked(getTranslate).mockResolvedValue((key: string) => key);
vi.mocked(isDiscordWebhook).mockReturnValue(false);
});
@@ -80,7 +91,7 @@ describe("testEndpoint", () => {
new InvalidInputError(messageKey)
);
expect(validateWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(validateAndResolveWebhookUrl).toHaveBeenCalledWith("https://example.com/webhook");
expect(generateStandardWebhookSignature).toHaveBeenCalled();
expect(getTranslate).toHaveBeenCalled();
});
@@ -12,7 +12,11 @@ import {
import { DANGEROUSLY_ALLOW_WEBHOOK_INTERNAL_URLS } from "@/lib/constants";
import { generateStandardWebhookSignature, generateWebhookSecret } from "@/lib/crypto";
import { validateInputs } from "@/lib/utils/validate";
import { validateWebhookUrl } from "@/lib/utils/validate-webhook-url";
import {
createPinnedDispatcher,
validateAndResolveWebhookUrl,
validateWebhookUrl,
} from "@/lib/utils/validate-webhook-url";
import { getTranslate } from "@/lingodotdev/server";
import { isDiscordWebhook } from "@/modules/integrations/webhooks/lib/utils";
import { TWebhookInput } from "../types/webhooks";
@@ -163,7 +167,7 @@ export const getWebhooks = async (environmentId: string): Promise<Webhook[]> =>
};
export const testEndpoint = async (url: string, secret?: string): Promise<boolean> => {
await validateWebhookUrl(url);
const address = await validateAndResolveWebhookUrl(url);
if (isDiscordWebhook(url)) {
throw new UnknownError("Discord webhooks are currently not supported.");
@@ -171,6 +175,10 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), 5000);
// Hoisted out of the try so the finally can close it on every path.
// Pin TCP connect to the validated IP — closes DNS-rebinding TOCTOU between
// validation and fetch (undici otherwise resolves the hostname a second time).
const dispatcher = address ? createPinnedDispatcher(address) : undefined;
try {
const webhookMessageId = uuidv7();
@@ -203,7 +211,8 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
headers: requestHeaders,
signal: controller.signal,
redirect: redirectMode,
});
dispatcher,
} as RequestInit & { dispatcher?: ReturnType<typeof createPinnedDispatcher> });
const statusCode = response.status;
@@ -236,5 +245,7 @@ export const testEndpoint = async (url: string, secret?: string): Promise<boolea
);
} finally {
clearTimeout(timeout);
// Free the pinned Agent's socket pool so the test endpoint doesn't accumulate sockets.
await dispatcher?.close();
}
};