import { resolve4 as defaultResolve4, resolve6 as defaultResolve6 } from "dns/promises" export interface DnsResolver { resolve4: (hostname: string) => Promise resolve6: (hostname: string) => Promise } const defaultResolver: DnsResolver = { resolve4: defaultResolve4, resolve6: defaultResolve6, } const PRIVATE_IPV4_RANGES: Array<[number, number, number]> = [ [0, 0, 8], [10, 0, 8], [100, 64, 10], [127, 0, 8], [169, 254, 16], [172, 16, 12], [192, 0, 24], [192, 168, 16], [198, 18, 15], [224, 0, 4], [240, 0, 4], ] function parseIPv4(address: string): number | null { const parts = address.split(".") if (parts.length !== 4) return null let result = 0 for (const part of parts) { const octet = Number(part) if (!Number.isInteger(octet) || octet < 0 || octet > 255) return null result = (result << 8) | octet } return result >>> 0 } function isPrivateIPv4(address: string): boolean { const numeric = parseIPv4(address) if (numeric === null) return true for (const [base, offset, prefix] of PRIVATE_IPV4_RANGES) { const networkBase = ((base << 24) | (offset << 16)) >>> 0 const mask = (0xffffffff << (32 - prefix)) >>> 0 if ((numeric & mask) === (networkBase & mask)) return true } return false } function isPrivateIPv6(address: string): boolean { const normalized = address.toLowerCase() if (normalized === "::1") return true if (normalized.startsWith("fc") || normalized.startsWith("fd")) return true if (normalized.startsWith("fe80")) return true if (normalized.startsWith("::ffff:")) { const ipv4Part = normalized.slice(7) if (ipv4Part.includes(".")) return isPrivateIPv4(ipv4Part) } return false } export async function validateWebhookUrl( rawUrl: string, resolver: DnsResolver = defaultResolver ): Promise<{ valid: true url: string } | { valid: false error: string }> { const trimmedUrl = rawUrl.trim() if (!trimmedUrl) { return { valid: false, error: "webhook url is required" } } let parsedUrl: URL try { parsedUrl = new URL(trimmedUrl) } catch { return { valid: false, error: "invalid url format" } } if (parsedUrl.protocol !== "https:") { return { valid: false, error: "webhook url must use https" } } const hostname = parsedUrl.hostname if (hostname === "localhost" || hostname === "[::1]") { return { valid: false, error: "webhook url must not point to internal addresses" } } const ipv4Direct = parseIPv4(hostname) if (ipv4Direct !== null) { if (isPrivateIPv4(hostname)) { return { valid: false, error: "webhook url must not point to internal addresses" } } return { valid: true, url: trimmedUrl } } let resolvedAddresses: string[] = [] try { const ipv4Addresses = await resolver.resolve4(hostname) resolvedAddresses = resolvedAddresses.concat(ipv4Addresses) } catch { } try { const ipv6Addresses = await resolver.resolve6(hostname) resolvedAddresses = resolvedAddresses.concat(ipv6Addresses) } catch { } if (resolvedAddresses.length === 0) { return { valid: false, error: "webhook hostname could not be resolved" } } for (const address of resolvedAddresses) { if (address.includes(":")) { if (isPrivateIPv6(address)) { return { valid: false, error: "webhook url must not resolve to internal addresses" } } } else { if (isPrivateIPv4(address)) { return { valid: false, error: "webhook url must not resolve to internal addresses" } } } } return { valid: true, url: trimmedUrl } }