The Webhook URL Field That Could Reach Your Metadata Endpoint
The feature was innocent. Merchants paste a URL where we should deliver webhooks. We store it, and on events we make an HTTP request to it. That is the whole feature.
It is also a server side request forgery primitive. Any request your server makes to an attacker controlled URL can be pointed at anything your server can reach, which is a much larger set of things than the internet.
This was found in a review, not in an incident, which is the good version of this story. The fixes below are the ones we applied.
Why your server sees a different network than you do
From your laptop, http://169.254.169.254/ is unreachable and http://localhost:6379/ is your own machine. From a server in a cloud VPC, the first is the instance metadata service, which hands out credentials and configuration, and the second is whatever is listening on the host.
The metadata endpoint is the classic target because it requires no authentication from a process that is already inside the instance. A single GET to it, made by your own server on the attacker's behalf, can return IAM credentials that then open everything else.
But even without the metadata service, SSRF lets an attacker scan your internal network, talk to services that trust the network because they have no authentication, and exfiltrate data through the responses. Any internal service that says "we do not need auth, it is internal only" becomes reachable. That sentence is the vulnerability.
The validation that is not
Most implementations do some validation, and almost none of it works. Here are the common attempts and why each fails.
Block the string "localhost". The attacker uses 127.0.0.1, or 0.0.0.0, or [::1], or a decimal IP like 2130706433, or a domain that resolves to loopback. String matching on hostnames is whack a mole against the entire space of ways to say "this machine".
Block private IP ranges in the URL. The URL is parsed before DNS. The attacker supplies a hostname they control, internal.attacker.com, that resolves to 10.0.0.5. Your check sees a public looking hostname and allows it. The resolution to a private address happens later, at connect time, after your check has passed.
Resolve once, then allow. You resolve the hostname, check the IP, and then let the HTTP client connect. Between your resolution and the client's resolution, the DNS answer can change. This is a time of check to time of use race, and an attacker who controls the DNS answer can serve you a public IP for the check and a private IP for the connect.
The validation that works
The core idea is to control the resolution and then refuse to connect to anything private, at the moment of connection, every time.
Resolve yourself and pin the result. Resolve the hostname to a list of IPs. Filter out any address that is private, loopback, link local, or otherwise internal. Then connect to the pinned IP directly, passing the original hostname as the Host header or SNI name. Because you connect to the IP you checked, there is no second resolution and no race.
In practice this means a custom resolver or dial function in your HTTP client. Most languages expose a hook for this. The check should use a proper IP classification, not a hand written range list, because the set of non public address space is longer than people remember. It includes loopback, RFC1918, link local, the metadata range, unique local IPv6 and more.
Refuse redirects by default, or re validate on every hop. An SSRF filter that checks the initial URL and then follows a redirect to http://169.254.169.254/ has been defeated. Either disable redirects or run the same resolution and classification on every redirect target.
Egress filtering as the backstop. Even a perfect application check benefits from a network level deny. If your egress proxy or firewall blocks connections from the application tier to the metadata range and to your internal ranges, then a missed check becomes a connection error instead of a breach. Cloud providers also now offer metadata service hardening, such as requiring a session token, which turns the metadata endpoint from an open door into a negotiated one.
The shape of a safe webhook fetcher
Our webhook sender now does this. Parse the URL and require http or https. Resolve the host ourselves and classify every returned address, refusing the request if any is non public. Connect to a pinned public address with the original host for SNI. Follow no redirects. Enforce a short timeout and a small maximum response size, so the feature cannot be used to pull large internal payloads. And log every resolution and refusal, because the refusal log is also your intrusion detection for probing.
The timeout and size limits matter for a second reason. SSRF is also used to make your server attack other people, or to hang your workers on slow internal sockets. Bounding the request keeps the feature from becoming a liability in the other direction.
Where else you have this
Every feature that takes a URL and fetches it is the same feature. Image by URL, import from URL, link preview, oEmbed, avatar fetch, remote config, font proxy. Audit them all with the same checklist. This is the same "the user supplied a reachable address" class as an open redirect, except the one being redirected is your server, not the user's browser, covered in the login redirect parameter.
The rule
If your server fetches a URL that a user can influence, you have built a proxy, and the question is only whether you have decided what it is allowed to reach. Decide explicitly, enforce at connect time, and assume the internal network is hostile the moment it is reachable through a user controlled request.
The metadata endpoint will still be there tomorrow. The only thing you can remove is the path from your input fields to it.