Almost every application I've looked at fetches a user-supplied URL somewhere. Webhooks, link previews, avatar imports, "import from URL" buttons, PDF renderers that helpfully follow image tags. Completely normal feature, and the code is usually one line, which is a good part of the problem. Nobody reviews one line.
Let's build one, break it, then watch every obvious fix fail in turn.
The feature
We're adding link previews to a comments box. Someone pastes a URL, we fetch it, pull the title out, show a nice card. The first version writes itself.
class LinkPreviewController extends Controller
{
public function store(Request $request)
{
$request->validate([
'url' => ['required', 'url'],
]);
$html = Http::timeout(5)->get($request->input('url'))->body();
preg_match('/<title>(.*?)<\/title>/s', $html, $matches);
return response()->json([
'title' => $matches[1] ?? 'Untitled',
]);
}
}That sails through code review. There's a validation rule, there's a timeout, we only return the title. Laravel's url rule even confirms it's a well-formed URL.
It's just answering a different question than the one that matters. url checks that the string parses. It has nothing to say about where the request goes, and our server sits somewhere very different on the network than the person typing into the box.
Pointing it inwards
An attacker submits this:
{ "url": "http://169.254.169.254/latest/meta-data/iam/security-credentials/" }169.254.169.254 is the cloud instance metadata endpoint. Link-local, reachable only from the instance itself, and on an EC2 box still allowing IMDSv1 there's no authentication on it at all. It returns the name of the attached role:
app-server-roleAppend that name to the same path and you get a JSON blob containing AccessKeyId, SecretAccessKey and Token. Live credentials for whatever that role can do.
Our particular controller only returns <title>, and IMDS responds with plain text, so this exact code hands back Untitled rather than the keys. That's luck, not a defence. Preview features cache the fetched image, or store the description, or return $e->getMessage() when the fetch fails, and any one of those turns the same bug into direct credential disclosure. The distance between "leaks nothing" and "leaks your IAM keys" is one product decision made by someone who wasn't thinking about SSRF, because why would they be.
That exact chain is the Capital One breach. In 2019 Paige Thompson found an SSRF in a misconfigured ModSecurity WAF on EC2 and requested /latest/meta-data/iam/security-credentials/ISRM-WAF-Role. That role had read access across the S3 estate, so the rest was just the AWS CLI: aws s3 ls enumerated around 700 buckets, aws s3 sync copied them down, and roughly 106 million credit card applicants lost their data. A firewall appliance handed over the keys to the business because it would fetch a URL for you.
Metadata is only the famous target, mind you. Anything speaking HTTP inside the network is fair game: admin panels that skip auth because "it's internal", Elasticsearch on 9200, the Kubernetes API, a /metrics endpoint quietly serving up connection strings. None of it expects to be attacked from the app server, which in practice means none of it is defended against the app server.
Attempt one, block localhost
The obvious patch:
$host = parse_url($request->input('url'), PHP_URL_HOST);
if (in_array($host, ['localhost', '127.0.0.1'])) {
abort(422, 'Nope.');
}This stops precisely one attacker: the one who tries 127.0.0.1, gets a 422, and wanders off. Because it turns out that address has a fair few spellings:
http://0177.0.0.1/ octal
http://2130706433/ 32-bit integer
http://127.1/ inet_aton shorthand
http://0/ INADDR_ANY on most stacks
http://[::1]/ IPv6 loopback
http://[::ffff:127.0.0.1]/ IPv4-mapped IPv6Every one of those reaches the loopback interface. Not one of them string-matches 127.0.0.1. And while we were busy with that list, note that we still haven't blocked 169.254.169.254, which was the actual attack.
Attempt two, block the ranges
Fine. We stop playing string bingo and parse the address properly, checking it against the private ranges. That's a real improvement, and it kills every encoding above at once.
Then the attacker stops giving us an IP. They register preview-test.attacker.com, point an A record at 127.0.0.1, and submit that. Our check sees a hostname on a perfectly ordinary public domain. Guzzle resolves it at connection time and dials loopback.
So the check has to happen after resolution, against the address we'll actually connect to:
$ip = gethostbyname($host);
if ($this->isPrivate($ip)) {
abort(422);
}
$html = Http::get($url)->body(); // resolves again, independentlyLook closely at those two lines though. We resolved the name, checked the IP, and then handed the hostname to Guzzle, which resolved it all over again. Two lookups. Nothing guarantees they return the same answer.
That gap is DNS rebinding, and if it sounds theoretical, it is the exact bug that took down a ByteDance product.
In 2023 a researcher found an SSRF in LarkSuite, the Slack competitor. Lark's wiki import read a Confluence export and fetched every image referenced inside it. They had already done the homework: internal IPs blocked, 169.254.169.254 blocked, submitting it directly went precisely nowhere.
So the researcher pointed the image URLs here instead:
8efb23ae.a9fea9fe.rbndr.usBoth labels are hex-encoded IP addresses. Decode them:
a9fea9fe -> 169.254.169.254
8efb23ae -> 142.251.35.174 (a public Google IP)That's Tavis Ormandy's rbndr service, which replies with one of the two encoded addresses at random on a very low TTL. I resolved it eight times while writing this and got the Google IP twice and the metadata IP once before Google's resolver got bored of me. Same hostname, different answer each time.
Lark's check resolved the name, saw a nice public Google IP, approved it. Guzzle's equivalent resolved the same name a moment later and got 169.254.169.254. About ten import attempts to win the coin flip, and the AWS credentials came back as downloadable attachments on the wiki page.
Nothing was smuggled past the filter here. The check was correct, about an answer that had already expired by the time it mattered. Any validation that resolves a hostname and then hands the hostname to an HTTP client has this hole, which includes basically every "block internal IPs" helper I've watched get pasted into a Laravel project. The only fix is to resolve once and connect to that exact IP, keeping the original Host header so the far end still routes properly.
There's also the redirect problem sitting underneath all of this. Even with perfect validation on the submitted URL, an attacker serves a 302 from a domain that passes every check:
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/Guzzle follows redirects by default. We validated the first URL and never looked at the second.
You are probably not the exception
At this point the natural instinct is to write the correct version yourself. It's maybe forty lines. Before you do, look at what happened when someone checked whether anyone actually gets it right.
SSRF vs. Developers (USENIX Security '24, by Malte Wessels, Simon Koch, Giancarlo Pellegrino and Martin Johns) analysed 27,078 open-source PHP applications hunting for exactly this bug. They found 237 places where attacker-controlled input reached a server-side request. The hypermajority were vulnerable. Two applications in the entire set implemented an SSR feature they'd call safe.
Two. Out of twenty-seven thousand.
Their Table 2 lists the three ways a validation layer gets evaded, and the fix for each:
- URL parser confusion, fixed by using a hardened parser. PHP's
parse_urland the parser inside your HTTP client disagree about enough edge cases that a check written against one can be walked past by aiming at the other. - DNS rebinding, fixed by IP pinning. They don't hedge: pinning is "the only reliable defense against attacks targeting local resources without unduly restricting the versatility of the SSR feature".
- Redirects, fixed by rechecking every hop, or refusing to follow them.
We hit all three above. Parser confusion is the encoding table, rebinding is what got Lark, redirects are the 302 we just looked at. The paper also covers the quiet variant, where the attacker never reads a response and just times it: a preview endpoint that answers in 6ms for a live internal host and hangs the full 5 seconds on a dead one is a working port scanner even when it leaks nothing else. I measured that on the controller above and the three cases are trivially distinguishable.
That's a lot of sharp edges for what began as one line of Http::get(). I'd rather it lived in one place with tests around it than got half-reinvented in every project, so I put it in a package.
The package
securized/laravel-ssrf validates the URL before the request leaves the app. Our controller becomes:
$html = Http::ssrf()->timeout(5)->get($request->input('url'))->body();ssrf() returns a normal PendingRequest, so it chains with everything else, and a blocked request throws GuzzleHttp\Exception\RequestException, the same exception Guzzle throws for any other failure. Existing error handling keeps working.
That one call covers every attempt above. Alternate encodings are normalised before checking, so the octal and integer forms of loopback are blocked along with the plain one. Validation happens after DNS resolution against the resolved address. Redirects are covered too, since Guzzle re-enters the middleware on each hop, so the 302 to 169.254.169.254 gets blocked even though the URL we passed in was fine.
If you'd rather reject the URL at the point it's submitted, there's a validation rule:
$request->validate([
'url' => ['required', 'url', new SsrfSafeUrl()],
]);The failure message is deliberately vague. The underlying exception says things like resolves to 10.0.4.12 which matches blacklisted range 10.0.0.0/8, which is a description of your internal network and not something to hand back to whoever submitted the form.
For the rebinding window, turn on DNS pinning:
'pin_dns' => true,The hostname is then resolved once, that IP is validated, and the request goes to the IP directly with the original Host header intact. It's off by default because it can interfere with certificate validation in some setups, but if you're accepting URLs from untrusted users it's worth the trade.
One thing it deliberately doesn't do is chase second-order requests. If you fetch a page, parse a link out of the body and then request that link, the second request needs its own validation. Same for anything you queue up for later based on fetched content.
Every attack in this post is in the test suite. All six loopback encodings, the rebinding race, the redirect hop, the parser disagreements between parse_url and Guzzle's URI, plus file:// and gopher://. I wrote them by working through the paper's attack table and turning each row into a test, so the three evasion techniques it documents are covered rather than assumed. There's also an integration suite that stands up a real server and asserts blocked requests never open a socket, because proving the validator threw an exception only proves the validator threw an exception.
Install
composer require securized/laravel-ssrf
php artisan vendor:publish --tag="ssrf-config"Out of the box it blocks RFC 1918 ranges, loopback, link-local (so cloud metadata), and every scheme except http and https.
That last one carries more weight than it looks. file:///etc/passwd reads the filesystem through something that thought it was fetching an image, and gopher:// lets an attacker write arbitrary bytes to an arbitrary port, which is the classic route from an SSRF to talking directly at whatever daemons are listening on localhost.
auto_protect is off by default. Flipping it on protects every Http:: call in the app, which sounds like what you want until it breaks the service legitimately calling an internal API on a private IP, and that's most apps with more than one moving part. Turn it on if all your outbound traffic is meant to be external. Otherwise use Http::ssrf() on the calls that take user input, which are the ones that matter anyway.
Source and issues are on GitHub.