{"record":{"id":"2b377335803eddc2","repo":"n8n-io/n8n","slug":"webhook-url-cannot-target-localhost","errorCode":null,"errorMessage":"Webhook URL cannot target localhost","messagePattern":"Webhook URL cannot target localhost","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/@n8n/ai-workflow-builder.ee/evaluations/cli/webhook.ts","lineNumber":126,"sourceCode":" * Note: This performs synchronous hostname string validation.\n * For full SSRF protection, use validateWebhookUrlWithDns() which also resolves DNS.\n */\nexport function validateWebhookUrl(webhookUrl: string): void {\n\tconst url = new URL(webhookUrl);\n\n\tif (url.protocol !== 'https:') {\n\t\tthrow new Error(`Webhook URL must use HTTPS. Got: ${url.protocol}`);\n\t}\n\n\tconst hostname = url.hostname.toLowerCase();\n\n\tif (\n\t\thostname === 'localhost' ||\n\t\thostname === '127.0.0.1' ||\n\t\thostname === '::1' ||\n\t\thostname === '[::1]'\n\t) {\n\t\tthrow new Error('Webhook URL cannot target localhost');\n\t}\n\n\tif (isPrivateIp(hostname)) {\n\t\tthrow new Error('Webhook URL cannot target private/internal IP addresses');\n\t}\n\n\tconst blockedHostnames = ['internal', 'intranet', 'corp', 'private', 'local'];\n\tfor (const blocked of blockedHostnames) {\n\t\tif (hostname === blocked || hostname.endsWith(`.${blocked}`)) {\n\t\t\tthrow new Error(`Webhook URL cannot target internal hostname: ${hostname}`);\n\t\t}\n\t}\n}\n\n/**\n * Validate webhook URL with DNS resolution for comprehensive SSRF protection.\n * Resolves the hostname and validates that resolved IPs are not private/internal.\n */","sourceCodeStart":108,"sourceCodeEnd":144,"githubUrl":"https://github.com/n8n-io/n8n/blob/5ac6606e81f67bb9534255570cd4e86fd8101eee/packages/@n8n/ai-workflow-builder.ee/evaluations/cli/webhook.ts#L108-L144","documentation":"`validateWebhookUrl` blocks webhook URLs whose hostname is a literal loopback (`localhost`, `127.0.0.1`, `::1`, `[::1]`). This is the localhost branch of the SSRF guard — even with HTTPS, allowing loopback targets would let eval output (and the HMAC-signed body) be POSTed to services on the same host. The comparison is a case-insensitive exact string match on `url.hostname`.","triggerScenarios":"`--webhook-url https://localhost:8080/hook`, `https://127.0.0.1:8080/hook`, or the IPv6 loopback forms. The URL is HTTPS and parses, but the hostname matches a loopback literal.","commonSituations":"Developer points eval output at a local receiver for debugging, or a config templated from a dev environment leaks `localhost` into a shared config. IPv6 `[::1]` is the easy miss because the bracketed form looks different from `127.0.0.1`.","solutions":["Point at a public/TLS-terminated receiver (e.g. an HTTPS ngrok/cloudflared tunnel in front of your local service).","Replace the loopback hostname with the machine's resolvable public hostname behind HTTPS.","Remove the `--webhook-url` flag entirely during local debugging if you don't actually need webhook delivery."],"exampleFix":"// before\nvalidateWebhookUrl('https://localhost:9000/eval');\n// after\nvalidateWebhookUrl('https://my-tunnel.example.dev/eval');","handlingStrategy":"validation","validationCode":"const LOOPBACK = new Set(['localhost', '127.0.0.1', '::1', '[::1]']);\nfunction isLoopbackHost(hostname: string): boolean {\n  return LOOPBACK.has(hostname.toLowerCase());\n}\n// before validateWebhookUrl:\nconst h = new URL(webhookUrl).hostname.toLowerCase();\nif (isLoopbackHost(h)) throw new Error('webhook host is loopback; use a public host');","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Default your dev webhook config to an HTTPS tunnel hostname rather than localhost.","Document that loopback webhook targets are blocked by SSRF policy so operators stop trying.","Audit shared config files for `localhost`/`127.0.0.1` webhook entries."],"tags":["webhook","ssrf","security","validation","loopback"],"backgroundTag":null,"analyzedSha":"5ac6606e81f67bb9534255570cd4e86fd8101eee","analyzedAt":"2026-08-12T05:26:35.080Z","schemaVersion":2},"datasetVersion":"2026-08-12T13:17:24.610Z"}