mirror of
https://github.com/formbricks/formbricks.git
synced 2026-03-07 01:02:21 -06:00
157 lines
5.2 KiB
TypeScript
157 lines
5.2 KiB
TypeScript
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");
|
||
}
|
||
}
|
||
};
|