mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-11 19:12:06 -05:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2884fc9f1e | |||
| 29d4f23520 |
@@ -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`);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user