import dns from "node:dns/promises"; import { URL } from "node:url"; import { isIPBlocked, isIPAddress, isHostnameBlocked } from "./ipBlocking"; import { env } from "../../env"; export async function resolveHost(hostname: string): Promise { // Returns every A + AAAA address const [v4, v6] = await Promise.allSettled([ dns.resolve4(hostname), dns.resolve6(hostname), ]); const ips: string[] = []; if (v4.status === "fulfilled") { // Filter out any undefined/null values const validV4Ips = v4.value.filter((ip) => ip && typeof ip === "string"); ips.push(...validV4Ips); } if (v6.status === "fulfilled") { // Filter out any undefined/null values const validV6Ips = v6.value.filter((ip) => ip && typeof ip === "string"); ips.push(...validV6Ips); } if (!ips.length) throw new Error(`DNS lookup failed for ${hostname}`); return ips; } /** * Validates a webhook URL to prevent SSRF attacks by blocking internal/private IP addresses * Should be called when saving webhook URLs and before sending webhooks * * Security Note: This validation has a Time-of-Check-Time-of-Use (TOCTOU) vulnerability * where DNS can change between validation and actual HTTP request. For maximum security, * the HTTP client should also implement IP blocking at connection time. */ export async function validateWebhookURL(urlString: string): Promise { // Step 1: Basic URL parsing and normalization let url: URL; try { // Normalize the URL string first to handle encoding issues const normalizedUrl = normalizeURL(urlString); url = new URL(normalizedUrl); } catch { throw new Error("Invalid URL syntax"); } if (!["https:", "http:"].includes(url.protocol)) { throw new Error("Only HTTP and HTTPS protocols are allowed"); } // Step 2: Port validation if (url.port && !["443", "80"].includes(url.port)) { throw new Error("Only ports 80 and 443 are allowed"); } // Step 3: Hostname normalization and validation const hostname = normalizeHostname(url.hostname); // Block obviously dangerous hostnames if (isHostnameBlocked(hostname)) { throw new Error("Blocked hostname detected"); } // Step 4: Check for IP address literals in hostname if (isIPAddress(hostname)) { if (isIPBlocked(hostname, env.LANGFUSE_WEBHOOK_WHITELISTED_IPS)) { throw new Error(`Blocked IP address detected: ${hostname}`); } } // Step 5: DNS resolution and validation const ips = await resolveHost(hostname); for (const ip of ips) { if (isIPBlocked(ip, env.LANGFUSE_WEBHOOK_WHITELISTED_IPS)) { throw new Error(`Blocked IP address detected: ${ip}`); } } } /** * Normalize URL to prevent encoding/unicode bypass attempts */ function normalizeURL(urlString: string): string { // Remove leading/trailing whitespace let normalized = urlString.trim(); // Decode URL encoding to prevent bypass attempts like %6C%6F%63%61%6C%68%6F%73%74 (localhost) try { normalized = decodeURIComponent(normalized); } catch { throw new Error("Invalid URL encoding"); } // Normalize unicode to prevent IDN bypass attempts try { normalized = normalized.normalize("NFC"); } catch { throw new Error("Invalid unicode in URL"); } return normalized; } /** * Normalize hostname to handle IDN and case sensitivity */ function normalizeHostname(hostname: string): string { let normalized = hostname.toLowerCase(); // Convert internationalized domain names to ASCII (Punycode) try { // This will convert unicode domain names to their ASCII representation const url = new URL(`http://${normalized}`); normalized = url.hostname; } catch { // If hostname is invalid, keep the original for later validation failure } return normalized; }