{"record":{"id":"b11ef5d16a60f949","repo":"n8n-io/n8n","slug":"webhook-url-hostname-resolves-to-a-private-interna","errorCode":null,"errorMessage":"Webhook URL hostname resolves to a private/internal IP address","messagePattern":"Webhook URL hostname resolves to a private/internal IP address","errorType":"exception","errorClass":"Error","httpStatus":null,"severity":"error","filePath":"packages/@n8n/ai-workflow-builder.ee/evaluations/cli/webhook.ts","lineNumber":159,"sourceCode":"/**\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\n\ttry {\n\t\tconst addresses = await dns.resolve(hostname);\n\t\tfor (const ip of addresses) {\n\t\t\tif (isPrivateIp(ip)) {\n\t\t\t\tthrow new Error('Webhook URL hostname resolves to a private/internal IP address');\n\t\t\t}\n\t\t}\n\n\t\ttry {\n\t\t\tconst ipv6Addresses = await dns.resolve6(hostname);\n\t\t\tfor (const ip of ipv6Addresses) {\n\t\t\t\tif (isPrivateIp(ip)) {\n\t\t\t\t\tthrow new Error('Webhook URL hostname resolves to a private/internal IP address');\n\t\t\t\t}\n\t\t\t}\n\t\t} catch {\n\t\t\t// IPv6 resolution may fail if no AAAA records exist, which is fine\n\t\t}\n\t} catch (error) {\n\t\tif (error instanceof Error && error.message.includes('private/internal')) {\n\t\t\tthrow error;\n\t\t}\n\t}","sourceCodeStart":141,"sourceCodeEnd":177,"githubUrl":"https://github.com/n8n-io/n8n/blob/5ac6606e81f67bb9534255570cd4e86fd8101eee/packages/@n8n/ai-workflow-builder.ee/evaluations/cli/webhook.ts#L141-L177","documentation":"`validateWebhookUrlWithDns` resolves the hostname's A records via `dns.resolve` and rejects the URL if any returned IPv4 is private/internal per `isPrivateIp`. This is the DNS-rebinding defense: a hostname that looks public can still resolve to an internal IP, so the literal-hostname checks alone are insufficient. The first private record found trips the throw.","triggerScenarios":"A public-looking hostname whose A record returns `10.x`, `192.168.x`, `127.x`, etc. — either intentionally (internal service exposed via a public DNS name) or maliciously (DNS rebinding). Also triggered by split-horizon DNS where the eval host resolves to the internal view.","commonSituations":"Running evals from inside a corp network where DNS returns internal IPs for `.com` names; a test domain that points at `127.0.0.1` for fun; or a webhook provider whose DNS temporarily includes an internal address.","solutions":["Ensure the hostname's public A records resolve to public IPs only.","If split-horizon DNS is the cause, run the eval from a network position that gets the public view, or pin the webhook to a host with consistent public resolution.","If you genuinely need an internal target, do not use `validateWebhookUrlWithDns` — deliver results out-of-band from outside this code path."],"exampleFix":"// before\n// webhook.example.com A record -> 10.0.0.5\nawait validateWebhookUrlWithDns('https://webhook.example.com/hook');\n// after\n// fix DNS so webhook.example.com -> 203.0.113.5\nawait validateWebhookUrlWithDns('https://webhook.example.com/hook');","handlingStrategy":"try-catch","validationCode":"import dns from 'node:dns/promises';\n\nasync function preflightPublicIpv4(hostname: string): Promise<void> {\n  let addrs: string[];\n  try { addrs = await dns.resolve(hostname); }\n  catch (e) { throw new Error(`cannot resolve ${hostname}: ${(e as Error).message}`); }\n  if (addrs.some(isPrivateIp)) {\n    throw new Error(`${hostname} resolves to a private IPv4; webhook rejected (SSRF)`);\n  }\n}\nawait preflightPublicIpv4(new URL(webhookUrl).hostname);","typeGuard":null,"tryCatchPattern":"try {\n  await validateWebhookUrlWithDns(webhookUrl);\n} catch (e) {\n  if (e instanceof Error && e.message.includes('resolves to a private/internal IP')) {\n    // SSRF guard tripped — do NOT retry; surface to operator and skip webhook\n    logger.error(`webhook rejected by SSRF guard: ${e.message}`);\n    return;\n  }\n  throw e;\n}","preventionTips":["Pin webhook targets to hostnames whose public A records you control.","From corp networks, run the eval where DNS returns the public view, or use a resolver that does.","Do not retry on this error — it is a deliberate security block, not a transient failure."],"tags":["webhook","ssrf","security","dns","dns-rebinding"],"backgroundTag":null,"analyzedSha":"5ac6606e81f67bb9534255570cd4e86fd8101eee","analyzedAt":"2026-08-12T05:26:35.080Z","schemaVersion":2},"datasetVersion":"2026-08-12T18:17:37.767Z"}