{"record":{"id":"9bcf3cb027cf749c","repo":"n8n-io/n8n","slug":"webhook-url-cannot-target-internal-hostname-hos","errorCode":null,"errorMessage":"Webhook URL cannot target internal hostname: ${hostname}","messagePattern":"Webhook URL cannot target internal hostname: (.+?)","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/@n8n/ai-workflow-builder.ee/evaluations/cli/webhook.ts","lineNumber":136,"sourceCode":"\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 */\nexport async function validateWebhookUrlWithDns(webhookUrl: string): Promise<void> {\n\tvalidateWebhookUrl(webhookUrl);\n\n\tconst url = new URL(webhookUrl);\n\tconst hostname = url.hostname.toLowerCase();\n\n\tif (isPrivateIp(hostname)) {\n\t\tthrow new Error('Webhook URL cannot target private/internal IP addresses');\n\t}\n","sourceCodeStart":118,"sourceCodeEnd":154,"githubUrl":"https://github.com/n8n-io/n8n/blob/5ac6606e81f67bb9534255570cd4e86fd8101eee/packages/@n8n/ai-workflow-builder.ee/evaluations/cli/webhook.ts#L118-L154","documentation":"`validateWebhookUrl` rejects hostnames that equal or end with `.internal`, `.intranet`, `.corp`, `.private`, or `.local`. These are heuristic internal-only DNS suffixes that bypass the literal-IP check; blocking them closes the SSRF gap for corp DNS names. The match is case-insensitive (hostname was lowercased) and covers both the bare suffix and any subdomain of it.","triggerScenarios":"`--webhook-url https://eval-sink.internal/hook`, `https://api.corp.company.internal/hook`, `https://receiver.local`, `https://thing.private`. URL is HTTPS, hostname is not a loopback/private literal, but matches a blocked suffix.","commonSituations":"Company-internal DNS names pasted into shared config, or a dev `.local` mDNS hostname used during testing. The `.local` case often catches macOS Bonjour names.","solutions":["Use the public/resolvable hostname of the receiver.","Add a public DNS record (or tunnel) for the service and reference that instead.","Pick a hostname that does not terminate in one of the blocked suffixes — e.g. `evalsink.dev.company.com`."],"exampleFix":"// before\nvalidateWebhookUrl('https://eval-sink.corp/hook');\n// after\nvalidateWebhookUrl('https://eval-sink.company.com/hook');","handlingStrategy":"validation","validationCode":"const BLOCKED_SUFFIXES = ['internal', 'intranet', 'corp', 'private', 'local'];\nfunction isBlockedSuffix(hostname: string): boolean {\n  return BLOCKED_SUFFIXES.some((s) => hostname === s || hostname.endsWith('.' + s));\n}\nconst h = new URL(webhookUrl).hostname.toLowerCase();\nif (isBlockedSuffix(h)) throw new Error(`webhook host ${h} uses a blocked internal suffix`);","typeGuard":null,"tryCatchPattern":null,"preventionTips":["Use a public DNS zone (e.g. `.company.com`) for webhook receivers, not internal suffixes.","Keep the blocked-suffix list in a shared module so the call-site and library checks do not diverge.","Document the blocked suffixes alongside the webhook-flag help text."],"tags":["webhook","ssrf","security","validation","dns"],"backgroundTag":null,"analyzedSha":"5ac6606e81f67bb9534255570cd4e86fd8101eee","analyzedAt":"2026-08-12T05:26:35.080Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}