#999·eShop

SSRF: webhook subscription issues an outbound request to a caller-controlled URL with no destination validation

Author: luuhung1217Created Jun 13, 2026Updated Jun 13, 2026

Summary

When a webhook subscription is created, Webhooks.API issues a server-side OPTIONS request to a URL taken from the request body, with no validation of the destination (no allow-list, and no deny-list for loopback / link-local / RFC1918 / cloud-metadata addresses). An authenticated caller can therefore make the service send requests to internal hosts it can reach but the caller cannot — a server-side request forgery (blind: the response body is not returned to the caller).

The only precondition before the outbound call, CheckSameOrigin(request.Url, request.GrantUrl), is not a destination-security control — see "On CheckSameOrigin" below.

Affected code

src/Webhooks.API/Services/GrantUrlTesterService.cs

csharp
public async Task<bool> TestGrantUrl(string urlHook, string url, string token)
{
    if (!CheckSameOrigin(urlHook, url))          // urlHook = request.Url, url = request.GrantUrl
        return false;

    var client = factory.CreateClient();
    var msg = new HttpRequestMessage(HttpMethod.Options, url);   // <-- outbound to a caller-chosen host
    msg.Headers.Add("X-eshop-whtoken", token);
    var response = await client.SendAsync(msg);                  // no host/IP allow- or deny-list
    ...
}

The stored DestUrl (= request.Url) is also requested later at event-delivery time (WebhooksSender), extending the same issue beyond subscription.

Steps to reproduce

bash

Verified by pointing <internal-host> at a local listener: the listener received a server-originated OPTIONS / request (carrying the X-eshop-whtoken header), confirming the service performs an outbound request to a caller-controlled, loopback destination. Pointing it at another internal port (e.g. a database port) still triggers the outbound request (blind; non-2xx response → 400 to the caller, but the request is sent).

Note: reaching cloud-metadata specifically (169.254.169.254) is inferred from the absence of any IP filtering in the code, not directly demonstrated in my environment (no metadata endpoint present).

On CheckSameOrigin (why this isn't a "bypass")

CheckSameOrigin compares the origin of request.Url (event-delivery target) with request.GrantUrl (ownership-grant target). Its purpose is delivery-consent binding — ensuring the endpoint that proves consent shares an origin with the endpoint that will receive events — not SSRF protection.

It cannot serve as an SSRF control: both URLs come from the same request, so the caller simply sets them equal and points both at any internal host. Setting Url == GrantUrl is satisfying a functional precondition, not defeating a security mechanism. The actual gap is the complete absence of destination validation on the outbound request.

Impact

Authenticated, blind SSRF from Webhooks.API to arbitrary internal endpoints (loopback, RFC1918, link-local, including cloud-metadata and service-mesh addresses). Exploitable via side effects / timing, and for credential theft against metadata services that respond to the request.

Suggested fix

Validate the destination of the outbound request, independently of CheckSameOrigin:

  • After DNS resolution, reject private / loopback / link-local / metadata ranges (127.0.0.0/8, 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16, 169.254.0.0/16, ::1, fc00::/7, …), re-checking post-resolution to defeat DNS rebinding.
  • Restrict the scheme to https (and http only if explicitly required).
  • Prefer an explicit allow-list of permitted destinations where the deployment allows it.
  • Apply the same validation at delivery time (WebhooksSender), not only at subscription time.

CheckSameOrigin can remain for its delivery-consent purpose, but it is orthogonal to the fix above.