Compare commits

..

1 Commits

Author SHA1 Message Date
Anshuman Pandey be1194a0fb fix: [Backport] backports dns pinning fix (#8108) 2026-05-22 08:42:55 +04:00
2 changed files with 158 additions and 64 deletions
+50 -17
View File
@@ -15,7 +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 { 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";
@@ -94,19 +94,24 @@ export const POST = async (request: Request) => {
// Fetch with timeout of 5 seconds to prevent hanging.
// `redirect: "manual"` blocks SSRF via redirect — webhook URLs are validated against private/internal
// ranges before delivery, but redirect targets would otherwise bypass that check. Gated on the same
// env var as `validateWebhookUrl`: self-hosters who opted into trusting internal URLs also get the
// pre-patch redirect-follow behavior for consistency.
// env var as `validateAndResolveWebhookUrl`: 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> => {
type WebhookFetchOptions = RequestInit & { dispatcher?: ReturnType<typeof createPinnedDispatcher> };
const fetchWithTimeout = (
url: string,
options: WebhookFetchOptions,
timeout: number = 5000
): Promise<Response> => {
return Promise.race([
fetch(url, { ...options, redirect: redirectMode }),
fetch(url, { ...options, redirect: redirectMode } as RequestInit),
new Promise<never>((_, reject) => setTimeout(() => reject(new Error("Timeout")), timeout)),
]);
};
const resolvedResponseData = resolveStorageUrlsInObject(response.data);
const webhookPromises = webhooks.map((webhook) => {
const deliverWebhook = async (webhook: Webhook): Promise<void> => {
const body = JSON.stringify({
webhookId: webhook.id,
event,
@@ -143,18 +148,46 @@ export const POST = async (request: Request) => {
);
}
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`);
let dispatcher: ReturnType<typeof createPinnedDispatcher> | undefined;
try {
const address = await validateAndResolveWebhookUrl(webhook.url);
// Pin TCP connect to validated IP — closes DNS-rebinding TOCTOU between
// validation and fetch. Null address = DANGEROUSLY flag + blocked name
// (/etc/hosts path), so skip pinning.
dispatcher = address ? createPinnedDispatcher(address) : undefined;
const webhookResponse = await fetchWithTimeout(webhook.url, {
method: "POST",
headers: requestHeaders,
body,
dispatcher,
});
});
// With `redirect: "manual"`, undici returns the actual 30x (not opaqueredirect).
// Treat as delivery failure so redirect-based SSRF cannot silently succeed.
if (webhookResponse.status >= 300 && webhookResponse.status < 400) {
throw new Error(`Webhook delivery blocked: redirect status ${webhookResponse.status}`);
}
} catch (error) {
logger.error({ error, url: request.url }, `Webhook call to ${webhook.url} failed`);
} finally {
// destroy() force-kills sockets — close() would deadlock on accepted-but-idle endpoints.
try {
await dispatcher?.destroy();
} catch (cleanupError) {
logger.warn(
{
err: cleanupError,
webhookId: webhook.id,
webhookUrl: webhook.url,
},
"Response pipeline webhook dispatcher cleanup failed"
);
}
}
};
const webhookPromises = webhooks.map((webhook) => deliverWebhook(webhook));
if (event === "responseFinished") {
// Fetch integrations and responseCount in parallel
+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
);
},
},
});
};