If your product sends webhooks, you ship a feature any customer can program: they give you a URL, and your servers send a request to it.
That is exactly what a SSRF attack (Server-Side Request Forgery) is after: getting your servers to talk to addresses only they can reach.
What the attacker wants:
- 169.254.169.254, your cloud’s metadata service (temporary credentials, configuration);
- your internal services, never exposed to the internet: databases, search engines, admin tools, message queues;
- and often the response itself: many dashboards show the response body in the delivery history. The attacker just has to read it.
The textbook defence fits in one sentence: reject URLs that point to a private address. Except there are at least four ways around it.
Trap #1: checking only when the URL is saved
The URL is validated when the customer enters it: hooks.customer.com points to a public IP, all good.
The next day, they change their domain’s DNS record to point to 10.0.0.5. Your workers now send every event into your internal network, and nobody checks again.
The rule: check on every delivery, not only when the URL is saved.
Trap #2: check, then connect again (DNS rebinding)
You learned the lesson: on every delivery you resolve the name, the IP is public, you send the request… and your HTTP client resolves the name a second time to connect.
A malicious DNS server with a TTL of 0 answers a public IP to the first question and 127.0.0.1 to the second. Your check saw the right address; your connection goes to the wrong one.
The rule: pin the connection to the IP you just checked. With curl, that’s CURLOPT_RESOLVE. Elsewhere, connect to the IP directly while keeping the right Host header and SNI.
With PHP / Laravel, the idea takes a few lines:
$ips = resolveAll($host); // A and AAAA, not just IPv4
foreach ($ips as $ip) {
if (isInternal($ip)) {
return; // one internal IP is enough to refuse
}
}
// IPv6 goes in brackets in CURLOPT_RESOLVE
Http::withOptions(['curl' => [CURLOPT_RESOLVE => ["{$host}:443:" . implode(',', $ips)]]])
->withoutRedirecting()
->timeout(15)
->post($url, $payload);
Trap #3: an incomplete list of “private” addresses
Blocking 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and 127.0.0.0/8 is not enough. Also think about:
- IPv6: ::1, fc00::/7, fe80::/10. And resolve AAAA records: a function that only reads IPv4 lets through a name whose only internal address is IPv6;
- IPv4 disguised as IPv6: ::ffff:10.0.0.1, or 64:ff9b::a00:1, which a NAT64 gateway translates to… 10.0.0.1;
- the shared 100.64.0.0/10 space (CGNAT), often used internally by Kubernetes networks and VPNs;
- 0.0.0.0, which reaches the local machine on Linux;
Blocking 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 and 127.0.0.0/8 is not enough. Also think about:
- IPv6: ::1, fc00::/7, fe80::/10. And resolve AAAA records: a function that only reads IPv4 lets through a name whose only internal address is IPv6;
- IPv4 disguised as IPv6: ::ffff:10.0.0.1, or 64:ff9b::a00:1, which a NAT64 gateway translates to… 10.0.0.1;
- the shared 100.64.0.0/10 space (CGNAT), often used internally by Kubernetes networks and VPNs;
- 0.0.0.0, which reaches the local machine on Linux;
- exotic notations: http://2130706433/ and http://0x7f.1/ both mean 127.0.0.1. Compare parsed IP addresses, never strings.
A concrete example: in PHP, filter_var() with FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE lets 100.64.0.1 and 64:ff9b::a00:1 through. You have to add those ranges yourself.
Trap #4: following redirects
The URL is public and passes the check. Then the server answers 302 Location: http://169.254.169.254/…, and your HTTP client politely follows.
The rule: turn redirects off. A 3xx is a failed delivery, not an invitation.
Bonus: don’t serve the response on a plate
Showing the response body in the delivery history turns a “blind” SSRF into a readable one. Truncate the response, and show nothing when the address was blocked.
The strongest defence is still the network
Checks in your code are essential, but the safest setup is one where your webhook workers simply cannot reach your internal network:
- route their requests through a dedicated egress proxy (Stripe’s Smokescreen is open source);
- block private ranges and 169.254.169.254 for them at the firewall;
- on AWS, require IMDSv2: the token is obtained with a PUT and a dedicated header, which a webhook sent as a POST cannot do.
The checklist
- ✅ Validate the URL when it is saved and on every delivery
- ✅ Resolve every IP (A and AAAA) and refuse as soon as one is internal
- ✅ Pin the connection to the IPs you checked
- ✅ Cover IPv6, IPv4-mapped, NAT64, CGNAT and 0.0.0.0
- ✅ Never follow redirects
- ✅ Short timeouts and a truncated response
- ✅ Isolate the workers at the network level: egress proxy, firewall, IMDSv2
- ✅ Log every blocked URL, and alert on it
At Wajub, every webhook now goes through these checks at delivery time, on the dashboard and on the API.
If you build a platform that calls URLs your customers provide, try the exercise: which of these four doors is still open on yours?
