API7 and API10: APIs That Talk to the Outside World — Webhooks, Outbound URL Validation and Not Trusting Third-Party Responses
You validate a user-supplied webhook URL by shape at registration and, at connect time, on every resolved address with SocketsHttpHandler.ConnectCallback, and you treat third-party responses like user input. A DNS rebinding imitation, delivery without redirects, a signed payment webhook processed once, and a shipping API response validated with a size limit and resilience.
Version note: The code in this post was built and run with .NET 10, EF (Entity Framework) Core 10.0.14 and Microsoft.Extensions.Http.Resilience 10.11.0; every outbound request went to receivers inside the local lab.
Guess First
We're adding a feature to Bookstore: the tenant admin registers a webhook URL (Uniform Resource Locator), and when an order's status changes the API (Application Programming Interface) sends a POST to that address. https://hooks.partner.test/bookstore is typed into the registration form and passes validation. A week later the DNS (Domain Name System) record of the same name is changed so that it resolves to 127.0.0.1 or to the cloud environment's metadata service. What good is the check you did at registration then?
The same week, the response from the shipping company's rates API comes back with the service name <script>alert(1)</script> and it's shown as is on the order screen. The shipping company is a "trusted partner", so nobody validated the response.
The two incidents fall into two separate OWASP (Open Worldwide Application Security Project) items: API7 (the seventh item of the 2023 list), i.e. SSRF (Server Side Request Forgery), and API10 (Unsafe Consumption of APIs). In this post we take them together, because both appear where the API talks to the outside world. The basics of SSRF, i.e. the URL allowlist and the IP (Internet Protocol) check, are in the second half of the A01 Broken Access Control post of the Web Security (OWASP Top 10) series. Here we apply those basics to webhooks registered by users, incoming webhooks and third-party API responses.
Two Risks, One Topic: An API That Talks to the Outside
In SSRF the server sends a request from inside our network to an address the attacker chooses. A webhook is its purest form: the user picks the address and the server sends requests there regularly. The target can be an admin panel on the internal network, a database's HTTP (Hypertext Transfer Protocol) interface or the cloud provider's metadata service.
In API10 the direction flips: a third party we call, or that calls us, sends us data we don't control. OWASP's observation for this item is that developers trust data from third-party APIs more than user input, and that's exactly why attackers go after the services the target integrates with instead of the target itself. An incoming payment webhook and a shipping API we call are the two examples in this post.

The Webhook URL: Shape at Registration
The checks you can do at registration are about the URL's shape. We can't know where the address will resolve here, but we can reject the obviously bad ones early:
// At registration: the URL's shape; the address is checked at connect time
public string? ValidateUrl(string raw)
{
OutboundOptions o = options.Value;
if (!Uri.TryCreate(raw, UriKind.Absolute, out Uri? uri))
{
return "Must be an absolute URL.";
}
if (uri.Scheme != Uri.UriSchemeHttps && !(o.AllowHttp && uri.Scheme == Uri.UriSchemeHttp))
{
return "Only https URLs are allowed.";
}
if (uri.UserInfo.Length > 0)
{
return "Credentials in the URL are not allowed.";
}
if (uri.HostNameType is not UriHostNameType.Dns)
{
return "Use a host name, not an IP address.";
}
if (uri.Host.Equals("localhost", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".localhost", StringComparison.OrdinalIgnoreCase)
|| uri.Host.EndsWith(".internal", StringComparison.OrdinalIgnoreCase))
{
return "This host is not allowed.";
}
if (!o.AllowedPorts.Contains(uri.Port) && !o.AllowLoopbackPorts.Contains(uri.Port))
{
return "This port is not allowed.";
}
return null;
}
Banning IP literals altogether saves you from dealing with dozens of different spellings of an IP address such as 0x7f000001, 2130706433 or [::ffff:127.0.0.1]: Uri recognises them as IPs and their HostNameType isn't Dns. The port allowlist excludes internal services running on ports other than 443 from the start. AllowHttp and AllowLoopbackPorts exist only for the lab and tests; in production the defaults apply, i.e. https and 443 only.
$ curl -s -X POST http://localhost:19528/admin/webhooks -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"url":"https://169.254.169.254/latest/meta-data/"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"url":["Use a host name, not an IP address."]},"traceId":"00-9deb2491b97f6f3fcd8988ee6d88f634-9d57ec0941c662f5-00"}
$ curl -s -X POST http://localhost:19528/admin/webhooks -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"url":"https://user:secret@hooks.partner.test/bookstore"}'
{"type":"https://tools.ietf.org/html/rfc9110#section-15.5.1","title":"One or more validation errors occurred.","status":400,"errors":{"url":["Credentials in the URL are not allowed."]},"traceId":"00-0ee42659ec72c63bd3a0a0445a4664e0-a20e564168b818d6-00"}
$ curl -s -X POST http://localhost:19528/admin/webhooks -H "Authorization: Bearer $ADA" -H "Content-Type: application/json" -d '{"url":"https://hooks.partner.test/bookstore"}'
{"id":"01a1b9d0-060c-7ac6-820f-153abde6d2f4","url":"https://hooks.partner.test/bookstore","signingSecret":"NGZY5LTA..."}
The webhook endpoints are on the admin port we separated in the previous post (19528 in this run) and behind the tenant admin policy. I shortened the signing secret in the registration response; as in the third post, it's derived from the server's master key with the webhook id.
The Address at Connect Time: ConnectCallback
At registration "hooks.partner.test" was a valid name. But which address a name resolves to is decided by the name's owner, and that can change at any moment. Looking at DNS once at registration and validating the result doesn't protect against the technique called DNS rebinding: at validation time the name resolves to a harmless public address, at request time to 127.0.0.1. The right place is the moment the connection is made. SocketsHttpHandler.ConnectCallback provides exactly that: HttpClient calls our code before opening the TCP (Transmission Control Protocol) connection, and we make the connection ourselves.
// Instead of trusting one DNS lookup at registration, resolve on every connection, check, and connect straight to that IP
public static SocketsHttpHandler Create(OutboundPolicy policy) => new()
{
AllowAutoRedirect = false,
ConnectTimeout = TimeSpan.FromSeconds(3),
UseProxy = false,
ConnectCallback = async (context, ct) =>
{
int port = context.DnsEndPoint.Port;
IPAddress[] addresses = await policy.ResolveAsync(context.DnsEndPoint.Host, ct);
if (addresses.Length == 0 || addresses.Any(a => !policy.IsAllowed(a, port)))
{
throw new OutboundBlockedException(
$"Blocked destination {context.DnsEndPoint.Host} ({string.Join(", ", addresses.Select(a => a.ToString()))}).");
}
Socket socket = new(SocketType.Stream, ProtocolType.Tcp) { NoDelay = true };
try
{
await socket.ConnectAsync(new IPEndPoint(addresses[0], port), ct);
return new NetworkStream(socket, ownsSocket: true);
}
catch
{
socket.Dispose();
throw;
}
}
};
There are three details. If even one of the resolved addresses is blocked, we don't connect; a "pick the first allowed one" approach opens a door for a DNS record that returns one public and one private address. The address we check is the address we connect to: ConnectAsync gets the checked IP, not the name, so there's no second DNS lookup in between. UseProxy = false stops an HTTP_PROXY setting in the environment from routing the connection to a proxy, and therefore our check being applied to the proxy's address.
The address check is written with the IPNetwork type that came with .NET 8. The list contains the RFC (Request for Comments) 1918 private blocks, loopback, link-local (169.254.0.0/16, where cloud metadata services live), the CGNAT (Carrier-Grade Network Address Translation) block 100.64.0.0/10, multicast and the IPv6 equivalents. IPv4-mapped IPv6 addresses (::ffff:127.0.0.1) are converted to IPv4 before the check; forget that step and an address with the ::ffff: prefix skips every IPv4 rule.
In the lab I used the Outbound:DnsOverrides setting to imitate rebinding without making a real DNS query: I make the name "hooks.partner.test" resolve to 127.0.0.1. Registration passed a moment ago; now the test delivery:
$ curl -s -X POST http://localhost:19528/admin/webhooks/01a1b9d0-060c-7ac6-820f-153abde6d2f4/ping -H "Authorization: Bearer $ADA"
{"delivered":false,"statusCode":null,"error":"Destination is not allowed."}
warn: Bookstore.Orders.Api.Outbound.WebhookSender[0]
Webhook 01a1b9d0-060c-7ac6-820f-153abde6d2f4 blocked: Blocked destination hooks.partner.test (127.0.0.1).
The message returned to the user and the message in the log differ. The user only sees "destination is not allowed"; they don't see what address it resolved to, the kind of connection error or the other side's response body. Each of those is information that turns SSRF into a port scanner for the internal network. On a successful delivery the response body isn't read either; only the status code goes back to the user.
Does It Reach the Right Place? A Positive Test and Redirects
A test set that only tests "blocked" scenarios will also pass a broken handler that blocks everything. So one of the tests starts a real receiver: a small WebApplication inside the test, listening on a random port on 127.0.0.1. We allow that port with the lab settings and register two webhooks: /hook returning 200 and /moved redirecting to /internal-only.
Assert.Equal(new DeliveryResult(true, 200, null), ok);
Assert.Equal(302, redirected!.StatusCode);
Assert.False(redirected.Delivered);
Assert.Equal(["/hook v1=", "/moved"], hits);
The receiver got two requests: the signed first request (its X-Bks-Signature header starts with v1=) and the second request that returned a redirect. /internal-only received no request. AllowAutoRedirect is on by default; if you don't turn it off, an external address can redirect your server to an internal one with a 302, and even though ConnectCallback still runs for the redirect target, the redirect has skipped every shape check done at registration.
Incoming Webhooks: Signature, Time and Processing Once
In the opposite direction, the payment provider sends us a "payment succeeded" event. This endpoint has to be anonymous, because the caller isn't a user. The signature proves its identity:
// 1) Signature and time: "t=<unix>,v1=<hex>" and HMAC(secret, "<t>.<body>")
byte[] secret = Encoding.UTF8.GetBytes(File.ReadAllText(configuration["Payments:WebhookSecretFile"]!).Trim());
if (!TryVerify(request.Headers[SignatureHeader].ToString(), body, secret, time))
{
logger.LogWarning("Rejected payment webhook: bad signature or timestamp");
return Results.Unauthorized();
}
// 2) Even when signed, the content is validated: schema, type, amount and currency
PaymentEvent? evt;
try
{
evt = JsonSerializer.Deserialize<PaymentEvent>(body, Strict);
}
catch (JsonException)
{
return Results.UnprocessableEntity();
}
if (evt is null || evt.Type != "payment.succeeded" || evt.Currency != "EUR")
{
return Results.UnprocessableEntity();
}
// 3) The scope is set to the tenant in the signed event; an order in another tenant stays invisible
await using OrdersDbContext db = new(options, new DataScope(evt.TenantId, null));
if (await db.ProcessedWebhookEvents.AnyAsync(e => e.Id == evt.Id, ct))
{
return Results.Ok(new { status = "already processed" });
}
Order? order = await db.Orders.FirstOrDefaultAsync(o => o.Id == evt.OrderId, ct);
if (order is null || order.Total != evt.Amount || order.Status != OrderStatus.Pending)
{
logger.LogWarning("Payment event {EventId} does not match order {OrderId}", evt.Id, evt.OrderId);
return Results.UnprocessableEntity();
}
The signature scheme follows the format many payment providers use: a timestamp and a signature in a header, and the signed text is the timestamp joined with the body. As in the third post, the timestamp limits replay with a five-minute window. Repeats within the window are caught by the event id: in the ProcessedWebhookEvents table the id is the primary key and it's written in the same SaveChanges as the order update. Providers may send the same event more than once to guarantee delivery; in that case returning 200 without redoing the work is the right behaviour.
The second and third steps are the heart of API10. A signature says "the provider sent this message", not "this message is correct". The provider's account may be compromised, an integration bug may send the wrong order id. So we compare the amount in the event with the order total in our own database, and we set the scope to the tenant in the event: an event signed on behalf of North can't see South's order. For deserialization I turned on the RespectRequiredConstructorParameters and RespectNullableAnnotations options that came with .NET 9; a missing field or an unexpected null turns into an exception.
$ curl -s -X POST http://localhost:19518/webhooks/payments -H "X-Payments-Signature: t=$T,v1=$SIG" -H "Content-Type: application/json" -d "$EVT"
{"status":"processed"}
$ curl -s -X POST http://localhost:19518/webhooks/payments -H "X-Payments-Signature: t=$T,v1=$SIG" -H "Content-Type: application/json" -d "$EVT"
{"status":"already processed"}
$ curl -s -o /dev/null -w "%{http_code}\n" -X POST http://localhost:19518/webhooks/payments -H "X-Payments-Signature: t=$T,v1=$SIG" -H "Content-Type: application/json" -d "${EVT/54.90/0.01}"
401
In the last request I changed the amount but not the signature: the signature doesn't match, so 401. The tests also have events with a correct signature but the wrong amount, and pointing at another tenant's order; both get 422.
API10: A Third-Party Response Is Input Too
We treat the response of the API we call for shipping rates with the same suspicion. Registering the typed client sets up two defences at once:
// Third-party API: a size limit and standard resilience (retry, timeout, circuit breaker)
builder.Services.AddHttpClient<CarrierRatesClient>(client =>
{
client.BaseAddress = new Uri(builder.Configuration["Carrier:BaseUrl"] ?? "https://rates.carrier.test");
client.MaxResponseContentBufferSize = 32 * 1024;
})
.AddStandardResilienceHandler(options =>
{
options.AttemptTimeout.Timeout = TimeSpan.FromSeconds(2);
options.TotalRequestTimeout.Timeout = TimeSpan.FromSeconds(6);
options.Retry.MaxRetryAttempts = 2;
});
The default of MaxResponseContentBufferSize is 2 GB; for a rate response 32 KB is plenty. The standard resilience handler adds retries, a per-attempt timeout, a total timeout and a circuit breaker; when the shipping API slows down, our order screen doesn't slow down with it. The client itself validates the response like user input:
// A third-party response is input too: not used before its type, size, schema and value ranges are validated
public sealed partial class CarrierRatesClient(HttpClient http, ILogger<CarrierRatesClient> logger)
{
private static readonly string[] Currencies = ["EUR", "USD"];
private static readonly JsonSerializerOptions Strict = new(JsonSerializerDefaults.Web)
{
RespectRequiredConstructorParameters = true,
RespectNullableAnnotations = true
};
public async Task<ShippingQuote?> GetQuoteAsync(int items, CancellationToken ct)
{
try
{
using HttpResponseMessage response = await http.GetAsync($"/rates?items={items}", ct);
if (!response.IsSuccessStatusCode || response.Content.Headers.ContentType?.MediaType != "application/json")
{
return Reject("unexpected status or content type");
}
CarrierQuoteDto? dto = JsonSerializer.Deserialize<CarrierQuoteDto>(
await response.Content.ReadAsStringAsync(ct), Strict);
if (dto is null || !ServiceName().IsMatch(dto.Service) || dto.Price is <= 0 or > 500
|| !Currencies.Contains(dto.Currency) || dto.EstimatedDays is < 1 or > 30)
{
return Reject("response outside the expected schema or ranges");
}
return new ShippingQuote(dto.Service, dto.Price, dto.Currency, dto.EstimatedDays);
}
catch (Exception ex) when (ex is HttpRequestException or JsonException or TaskCanceledException
or Polly.Timeout.TimeoutRejectedException
or Polly.CircuitBreaker.BrokenCircuitException)
{
return Reject(ex.GetType().Name);
}
ShippingQuote? Reject(string reason)
{
logger.LogWarning("Carrier quote rejected: {Reason}", reason);
return null;
}
}
[GeneratedRegex("^[A-Za-z0-9 -]{1,40}$")]
private static partial Regex ServiceName();
}
A response that can't be validated returns null, and the endpoint turns that into 503, meaning "no quote right now". That's a fail-closed decision: better to show no price than a wrong one. In the lab I tried three responses with a small Python server imitating the shipping API: a valid price, a script in the service name and a 40 KB padding field.
MODE ok
{"service":"Express","price":7.5,"currency":"EUR","estimatedDays":2}
MODE script
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Shipping quotes are temporarily unavailable.","status":503,"traceId":"00-9ed1e581e9092a664c2c1bfc9b866a15-83397990a44e0b9d-00"}
MODE huge
{"type":"https://tools.ietf.org/html/rfc9110#section-15.6.4","title":"Shipping quotes are temporarily unavailable.","status":503,"traceId":"00-669e3b77c49d8b3fcecdd626f007d495-bc1c9e5b896001d4-00"}
warn: Bookstore.Orders.Api.Outbound.CarrierRatesClient[0]
Carrier quote rejected: response outside the expected schema or ranges
warn: Bookstore.Orders.Api.Outbound.CarrierRatesClient[0]
Carrier quote rejected: HttpRequestException
The tests do the same with a fake HttpMessageHandler: a negative price, an unsupported currency, a missing field, a null service name, a body that isn't JSON (JavaScript Object Notation) and a response above 32 KB. A seven-row Theory and a separate size test; together with all the tests in the OutboundTests class, 163 tests are green.
Production Notes
The application-level check shouldn't be the only layer. Running the process that sends webhooks as a separate service or container, on a network that only has outbound internet access and whose access to the internal network is cut by a network policy, compensates for a bug in your code. In cloud environments, enforcing the token-based version of the metadata service (for example IMDSv2, Instance Metadata Service version 2, on AWS (Amazon Web Services)) is another lock.
Delivering webhooks synchronously inside the request is fine for the lab, not for production. In a real system delivery goes through an outbox and a background worker, with retries and backoff; if the receiver keeps failing, the webhook is disabled and the tenant admin is notified. We discussed that structure in detail in the Messaging and Event-Driven Architecture series.
Never turn off third-party TLS (Transport Layer Security) validation. The line ServerCertificateCustomValidationCallback = (_, _, _, _) => true is usually added because "it threw a certificate error in the test environment" and then moves to production. If there is a certificate problem, the fix is correcting the list of trusted CAs (Certificate Authorities), not turning validation off.
Incoming webhook secrets are a key management topic too. Providers usually suggest accepting two secrets together during rotation; writing TryVerify to work with a list of secrets makes that easy.
Common Pitfalls
The first pitfall is validating the URL once, at registration. DNS answers change; the check must happen at connect time and on the address you connect to.
The second is "pick the first allowed address" logic, or checking only the first address. A name can resolve to several addresses, and if you don't decide which one HttpClient connects to, the check is meaningless. Make the connection yourself, to the address you checked.
The third is showing the user the response of the outbound request. A "test webhook" button that shows the other side's response body or the details of the connection error takes SSRF out of the dark and leaks information about the internal network.
The fourth is not validating data because it came from a signed or "trusted" source. A signature proves the source, not the content. Amount, currency, status transition and tenant must be compared with your own data.
A Small Experiment
★☆☆ In the lab, make "hooks.partner.test" resolve to 10.0.0.5 with Outbound:DnsOverrides and repeat the ping. What do you see in the log? Try the same with fd00::1.
★★☆ Change TryVerify to accept two secrets and test the rotation scenario: an event signed with the old secret is accepted until the transition period ends.
★★★ Move webhook delivery to an outbox table and a BackgroundService. Disable the webhook after five consecutive failed deliveries; think about how that slows down an attacker trying internal addresses one by one.
Next up are API8 (Security Misconfiguration) and API9 (Improper Inventory Management): an API-specific configuration checklist, keeping the OpenAPI document generated at build time as an inventory, catching unannounced endpoints with a diff between versions, and retiring old versions with the Deprecation and Sunset headers.