mirror of
https://github.com/formbricks/formbricks.git
synced 2026-05-22 11:39:35 -05:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| be1194a0fb |
@@ -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
|
||||
|
||||
@@ -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
|
||||
);
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user