fullstackhero/dotnet-starter-kit · error · HttpRequestException

Blocked webhook target

Error message

Blocked webhook target '{host}': it resolves only to non-routable or internal addresses.

What it means

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.

Solutions

  1. Use a publicly routable, internet-reachable URL for the webhook endpoint (e.g. an ngrok/public tunnel for local testing).
  2. Verify DNS: run `dig <host>` / `nslookup <host>` and ensure it resolves to a public IP.
  3. 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.
  4. Remove any IPv6/IPv4 records that map the host to loopback or private ranges if the host should be public.

Example fix

// before
await createSubscription({ url: 'http://localhost:5000/hooks' }); // Blocked webhook target 'localhost'
// after (expose local receiver publicly for dev)
// $ ngrok http 5000  ->  https://abc123.ngrok.app
await createSubscription({ url: 'https://abc123.ngrok.app/hooks' });
Defensive patterns

Strategy: validation

Validate before calling

// Resolve the host before registering the webhook
const addrs = await dns.promises.resolve6(host).catch(() => []);
const addrs4 = await dns.promises.resolve4(host);
const all = [...addrs, ...addrs4];
if (all.length === 0) throw new Error('Host does not resolve');
const blocked = (a) => /^(127\.|10\.|192\.168\.|172\.(1[6-9]|2\d|3[01])\.|169\.254\.|::1$|f[cd]/.test(a));
if (all.every(blocked)) throw new Error('URL points to internal address; use a public endpoint');

Try / catch

try {
  await deliverWebhook(url, payload);
} catch (e) {
  if (String(e.message).includes('Blocked webhook target')) {
    failSubscription(url, 'Internal/SSRF address blocked');
  } else throw e;
}

Prevention

When it happens

Trigger: 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.

Common situations: 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.

Understand the failure class

Background: 'Something went wrong' / 'Request failed (500)' / 'HTTP error! status: 404' — what failed HTTP requests actually mean and how to find the real cause — this error's family across 28 libraries.

Related errors


AI-assisted analysis of fullstackhero/dotnet-starter-kit@3f2959e683 (2026-09-15). Data as JSON: /api/errors/6f9298e8a4729cb7. Report an issue: GitHub.

Appendix: source

Thrown at src/Modules/Webhooks/Modules.Webhooks/Services/WebhookUrlGuard.cs:94

    private static bool IsIPv6UniqueLocal(IPAddress address) =>
        (address.GetAddressBytes()[0] & 0xFE) == 0xFC;

    /// <summary>
    /// <see cref="SocketsHttpHandler.ConnectCallback"/> that resolves the destination host and
    /// refuses to connect to any non-routable/internal address. This is the authoritative SSRF
    /// gate: it runs at delivery time on the real resolved IP, closing the DNS-rebinding window
    /// left open by a create-time hostname check.
    /// </summary>
    public static async ValueTask<Stream> ConnectAsync(SocketsHttpConnectionContext context, CancellationToken ct)
    {
        ArgumentNullException.ThrowIfNull(context);

        var host = context.DnsEndPoint.Host;
        var addresses = await Dns.GetHostAddressesAsync(host, ct).ConfigureAwait(false);
        var target = Array.Find(addresses, a => !IsBlockedAddress(a));
        if (target is null)
        {
            throw new HttpRequestException(
                $"Blocked webhook target '{host}': it resolves only to non-routable or internal addresses.");
        }

        Socket? socket = null;
        try
        {
            socket = new Socket(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
            await socket.ConnectAsync(new IPEndPoint(target, context.DnsEndPoint.Port), ct).ConfigureAwait(false);
            var stream = new NetworkStream(socket, ownsSocket: true);
            socket = null; // ownership transferred to the stream
            return stream;
        }
        finally
        {
            socket?.Dispose();
        }
    }
}

View on GitHub (pinned to 3f2959e683)