July 11, 2026
Blind SSRF via Webhook Subscription URLs: When a SaaS Trusts Your Callback Too Much
A customer-configurable webhook feature with no egress validation let me make a platform’s own backend fire HTTP requests at internal and…

By a71n
3 min read
A customer-configurable webhook feature with no egress validation let me make a platform's own backend fire HTTP requests at internal and cloud-metadata addresses. #ssrf #webhooks #bug-bounty #appsec #cloud-security
A B2B product-onboarding SaaS lets any customer register webhook subscriptions you give it a URL, and its servers POST event notifications to that URL. The URL was not validated at all: it accepted plaintext [http://](http://), internal hostnames like metadata.google.internal, and the link-local address 169.xxx.xxx.xxx`. I confirmed out-of-band that the platform's own backend then makes those requests a classic Server-Side Request Forgery (SSRF) primitive, reachable by anyone with a normal API key. I'll also show the honest boundaries: it's blind, and the obvious escalations (reading cloud metadata, following redirects into the network) were closed which is exactly why this is a solid Medium, not a headline "I dumped the cloud credentials."
what a webhook subscription is?
Most SaaS platforms let you subscribe to events (user.created, order.updated, …). You register a callback URL; when the event fires, the platform's backend sends an HTTP POST to your URL. That's a deliberate outbound request made by the server which makes the callback URL a textbook SSRF sink unless the platform validates where you're allowed to point it. Good implementations reject private/link-local ranges, cloud-metadata hostnames, and non-HTTPS schemes, and re-resolve DNS at
send time to defeat rebinding.
POST /webhook_subscriptions HTTP/2 Host: api..com Authorization: Bearer ak_REDACTED Content-Type: application/json
{"url":"http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token", "topics":["user.created"]}
the subscription was created. It accepted an internal-only hostname, the
cloud-metadata path, and plaintext [http://](http://). [http://169.254.169.254/latest/meta-data/](http://169.254.169.254/latest/meta-data/) was accepted the same way. No allow-list, no private-range block, no scheme check.
Confirming the server actually fires the request (the OOB proof)
Acceptance at creation time isn't enough a careful platform might still block the target at send time. So I registered a second webhook pointing at my own Collaborator host and triggered the event:
POST /webhook_subscriptions {"url":"https:///wh","topics":["user.created"]} POST /users {"id":"probe","attributes":{"name":"probe"}} # fires user.created
Within a second, my listener recorded a DNS lookup + an HTTP POST originating from the platform's
backend IP (_<vendor-backend-ip>_), carrying the event body:
POST /wh HTTP/1.1 User-Agent: /1.0 -signature: t=…,v1=… Host: Content-Type: application/json
{ "object":"webhook_notification", "data":{…}, "topic":"user.created" }
That's the money shot: the server itself makes the request, to a URL I chose, from inside its own network. SSRF confirmed.
Being honest about impact (and why the escalations failed) I tried the standard escalations and they were all closed I document them because the absence of impact is part of the truth:
- Cloud-metadata token read → no. GCP's metadata endpoint requires a
Metadata-Flavor: Googleheader. I tried supplying custom headers via aheadersfield on the subscription; it was accepted at creation but stripped at delivery (the outgoing request carried only the platform's own UA and signature). No injected header, no token. - Redirect-following → no. I pointed a webhook at a public redirector that 302s to a second Collaborator host; the second hop never fired. The delivery client doesn't follow redirects to a new host, so you can't chain an external URL into an internal one.
- **Response readback → no.***There's no delivery-log endpoint, so the response body never comes back. The SSRF is blind.
So the demonstrated primitive is a blind, authenticated SSRF with zero egress validation: attacker-chosen outbound requests from the platform's network, but no response exfiltration and no proven internal-service compromise. Fully proving internal reach would need DNS-rebinding infrastructure (a hostname that resolves public at validation and internal at delivery)-which I called out as the next step rather than faking.
Severity Medium (CVSS ~5.0). Lowest-privilege access, zero egress validation, OOB-proven outbound from the vendor's backend — a confirmed*SSRF, not a "could allow." Held at Medium because the impact is honestly bounded (blind; no metadata read; no internal service hit).
Remediation
Validate the webhook URL at both create and send time: require [https://](https://); resolve the host and reject RFC1918, link-local 169.254.0.0/16`, loopback, and metadata hostnames/IPs; re-resolve and pin at delivery (anti-rebinding); don't follow redirects to disallowed hosts.
Takeaways
- Any "give us a URL and we'll call it" feature is an SSRF sink until proven otherwise. Test the URL
parser with internal hosts, link-local IPs, and
[http://](http://`). - Out-of-band confirmation is the finding. A 200 at creation is a lead; the callback from thevendor's own IP is the proof.
- Document the escalations that failed. Bounding the impact honestly makes triage faster and your report more credible — and it stops you from over-claiming.