You let the client choose a URL, the server obeyed, and went to fetch the credential of your own infrastructure.
The field looks harmless: "webhook callback URL," "company logo by link," "import a spreadsheet from a URL."
The user types in an address, your backend makes a GET request, and you just handed them a browser running inside your VPC.
SSRF - Server-Side Request Forgery - is the vulnerability where the attacker doesn't access the internal resource directly, instead, they convince your server to access it on their behalf, and the server has everything the attacker doesn't: it's inside the network, it passes through the firewall, it has a route to the database, and in the cloud it has access to one very specific address that hands out credentials to whoever asks.
The advice that circulates about this almost always stops at the first line of defense: "validate the URL," "block 169.254.169.254," "don't accept localhost," and all of it is correct, and all of it is insufficient, because each of these statements describes a check on text, while the server acts on the IP resolved at connection time, these are different things, and the attacker lives exactly in that difference.
I see this class of bug in financial APIs all the time, because fintech is built on integration: a webhook to notify a partner, an acquirer callback, fetching a receipt, importing from a link, validating an OIDC partner's public key, every place where the server fetches a URL that came from outside is a candidate, and I'm going to write about what actually happens, why the obvious defense fails on five different fronts, what defense actually holds, and how you prove it without taking anything down.
Scope note: this is security engineering, not an attack recipe, every test described here assumes authorization, a pentest contract or a program's policy, and pointing someone else's server at their own internal network without permission is a break-in, not research.

Fig. 1 - The firewall worked perfectly, the request originated from the inside.
The attacker doesn't enter the network, they send your server in
The question that defines SSRF is a single one: who makes the outbound request?
When your backend receives { "webhook_url": "https://parceiro.com/hook" } and goes to fetch it or notify it, the traffic originates from the server, not the client, and the server sits in a privileged place: inside the network, with a route to the database, to the cache, to the internal dashboard, and in the cloud, to an address that has come to haunt everyone.
Swap the partner's URL for an internal target:
{ "webhook_url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }
That 169.254.169.254 is the instance metadata endpoint on AWS, GCP, and Azure, under IMDSv1, a GET with no headers at all returns the role name, and a second GET returns its temporary credentials, the attacker never touched your network, they typed a URL into a form field, and your server fetched the key.
Where the key shows up afterward changes how much the bug is worth, which is why there are three flavors:
| Type | What you see | How it's confirmed |
|---|---|---|
| Reflected SSRF | the remote response body | directly, the data comes back |
| Semi-blind SSRF | status, size, timing | by the difference between targets |
| Blind SSRF | nothing | via a callback to a collector you control |
Blind SSRF is the most common in webhooks, because the product has no reason to show you what the partner responded, and it's the most dangerous to underestimate: "returns nothing" turns into "low impact" in the report, when in practice it still reaches every POST, everything that changes state, and everything that can be exfiltrated over DNS.
SSRF turns "my server makes HTTP requests" into "the attacker makes HTTP requests from the inside."
Real problem #1: the target isn't just the metadata, it's everything the firewall was protecting
The metadata endpoint is the trophy because the payoff is a credential, but SSRF doesn't stop there, and from inside the network, the server reaches what the outside world never reaches:
- Unauthenticated internal services. An admin panel that "only the internal network can reach," Elasticsearch on 9200, Redis on 6379, Kibana, Consul, Prometheus, Jenkins, the Spring Boot actuator on 8080, Kubelet on 10250, a database exposed internally, and the network was the authentication, and you just punched a hole through it.
- Cloud metadata. IAM role credentials on a plate, plus
user-data, which usually holds environment variables, database passwords, and deploy tokens because someone assumed that was private. - Internal port scanning. A difference in timing or error message between
http://10.0.0.5:8080andhttp://10.0.0.5:9999maps out what's alive in there, even under blind SSRF, a refused connection comes back in milliseconds; a filtered port blows through the timeout, and that's a binary oracle, and a binary oracle maps out an entire/24. - Other schemes. If the HTTP client follows
file://, you read/etc/passwd,/proc/self/environ, and the Kubernetes service account token at/var/run/secrets/kubernetes.io/serviceaccount/token, if it followsgopher://, you speak raw protocol to Redis and turn a read into an arbitrary write, anddict://,ftp://,ldap://, andjar://are variations on the same theme. - The SSRF that never leaves the machine.
http://127.0.0.1:8500,http://[::1]:9200, the Docker socket, a containerized application usually has interesting neighbors on the same host.
In a financial system, this is severe in a specific way: a leaked IAM credential usually grants access to buckets holding receipts and KYC documents, the settlement queue, the transaction database, it isn't "they read an internal file," it's "they assumed the identity of the application in the cloud," that exact chain is what produced the Capital One breach in 2019, exposing data on roughly 100 million people in the US and 6 million in Canada: SSRF through a misconfigured WAF (ModSecurity) running on EC2, IMDSv1 responding with no token required, the instance role's credentials, then a read of the S3 buckets, and notice the first link in that chain, because it's uncomfortable: the vulnerable piece was the security control itself.
The internal network was never a security boundary, SSRF just proves it in practice.
Real problem #2: blocking by IP blocklist doesn't work, and it's easy to show why
The defense everyone writes first is filtering the URL: "if it points to 169.254.169.254, 127.0.0.1, or 10.x, reject it," and it sounds reasonable, and it's broken on at least five independent fronts - what matters isn't any single one of them, it's the fact that there are five, because that means the next one also exists and you don't know about it yet.
1. Alternative representations of the same IP. An IPv4 address is a 32-bit integer, and practically every networking library accepts the historical ways of writing that integer:
| Form | 169.254.169.254 |
|---|---|
| dotted decimal | 169.254.169.254 |
| decimal integer | 2852039166 |
| hexadecimal | 0xA9FEA9FE |
| octal | 0251.0376.0251.0376 |
| mixed | 0xA9FE.43518 |
Your regex that looks for the text 169.254 catches none of the last four, the operating system resolves all of them to the same place, because inet_aton has always accepted this format and nobody is going to remove that now.
2. Localhost has many names. 127.0.0.1, 127.1, 127.0.1, 0.0.0.0, [::1], [::], localhost, localtest.me, and any domain someone deliberately points at loopback, and the loopback block is an entire /8: that's 16 million addresses that all answer on the same machine.
3. DNS you don't control. The attacker registers evil.com and makes the record answer with a public IP at validation time and 169.254.169.254 at fetch time, this is DNS rebinding, a classic TOCTOU: you validate one IP and connect to another, because you resolved twice, and it doesn't even require the attacker's own infrastructure - services like nip.io and sslip.io return whatever IP is embedded in the name, and public rebinding tools alternate between two answers on every query.

Fig. 2 - The window between validating and connecting is the vulnerability, it exists whenever the name gets resolved twice.
4. Redirects. You validate https://inocente.com, the remote server responds with 302 Location: http://169.254.169.254/latest/meta-data/, and the HTTP client obediently follows it, you validated the input URL and ignored where it actually led, and this is the easiest bypass of all, because it requires no parsing trickery whatsoever: it only requires the attacker to control a server on the internet, which they do.
5. IPv6, mappings, and translation. [::ffff:169.254.169.254] is the same address in mapped form, [::ffff:a9fe:a9fe] is the same one in hexadecimal, and AWS also publishes its metadata at [fd00:ec2::254], and a filter written with only IPv4 in mind sees none of this.
And there's a case that survives even for whoever handled the mappings, because it isn't IPv4 disguised as IPv6, it's IPv4 translated: the well-known NAT64 prefix, 64:ff9b::/96, on any network with NAT64 and DNS64 (which includes IPv6-only EKS clusters, a good chunk of mobile carriers, and several modern VPCs), [64:ff9b::a9fe:a9fe] lands on the metadata endpoint, whoever wrote the blocklist by looking at the RFC 1918 private ranges and their IPv6 equivalents won't have this prefix, because it isn't private: it's a global block reserved for translation, and add 2002::/16, 6to4, for the same reason.
This fifth item is the best illustration of the whole section's argument, it isn't that you forgot a range, it's that the list of ways to write "the same destination" keeps growing over time, by decisions made by people who aren't thinking about your blocklist.
The pattern behind all five: you validated the text of the URL, and the server acted on the IP resolved at connection time.
Real problem #3: your URL parser and your HTTP client disagree
This is the hole that survives even the team that did everything right in the previous section, and it's the most elegant of the five, because it doesn't depend on you having forgotten anything, it depends on two libraries reading the same string differently.
Almost every validation follows this shape:
host = urlparse(url).hostname # library A decides what the host is
if not allowed(host):
raise Refused
requests.get(url) # library B decides again, from scratch
Validation and connection parse the same string independently, and if A and B disagree on any edge case, the entire check becomes decoration, and they do disagree, because the URL is a format with 30 years of backward compatibility and three competing specifications: RFC 3986, the WHATWG URL Standard, and whatever each implementation did before any standard existed.
The cases that show up most often:
| String | What A usually sees | What B usually connects to |
|---|---|---|
http://parceiro.com@169.254.169.254/ | parceiro.com | 169.254.169.254 |
http://169.254.169.254#@parceiro.com/ | parceiro.com | 169.254.169.254 |
http://parceiro.com\n@169.254.169.254/ | error or parceiro.com | varies |
http://parceiro.com:80@internal:8080/ | parceiro.com | internal |
The first one is the userinfo from RFC 3986: everything before the @ is username and password, not host, a careless parser reads left to right and finds parceiro.com first; a correct HTTP client reads the last @ and connects to whatever comes after it.
And there's a case that doesn't fit in the table at all, because it isn't a malformed string, it's a string that changes identity depending on who reads it: a domain written with circled alphanumerics, something like ⓟⓐⓡⓒⓔⓘⓡⓞ.com, under NFC it stays exactly that; under NFKC it becomes parceiro.com, and the divergence between libraries in the very same ecosystem is more direct than anything in the table above: the idna package, which implements IDNA 2008, rejects the name with InvalidCodepoint; Python's built-in str.encode("idna"), which is IDNA 2003 and still does nameprep, accepts it and returns parceiro.com.
One rejects it, the other silently resolves it to a different host, and the question that decides your case is which of the two answered when your domain allowlist was consulted.
This territory was mapped in detail by Orange Tsai in A New Era of SSRF (Black Hat USA 2017), which showed the same kind of divergence between libcurl, PHP's parser, Python's, Java's, and Ruby's, and his practical conclusion is the only one that matters here:
If the thing that validates and the thing that connects aren't the same thing, you haven't validated anything.
The defense isn't finding a perfect parser, it's eliminating the second parse: you resolve, you decide, and you hand the HTTP client an IP that's already chosen, not a string for it to reinterpret, that's the next section.
Real problem #4: the defense that holds lives at the destination, not the input
SSRF isn't solved by cleaning up the input, it's solved by controlling the output, in order of effectiveness:
1. Allowlist, not blocklist. Don't try to enumerate everything that's forbidden, you will forget a representation - section 2 lists five families and isn't exhaustive, list what's allowed instead: the specific domains or IPs the application legitimately talks to, everything else out, rejected, and a blocklist is you against the attacker's creativity; an allowlist is the attacker against a closed door.
The immediate objection is real: a client webhook is, by definition, an arbitrary destination, you can't build a domain allowlist for that, and that's fair - which is exactly why a domain allowlist and an IP range allowlist are different controls, a client webhook can go to any domain, as long as it resolves to a public IP that isn't yours, and that's still an allowlist, just one over the address space instead of the name space.
2. Resolve DNS once and connect to that IP. This kills rebinding and the parser differential in one move, because the HTTP client no longer receives a name to reinterpret.
3. Don't follow redirects, or revalidate every hop. The simple case is to turn off following redirects and treat a 3xx as a response, not as an instruction, and if the product needs to follow them, every hop goes through the same IP check, with a low limit on the number of hops.
4. Reject every range that isn't public internet. After resolving, and not just the obvious ones:
| Range | What it is |
|---|---|
0.0.0.0/8 | "this network," resolves to local |
10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 | private |
100.64.0.0/10 | CGNAT, used by several VPCs |
127.0.0.0/8 | the entire loopback range |
169.254.0.0/16 | link-local, includes the metadata endpoint |
192.0.0.0/24, 198.18.0.0/15 | special use and benchmarking |
224.0.0.0/4, 240.0.0.0/4 | multicast and reserved |
::/128, ::1/128, fc00::/7, fe80::/10 | unspecified, loopback, ULA, and IPv6 link-local |
64:ff9b::/96, 2002::/16 | NAT64 and 6to4, the whole IPv4 space tunneled inside |
The last two rows are the ones missing from almost every implementation, and they're the ones from section 2, item 5: they don't block an internal range, they block a translation path that delivers the entire IPv4 space, metadata included, inside a global IPv6 address.
5. Close the metadata port at the source. Require IMDSv2, block the route, or both.
6. And one that isn't a defense, it's a discount on the bug's price: don't return the remote response to the caller. The table from the beginning separates reflected, semi-blind, and blind by a single criterion: how much the attacker sees come back, that's almost always a product decision, not a security one - someone put the webhook response body on the client's debug screen because it was useful for support, taking it off that screen turns a reflected SSRF, which hands over IAM credentials in plain text, into a blind SSRF, which is still serious but requires a side channel for every byte, and it fixes nothing and drops the impact by an order of magnitude, which is a lot for one line of code.
In Ruby, the version that pulls all of this together fits on one screen:
require "resolv"
require "ipaddr"
require "net/http"
class DestinationRefused < StandardError; end
RESPONSE_CAP = 5 * 1024 * 1024
FORBIDDEN_RANGES = %w[
0.0.0.0/8 10.0.0.0/8 100.64.0.0/10 127.0.0.0/8 169.254.0.0/16
172.16.0.0/12 192.0.0.0/24 192.168.0.0/16 198.18.0.0/15
224.0.0.0/4 240.0.0.0/4
::/128 ::1/128 fc00::/7 fe80::/10 64:ff9b::/96 2002::/16
].map { |range| IPAddr.new(range) }
def public?(address)
ip = IPAddr.new(address)
ip = ip.native if ip.ipv6? && ip.ipv4_mapped? # ::ffff:169.254.169.254 -> 169.254.169.254
FORBIDDEN_RANGES.none? { |range| range.include?(ip) }
end
def addresses_for(host)
IPAddr.new(host) # already a literal in the URL, no second resolution to diverge
[host]
rescue IPAddr::InvalidAddressError
Resolv.getaddresses(host)
end
def external_fetch(raw_url)
uri = URI.parse(raw_url)
raise DestinationRefused, "scheme #{uri.scheme}" unless %w[http https].include?(uri.scheme)
host = uri.host.to_s.delete_prefix("[").delete_suffix("]") # [::1] -> ::1
addresses = addresses_for(host)
raise DestinationRefused, "no resolution" if addresses.empty?
raise DestinationRefused, "resolves to an internal range" unless addresses.all? { |a| public?(a) }
http = Net::HTTP.new(uri.host, uri.port)
http.ipaddr = addresses.first # connects to this IP, does not resolve again
http.use_ssl = uri.scheme == "https"
http.open_timeout = 2
http.read_timeout = 5
http.max_retries = 0
http.request(Net::HTTP::Get.new(uri)) do |response|
raise DestinationRefused, "redirect to #{response["location"]}" if response.is_a?(Net::HTTPRedirection)
body = +""
response.read_body do |chunk|
body << chunk
raise DestinationRefused, "response over the cap" if body.bytesize > RESPONSE_CAP
end
return body
end
end
Six points in this code aren't just style:
http.ipaddr =. This is the line that closes the TOCTOU,Net::HTTPconnects to the given IP and still usesuri.hostfor theHostheader and for TLS SNI, so the certificate is still validated against the name, and without this line,Net::HTTPwould resolve the name again, and you would have validated one resolution and used another.addresses.all?, notaddresses.find. If the name resolves to one public IP and one internal one, the correct answer is to reject everything, and choosing "whichever one passed" hands the attacker control of the choice: they just need to include one good IP in the list.addresses_forhandles a literal IP before trying to resolve it. Without this detour,http://169.254.169.254/would fall intoResolv.getaddresses, which doesn't resolve a literal, returns an empty list, and rejects with "no resolution," it fails closed, so it isn't a hole, but the message lies: whoever tests the implementation will see the wrong error and conclude the range filter isn't working, and a control that rejects for the wrong reason is a control someone will "fix" in the next refactor.Net::HTTP#requestdoesn't follow redirects. This is the library's default behavior, and here it's an advantage: a3xxbecomes an explicit error instead of turning into a second, unvalidated fetch, and higher-level libraries follow redirects by default, and that's where the defense usually leaks.- Short timeouts and
max_retries = 0. A long timeout is what turns blind SSRF into a comfortable port scanner, and retries multiply every attempt the attacker makes. read_bodyin a block, with a byte cap.read_timeoutmeasures the interval between chunks, not the total size: an internal target that returns a 10 GB table, or an endpoint that dribbles out one chunk per second forever, respects the timeout and eats the worker alive, and reading as a stream and aborting at the cap is the difference between araiseand an availability incident caused by your own defense.
What this code still doesn't solve, and it's honest to say so: it doesn't protect against rebinding if something between you and the destination resolves the name again - an HTTP proxy in the middle, for example, in container architecture, the more robust answer isn't code at all, it's dedicated egress, all outbound traffic triggered by user content goes through its own proxy, on a subnet with no route to the internal network and no access to the metadata endpoint, with a centralized destination policy, and at that point, the application can get its validation wrong and still reach nothing.
The rule: decide based on the destination IP at the instant of connection, against a list of what's allowed, never based on the text the client sent.
Real problem #5: IMDSv2 isn't automatic, and every cloud has its own gate
The cheapest mitigation of all is closing off the metadata endpoint, and it's cheap because it doesn't require touching a single line of application code, except "use IMDSv2" isn't one global switch, it's a setting per instance, per template, and per container, and the default inherited from an old instance keeps allowing v1.
How each cloud behaves:
| Cloud | Address | What it requires |
|---|---|---|
| AWS IMDSv1 | 169.254.169.254 | nothing, plain GET |
| AWS IMDSv2 | 169.254.169.254 | PUT to get a token, then a header |
| GCP | metadata.google.internal | Metadata-Flavor: Google header |
| Azure | 169.254.169.254/metadata | Metadata: true header |
| Alibaba | 100.100.100.200 | nothing, on the legacy configuration |
The practical difference: IMDSv2 requires a PUT with X-aws-ec2-metadata-token-ttl-seconds to get a token, then a GET carrying that token in a header, an SSRF that can only fire off a GET, with no control over method or headers, stops right there, and GCP and Azure get the same effect with a mandatory header, for the same reason: a custom header is exactly what a simple SSRF doesn't control.
Three traps in this configuration:
HttpTokens: requiredneeds to be required, not merely offered. An instance set tooptionalaccepts both v1 and v2, check withaws ec2 describe-instances --query 'Reservations[].Instances[].MetadataOptions'and treatoptionalas a finding.HttpPutResponseHopLimitbreaks containers. The default of 1 means the packet doesn't survive the container network's extra hop, so the team bumps it to 2 to make the pod work, and that reopens the metadata endpoint to any process in the container, and if the workload needs a credential, the right path is IRSA or Workload Identity, not raising the hop limit.- An SSRF that controls method or headers defeats v2. An endpoint that accepts
{"method": "PUT", "headers": {...}}in its webhook, or agopher://primitive, can carry out the two-step flow, and IMDSv2 raises the cost, it doesn't zero it out.
And the defense that doesn't depend on any of these checkboxes: block the route to 169.254.169.254 from inside every container that doesn't need it, a network rule is easier to audit than the configuration matrix of thirty templates.
Real problem #6: the URL fields nobody calls a URL field
If you search for url in the API schema, you'll find half the cases, and the other half is what makes this bug last.
- HTML-to-PDF rendering. A generator for boletos, contracts, statements: the engine fetches
<img src>,<link>, and sometimes follows<iframe>, and HTML going into the generator is a URL going into the server. - Image processing. ImageMagick with delegates enabled fetches URLs; a user-uploaded SVG carries an external reference and, depending on the renderer, an XML entity.
- XML of any kind. XXE and SSRF are neighbors: an external entity is an outbound request, and in fintech this shows up in NF-e (electronic invoice) integration, bank return files, and SAML.
jwks_uriand OIDC discovery. If the product lets the client configure an identity provider, it hands you a URL the server will fetch on every token validation, and this is SSRF that's authenticated, recurring, and dressed up as legitimate configuration.- SAML metadata by URL. Same story, with the added problem of being configured once and never reviewed again.
- Integration with "your system's URL." ERP callbacks, a client's reconciliation endpoint, a payment status notification URL, and in a financial API this is the single most common field of all.
- Internal proxies and shorteners. A
GET /proxy?url=...someone built to work around CORS on the frontend, it's SSRF, named, documented, and with a legitimate use case. - Import by link. A billing spreadsheet, a payment batch, a return file, and its worst cousin: an import that accepts
file://because the same code also serves local uploads.
The exercise is worth doing: list every point in the system where the server makes an outbound request whose destination depends on something a user sent, if the list has one item, you have one control to write, and if it has twelve, you need a centralized HTTP client, because twelve independent validation implementations will diverge.
Real problem #7: how I prove it without scanning anyone's network
Blind SSRF is unsettling because the server fetches the URL but never shows you the response, you need to confirm the outbound request happened without ever seeing the body, here's the clean, non-destructive way:
- Stand up a collector you control. A logging endpoint on a domain you own, or an interaction service authorized by the scope, note down a unique URL, with a token in the path to correlate it with the request you sent.
- Put that URL in the suspect field.
webhook_url,avatar_url,import_from,jwks_uri, whatever the endpoint accepts. - Wait for the hit. If the server reaches your collector, you have confirmed SSRF: the request originated from inside, the source IP in your log is the target's own egress, not yours.
The decision table:
| Signal at the collector | Reading |
|---|---|
| HTTP, target's egress IP | SSRF confirmed |
| DNS only, no HTTP | resolves but doesn't connect, half a hole |
| nothing, immediate error | likely input validation |
| nothing, long timeout | may be trying to reach something |
The "DNS only" case deserves attention, because it usually gets read as "not vulnerable," and that's wrong, it means something resolved the name, almost always the validation step itself, and that already confirms client-controlled resolution exists, which is the prerequisite for rebinding, and it already hands you a DNS exfiltration channel whenever there's any data to embed in the subdomain.
What closes out the finding without escalating anything:
- A negative control. Send a URL for a domain that doesn't exist and confirm there's no hit, without that, you can't tell your own traffic apart from some random scanner that happened to hit your collector.
- Correlation by unique token. A different path per attempt, to tie every hit back to the exact request that generated it, that's what survives the question "how do you know it was our server?"
- Log the source IP and the User-Agent. The User-Agent almost always gives away the HTTP library in use, and the library in use tells you which bypasses are plausible,
python-requests/2.xandGo-http-client/1.1have different redirect behaviors.
Notice what this test does not do: it doesn't point at production's 169.254.169.254, it doesn't scan the client's internal network, it doesn't try to extract a credential, it demonstrates the primitive, "your server fetches an arbitrary URL I supply," against infrastructure I control, and proving the flaw exists doesn't require exploiting the worst case, and in a serious report, the demonstrated primitive plus a description of the maximum impact is worth more than a stolen credential obtained outside the scope.
You prove the capability without steering the damage, you describe the maximum impact; you demonstrate the existence against yourself.
Bonus: SSRF that leaves your network and enters someone else's
A consequence that almost never makes it into the threat model, and that on a payment platform is contractual before it's technical.
Your server has a reputation: the egress IP is yours, the certificate is yours, the User-Agent carries your product's name, when someone uses your webhook feature to hit a third party, you're the one who shows up in the victim's logs, and that turns into three problems at once: your IP lands on a blocklist, your legal team gets a notice, and your support team finds out on Twitter.
Add amplification on top of that: an endpoint that accepts a URL and can be called for free is a reflector, if an attacker can trigger a thousand of your requests against a target, they've outsourced their traffic to your infrastructure, on your network bill.
It's worth two cheap controls almost nobody bothers with: a per-origin rate limit on the outbound HTTP client, and an identifiable User-Agent with a contact endpoint, so the victim on the other end knows who to complain to before blocking your entire range.
What to do on Monday
No generic checklist, six questions that, answered honestly, tell you the size of your problem:
- How many places in the system make an outbound request to a destination chosen by a user? If you can't list them, that's the first job, and it's an inventory job, not a coding one.
- Is there a single HTTP client for this traffic, or did every team write their own? N implementations mean N different validations, and one of them is wrong.
- Do validation and connection use the same IP, resolved once? If the code validates a string and passes that same string to the library, you have both TOCTOU and a parser differential at the same time.
- What happens with a
302to169.254.169.254? If nobody knows, test it today against your own environment, it's the easiest and most common bypass. - Do all instances require IMDSv2, and does no container have a hop limit of 2 without needing it? A single query against your inventory answers this, and the result is usually uncomfortable.
- Does outbound traffic generated by user content leave through a route with no access to the internal network? If it leaves through the same route as everything else, your only defense is validation, and section 2 gave five reasons not to trust that alone.
You don't control what the client types, you control where your server connects, and the entire security model lives in that second verb.
References
- OWASP API Security Top 10 (2023) - API7:2023 Server Side Request Forgery - the entry dedicated to the problem in APIs, with webhook scenarios.
- OWASP Top 10 (2021) - A10:2021 Server-Side Request Forgery - the category that made it into the Top 10 by community vote, not by data.
- OWASP SSRF Prevention Cheat Sheet - a breakdown by use case and the difference between application-level and network-level allowlists.
- CWE-918 - Server-Side Request Forgery (SSRF) - the identifier to cite in a report.
- PortSwigger Web Security Academy - SSRF - free labs, including the blocklist bypasses and the blind case.
- Orange Tsai - A New Era of SSRF: Exploiting URL Parsers - Black Hat USA 2017, the reference on divergence between parsers.
- AWS - Use IMDSv2 - the token flow via
PUT,HttpTokens: required, and the effect of the hop limit. - GCP - Metadata server - the required
Metadata-Flavor: Googleheader. - Azure - Instance Metadata Service - the required
Metadata: trueheader. - Capital One - 2019 incident analysis - the public case of SSRF chained with metadata and S3.
- RFC 3986 - Uniform Resource Identifier: Generic Syntax and WHATWG URL Standard - the two specifications that explain why two parsers disagree.