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
- 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.
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
- 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.
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
- File type ' ' is not allowed. Allowed
- File type ' ' is not allowed. Allowed
- Extension ' ' not allowed for category ' '.
- Current password is incorrect.
- Webhook subscription
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)