{"record":{"id":"6f9298e8a4729cb7","repo":"fullstackhero/dotnet-starter-kit","slug":"blocked-webhook-target-host-it-resolves-only-to-non-routable","errorCode":null,"errorMessage":"Blocked webhook target '{host}': it resolves only to non-routable or internal addresses.","messagePattern":"Blocked webhook target '(.+?)': it resolves only to non-routable or internal addresses\\.","errorType":"exception","errorClass":"HttpRequestException","httpStatus":null,"severity":"error","filePath":"src/Modules/Webhooks/Modules.Webhooks/Services/WebhookUrlGuard.cs","lineNumber":94,"sourceCode":"    private static bool IsIPv6UniqueLocal(IPAddress address) =>\n        (address.GetAddressBytes()[0] & 0xFE) == 0xFC;\n\n    /// <summary>\n    /// <see cref=\"SocketsHttpHandler.ConnectCallback\"/> that resolves the destination host and\n    /// refuses to connect to any non-routable/internal address. This is the authoritative SSRF\n    /// gate: it runs at delivery time on the real resolved IP, closing the DNS-rebinding window\n    /// left open by a create-time hostname check.\n    /// </summary>\n    public static async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct)\n    {\n        ArgumentNullException.ThrowIfNull(context);\n\n        var host = context.DnsEndPoint.Host;\n        var addresses = await Dns.GetHostAddressesAsync(host, ct).ConfigureAwait(false);\n        var target = Array.Find(addresses, a => !IsBlockedAddress(a));\n        if (target is null)\n        {\n            throw new HttpRequestException(\n                $\"Blocked webhook target '{host}': it resolves only to non-routable or internal addresses.\");\n        }\n\n        Socket? socket = null;\n        try\n        {\n            socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };\n            await socket.ConnectAsync(new IPEndPoint(target, context.DnsEndPoint.Port), ct).ConfigureAwait(false);\n            var stream = new NetworkStream(socket, ownsSocket: true);\n            socket = null; // ownership transferred to the stream\n            return stream;\n        }\n        finally\n        {\n            socket?.Dispose();\n        }\n    }\n}","sourceCodeStart":76,"sourceCodeEnd":112,"githubUrl":"https://github.com/fullstackhero/dotnet-starter-kit/blob/3f2959e683e9f83f13e55e1678c9119f63c7e8e5/src/Modules/Webhooks/Modules.Webhooks/Services/WebhookUrlGuard.cs#L76-L112","documentation":"WebhookUrlGuard is an SSRF protection that intercepts outgoing webhook connections. In ConnectAsync it resolves the target host via DNS and throws HttpRequestException if EVERY resolved address is private/loopback/link-local/non-routable — meaning the URL points at an internal network address (e.g. 127.0.0.1, 10.x, 169.254.x, metadata endpoints) which the platform deliberately blocks.","triggerScenarios":"Registering a webhook subscription whose URL host resolves only to blocked addresses: localhost/127.0.0.1, RFC1918 ranges (10/8, 172.16/12, 192.168/16), link-local 169.254.x (cloud metadata services), ::1, or a DNS name that only maps to internal IPs.","commonSituations":"Developers pointing webhooks at a local test receiver (localhost:5000) during development; misconfigured internal hostnames; DNS returning only internal addresses in containerized/Kubernetes environments where cluster-internal DNS names are used; an attacker-supplied URL attempting SSRF against the cloud metadata service.","solutions":["Use a publicly routable, internet-reachable URL for the webhook endpoint (e.g. an ngrok/public tunnel for local testing).","Verify DNS: run `dig <host>` / `nslookup <host>` and ensure it resolves to a public IP.","If the receiver is genuinely internal, expose it via a public reverse proxy or use the platform's approved internal-event mechanism instead of webhooks.","Remove any IPv6/IPv4 records that map the host to loopback or private ranges if the host should be public."],"exampleFix":"// before\nawait createSubscription({ url: 'http://localhost:5000/hooks' }); // Blocked webhook target 'localhost'\n// after (expose local receiver publicly for dev)\n// $ ngrok http 5000  ->  https://abc123.ngrok.app\nawait createSubscription({ url: 'https://abc123.ngrok.app/hooks' });","handlingStrategy":"validation","validationCode":"// Resolve the host before registering the webhook\nconst addrs = await dns.promises.resolve6(host).catch(() => []);\nconst addrs4 = await dns.promises.resolve4(host);\nconst all = [...addrs, ...addrs4];\nif (all.length === 0) throw new Error('Host does not resolve');\nconst blocked = (a) => /^(127\\.|10\\.|192\\.168\\.|172\\.(1[6-9]|2\\d|3[01])\\.|169\\.254\\.|::1$|f[cd]/.test(a));\nif (all.every(blocked)) throw new Error('URL points to internal address; use a public endpoint');","typeGuard":null,"tryCatchPattern":"try {\n  await deliverWebhook(url, payload);\n} catch (e) {\n  if (String(e.message).includes('Blocked webhook target')) {\n    failSubscription(url, 'Internal/SSRF address blocked');\n  } else throw e;\n}","preventionTips":["Never register localhost, 127.0.0.1, 10.x, 172.16-31.x, 192.168.x, or 169.254.x webhook URLs.","Use a public tunnel (ngrok/smee) for local webhook development.","Validate URLs against a public-IP allowlist at subscription-creation time.","Remember DNS-over-IPv6 records also count: check AAAA results too."],"tags":["ssrf","dns","webhooks","security","network"],"backgroundTag":"http-request-failed","analyzedSha":"3f2959e683e9f83f13e55e1678c9119f63c7e8e5","analyzedAt":"2026-09-15T22:20:53.684Z","contentChangedAt":"2026-09-15T22:20:53.684Z","schemaVersion":2},"datasetVersion":"2026-09-16T04:17:20.429Z"}