SSRF Attacks and Defenses: URL Parsing, Allowlists, and Cloud Metadata
SSRF Attacks and Defenses stop your servers from fetching attacker URLs. Learn allowlists, DNS checks, metadata blocks, and how to fail closed on parse.
SSRF Attacks and Defenses cover the case where your server fetches a URL that a caller can influence. It matters because the server sits inside your network and can reach hosts the caller cannot. A bad fetch can hit admin panels, cloud metadata, or internal APIs. You should only fetch destinations you chose in advance.
If you block a few host names and allow the rest, the list will miss a new internal name. Then the next service you ship becomes reachable. Also, a check that looks only at the raw string will disagree with the client that actually connects.
What the failure is
The server takes a URL from a request, a webhook, or a file import and opens it. The caller picks the host. Your service adds its network position and its credentials. Because those credentials are meant for your code, a fetched internal URL can return secrets or trigger actions.
In my experience, the risky features look helpful. However, link previews, import from URL, and webhook testers are the usual doors. As a result, a public endpoint becomes a proxy into the private network. You should treat every outbound URL from user input as untrusted.
Cloud metadata is the sharp case. Instance metadata answers on a link local address and can hand out role credentials if you leave the old defaults. The defense is to require the newer session style, hop limits, and a network block so app pods cannot call it by accident. Do not give application code a reason to browse that address.
What you are protecting
Protect three things. First, cloud and platform credentials that live on metadata or local sockets. Next, internal admin URLs that trust the network.
Finally, other tenants if you run a multi tenant fetcher. If one customer can make you call another customer host, you have mixed their trust zones.
Still, deny by default is the posture. A public website fetch for a preview is a product choice. Therefore put it behind an allow list or a dedicated fetcher with no internal routes. Do not run that fetch inside the service that holds database credentials.
Architecture that fails closed
Split the fetcher from the main app. The app sends a job to a worker that has no metadata role and no path to internal admin ports. The worker allows only https, only ports you name, and only hosts on a list or a narrow set of public addresses. If the parse fails, the worker refuses the job.
A common mistake I have seen is to validate the host string and then follow redirects with a different HTTP client. The first host was safe. The next hop was not.
Specifically, recheck scheme, host, port, and resolved address on every hop. For example, cap redirects at a small number and then stop.
Parse the URL with a real library. Reject missing schemes, user info in the URL, and hosts you do not allow. Do not use a substring check on the raw text.
Substring checks disagree with how clients parse odd but legal URLs. Build a canonical form, then decide.
Steps to lock outbound calls
- Find every HTTP client call that uses request data.
- Move those calls to a fetcher with its own network policy.
- Allow only https and a small port list.
- Resolve DNS and reject private, loopback, and link local answers.
- Recheck those rules after each redirect before you connect.
When a product needs arbitrary public URLs, isolate that fetcher and strip its credentials. After isolation, a mistake returns a public page, not a cloud role key. Although isolation costs a service, it is cheaper than a leaked role.
Trade-offs in the control design
An allow list of hosts is the strongest fit for webhooks you configure. A deny list of private ranges is required even then, because an allowed name can point at a private address. A full isolation network is the right fit for open previews. You should write which model each feature uses.
| Control. | Use it when. | Main risk. | What you configure. |
|---|---|---|---|
| Host allow list. | Partners are known. | A listed name points inward. | Exact hosts and ports. |
| Address deny list. | You fetch public sites. | DNS answers can change. | Block private and link local. |
| Isolated fetcher. | Users supply any URL. | The isolate gains a new route. | No internal egress. |
| No redirects. | You can require a direct URL. | Some partners rely on hops. | Refuse redirect status. |
| Metadata lock. | You run on a cloud VM. | Old session style still on. | Require tokens and hop limit. |
If the host list is short and stable, allow list plus address checks. If the host list is the whole public web, use a fetcher with no path to internal RFC ranges and no cloud role. Instead of a growing deny list of host names, deny address classes and allow ports.
Blocking redirects is simpler and will break some content delivery hops. Following redirects is usable only if each hop repeats the full check. Therefore default to no redirects on webhook tests. Allow a small hop count only on the isolated fetcher.
Pitfalls and failure modes
DNS can change between the check and the connect. A name can resolve to a public address first and a private address second. This is why the check must happen on the address you actually connect to, inside the client, not only in a helper you call earlier.
Pin the lookup to the connect path. Also, set a short DNS life so you do not cache a flip for a long time.
Parser gaps are the next failure. One library accepts a host form that another treats as a different host. Use one parser for the decision and the same client for the call.
Reject URLs with user info, backslashes, and odd numeric hosts if you do not have a clear reason to allow them. Fail closed on parse errors.
IPv6 and name aliases expand the private space. If you only block one familiar address, other private forms still connect. Use a maintained range check for all private, loopback, and link local networks, for both address families. Test the helper with addresses from each class.
Metadata and identity
Turn on the session based metadata service and set the hop limit so a workload cannot forward the call. Remove instance roles from any box that does not need them. Use IAM roles with a narrow policy on the boxes that do.
A fetcher role should not be able to read secrets or change infrastructure. Network policy should still block the metadata address from app namespaces.
- HTTP client uses a URL from the request with no allow list.
- Redirects followed without a second check.
- Fetcher runs with a broad cloud role.
- Private address check covers only one dotted address.
- Parse errors logged and then fetched anyway.
A web application firewall does not see the outbound call your server makes later. Do not look for this bug only in inbound rules. We once hit a bottleneck when a preview feature ran inside the main app and cached remote bodies in the primary cache.
A slow remote host tied up app workers. The isolated fetcher added timeouts, a size cap, and no shared cache with the API.
Timeouts and size caps are part of the defense. A caller should not make you download a huge body or hold a connection open. Set both limits. Return a clear error when they trip.
A check you can adapt
The snippet parses a URL, allows only https on port 443, and rejects hosts that are not on a list. It also shows where an address check belongs before connect. Replace the host list with your partners. Also, call the same checks again if you ever follow a redirect.
# Illustrative outbound guard. Fail closed on parse errors.
# Recheck on every redirect. Do not fetch from the main app role.
import ipaddress
from urllib.parse import urlparse
ALLOWED_HOSTS = {"partner.example.com", "hooks.example.com"}
def vet_url(raw):
parts = urlparse(raw)
if parts.scheme != "https" or parts.username or parts.password:
raise ValueError("scheme or user info refused")
if parts.hostname not in ALLOWED_HOSTS:
raise ValueError("host not allowed")
if parts.port not in (None, 443):
raise ValueError("port refused")
return parts
def vet_address(addr):
ip = ipaddress.ip_address(addr)
if (ip.is_private or ip.is_loopback or ip.is_link_local
or ip.is_reserved or ip.is_multicast):
raise ValueError("address class refused")
Wire this guard inside the HTTP client so a later refactor cannot call connect without it. Add tests for a good partner URL, a private address, a non https scheme, and a redirect hop you refuse. Keep the metadata address in the address class tests so a library change cannot drop it.
The AWS instance metadata guide shows how to require session tokens and set hop limits. The OWASP SSRF Prevention Cheat Sheet lists allow lists, network controls, and parse pitfalls. Use the cloud guide for metadata and the cheat sheet for the app fetcher.
Performance, scale, and cost
Outbound fetches are slow compared with local work. Give them a deadline that is shorter than the user request deadline. Otherwise one remote stall holds your whole handler. Therefore queue preview jobs and return a pending state when the product allows it.
DNS and address checks add a lookup. Cache positive public results for a few seconds, not for hours. Do not cache a failure as a success.
Also, cap concurrent fetches per tenant so one account cannot exhaust the worker pool. That cap is both a cost control and a fairness control.
At scale, the isolated fetcher is its own service with its own autoscale and its own bill. That split is worth it when user URLs are a feature. A handful of partner webhooks can stay in a small allow list inside one service. Measure egress bytes so a bug cannot pull large objects all day.
Network and the rest of the stack
Kubernetes network policies can deny egress from the app namespace to metadata and to internal ranges. Write the policy next to the service. A cluster default of allow all egress undoes the app check the first time someone uses a raw client.
Pair fetches with DDoS protection on the way in so a flood of URL jobs cannot queue forever. Use TLS to the partner and verify the certificate host. Do not disable verification to make a webhook pass.
Cost shows up as egress, worker CPU, and incident risk. An illustrative production range is a few preview fetches per user action, each with a small byte cap. Put the cap in config. Alert when a tenant crosses it.
Key Takeaways
- Treat every caller supplied URL as untrusted input.
- Allow list hosts when you can, and still reject private addresses.
- Parse with one library and fail closed on errors.
- Recheck scheme, host, and address on every redirect.
- Run open fetches in a worker with no cloud role and no internal egress.
- Lock metadata with session tokens, hop limits, and network deny.
- Cap time, size, and concurrency so a fetch cannot exhaust the app.
FAQ
Is a host name block list enough?
No. New internal names appear faster than deny lists. Block private and link local address classes, and allow only known hosts when the feature is a webhook. Isolate the fetcher when the host can be any public site.
Why check DNS if the host string looks public?
A public name can resolve to a private or link local address. The string check will pass and the connect will hit your network. Vet the address you connect to, and do it again if you follow a redirect.
Should application pods reach instance metadata?
Only the pods that need a role, and only through the locked down metadata service. Preview and webhook workers should not have a role and should be denied by network policy. Most request handlers do not need metadata at all.
What should a refused fetch return?
Return a client error and do not connect. Log the reason code and the tenant, not a full internal URL from a redirect chain if it may contain secrets. Alert on a spike of address class refusals. That spike can be abuse or a bad partner DNS change.
SSRF Attacks and Defenses reduce to a boring rule. Your server should fetch only the URLs you planned, from a process that holds no extra power. Inventory every client that builds a URL from request data.
Next, put an allow list and an address class check on those calls. Then move open fetches to a worker with no internal egress and no cloud role. After that, confirm metadata requires a session token and that app namespaces cannot reach it.
Last updated on 21 September 2026.