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
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
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.
Summary
When a webhook subscription is created,
Webhooks.APIissues a server-sideOPTIONSrequest 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 "OnCheckSameOrigin" below.Affected code
src/Webhooks.API/Services/GrantUrlTesterService.csThe stored
DestUrl(=request.Url) is also requested later at event-delivery time (WebhooksSender), extending the same issue beyond subscription.Steps to reproduce
Verified by pointing
<internal-host>at a local listener: the listener received a server-originatedOPTIONS /request (carrying theX-eshop-whtokenheader), confirming the service performs an outboundrequest 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 →
400to the caller, but the requestis sent).
On
CheckSameOrigin(why this isn't a "bypass")CheckSameOrigincompares the origin ofrequest.Url(event-delivery target) withrequest.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 == GrantUrlis satisfying a functionalprecondition, 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.APIto 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:(
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.
https(andhttponly if explicitly required).WebhooksSender), not only at subscription time.CheckSameOrigincan remain for its delivery-consent purpose, but it is orthogonal to the fix above.