July 14, 2026
Server-Side Request Forgery: Exploiting and Fixing the SSRF Vulnerability
SSRF (Server-Side Request Forgery) makes your own server send requests you never intended, all the way into your internal network. A…

By Arthur Monney
9 min read
SSRF (Server-Side Request Forgery) makes your own server send requests you never intended, all the way into your internal network. A feature as mundane as a "URL" field is enough to open the breach. This article shows how the vulnerability works, how it's exploited in practice, and how to fix it layer by layer.
The context
For the past few weeks, I've been building an addon for Shopper, a headless e-commerce solution built on Laravel. The addon's goal: migrate an existing store to Shopper from any e-commerce platform (Shopify, PrestaShop, Magento, WooCommerce, and so on). I designed it as multi-source from day one, with a generic connector layer where each platform provides its own implementation of a shared interface. In short: one contract, several drivers.
The first connector I shipped targets WooCommerce. On paper it's trivial: the merchant enters their store URL, an API key and secret, and the connector queries the WooCommerce REST API to pull products, categories, variants, images and customers before recreating them on the Shopper side.
The HTTP client at the heart of that driver looked like this:
final class WooCommerceClient
{
public function __construct(
private string $storeUrl,
private string $consumerKey,
private string $consumerSecret,
) {}
public function testConnection(): Response
{
return Http::withBasicAuth($this->consumerKey, $this->consumerSecret)
->get("{$this->storeUrl}/wp-json/wc/v3/products", ['per_page' => 1]);
}
}final class WooCommerceClient
{
public function __construct(
private string $storeUrl,
private string $consumerKey,
private string $consumerSecret,
) {}
public function testConnection(): Response
{
return Http::withBasicAuth($this->consumerKey, $this->consumerSecret)
->get("{$this->storeUrl}/wp-json/wc/v3/products", ['per_page' => 1]);
}
}On the UI side, a Livewire component that passed the merchant-supplied URL straight into the client's constructor:
public function testConnection(): void
{
$client = new WooCommerceClient(
storeUrl: $this->storeUrl, // user-supplied
consumerKey: $this->consumerKey,
consumerSecret: $this->consumerSecret,
);
$client->testConnection();
}public function testConnection(): void
{
$client = new WooCommerceClient(
storeUrl: $this->storeUrl, // user-supplied
consumerKey: $this->consumerKey,
consumerSecret: $this->consumerSecret,
);
$client->testConnection();
}Nothing fancy so far. It worked, the tests were green, I was pleased with myself.
Before cutting a release, I ran an architecture and security review over the whole addon with Claude Code. Among the findings, one line caught my eye:
SSRF: the server makes an HTTP request to a URL entirely controlled by the user. An attacker can target internal services.
My first reaction: "SSRF? You mean CSRF, right?"
Well, no. And that's exactly what this article is about.
CSRF vs SSRF: what's the difference?
The two acronyms look almost identical, but they attack opposite things.
Laravel has covered me against CSRF forever without me thinking about it: the **VerifyCsrfToken** middleware, the **@csrf** directive, the token checked on every POST. Built in, transparent, you end up forgetting it's even running.
SSRF, on the other hand, has no middleware handling it by default, and that's logical: the framework can't guess that this particular outgoing request is hostile. It's your business code that decides to go fetch a user-supplied URL. The safeguard is yours to write.
What an SSRF actually is
Let's start from the definition F5 lays out in its SSRF glossary:
"A security flaw that occurs when an attacker manipulates a web application or API into making requests to internal resources."
The word that matters: server-side. Normally, when a user wants to reach a resource, it's their browser that sends the request, and that browser is stuck outside, in front of your firewall. With an SSRF, the request comes from your server. And your server runs inside the network, with direct access to internal services.
Look at the typical infrastructure of a production app:
Internet --[ firewall ]--> Laravel app --+--> 169.254.169.254 (cloud metadata)
+--> 10.0.0.5:6379 (internal Redis)
+--> 10.0.0.8:5432 (PostgreSQL)
+--> 10.0.0.9/admin (internal dashboards)Internet --[ firewall ]--> Laravel app --+--> 169.254.169.254 (cloud metadata)
+--> 10.0.0.5:6379 (internal Redis)
+--> 10.0.0.8:5432 (PostgreSQL)
+--> 10.0.0.9/admin (internal dashboards)The firewall blocks traffic coming from the Internet toward the internal network. But your app talks to the internal side all day long, that's its job. So if an attacker controls the URL it calls out to, they use your server as a proxy to cross that firewall. F5 puts it well:
"The request is made from the server's perspective, not the user's browser, which allows bypassing the protections offered by firewalls and VPNs."
In security, this is the confused deputy pattern: a trusted component, here your server, performs an action on behalf of someone who had no right to do it themselves.
How my addon could be exploited
Back to that "store URL" field. Zero validation. The value went straight into an outgoing HTTP request made by the server. Here's what an attacker could do with it.
Steal cloud credentials
The scenario that really hurts. On AWS, GCP or Azure, every instance exposes a metadata service on a fixed IP that isn't routable from the outside: 169.254.169.254. On AWS, that endpoint serves the instance's temporary IAM credentials:
http://169.254.169.254/latest/meta-data/iam/security-credentials/http://169.254.169.254/latest/meta-data/iam/security-credentials/All an attacker had to do was paste this into my "store URL" field:
http://169.254.169.254/latest/meta-data/iam/security-credentials/http://169.254.169.254/latest/meta-data/iam/security-credentials/My server would query that endpoint from its privileged position and hand me back, potentially, the instance's AWS access keys. With those keys the attacker moves on: S3 buckets, databases, anything the IAM role allows.
Not theoretical. It's the exact mechanism behind the Capital One breach of 2019: an SSRF read the IMDS credentials, which opened access to more than 100 million customer records. All because of one unvalidated outgoing request.
Scan and reach internal services
Even without a cloud, the attacker targets the machine itself or the local network:
http://localhost:6379 -> talks to Redis in cleartext
http://127.0.0.1:9200 -> internal Elasticsearch
http://10.0.0.9/admin -> an internal dashboard with no external auth
http://192.168.1.1 -> the router's interfacehttp://localhost:6379 -> talks to Redis in cleartext
http://127.0.0.1:9200 -> internal Elasticsearch
http://10.0.0.9/admin -> an internal dashboard with no external auth
http://192.168.1.1 -> the router's interfaceEach entry becomes a request my server runs from the inside, under the firewall that was supposed to isolate those services.
The "I don't return the response body" trap: blind SSRF
The comforting thought: "Either way I don't display the response body, just connection succeeded or failed." I had it. False sense of security.
F5 lists three variants, and two of them work fine without the response:
-
Standard SSRF. The response comes back to the attacker. The worst case.
-
Blind SSRF. The response is never shown, but an error message that changes leaks the information. "Failed" on a closed port versus "unexpected response" on an open port, and the attacker maps the internal network, service by service.
-
Time-based blind SSRF. Even without an exploitable message, a host that answers in 5 ms versus a 30 s timeout gives away what exists. They read the network with a stopwatch.
My little "Test connection" button, which returned nothing more than success, failed or unreachable, was already enough to mount a blind SSRF. The "it doesn't return anything sensitive" argument no longer held.
How to fix it: layered validation
Good news on the implementation side: everything went through a single choke point. All my outgoing requests were built in the same client method. One place to hook the validation in. Here's how I built it, layer by layer.
Layer 1: allow HTTPS only
An attacker won't necessarily stick to http://. Depending on the HTTP client, schemes like file://, gopher://, dict:// or ftp:// open up other abuses: reading local files, forging requests to other protocols. We lock it down with an allowlist, here HTTPS only.
if (! str_starts_with(strtolower($url), 'https://')) {
throw new MigrationException('The store URL must use HTTPS.');
}if (! str_starts_with(strtolower($url), 'https://')) {
throw new MigrationException('The store URL must use HTTPS.');
}Layer 2: reject private and reserved IPs
The core of the defense against direct attacks. PHP has exactly the right tool, filter_var with two flags:
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
if (filter_var($host, FILTER_VALIDATE_IP)) {
$isPublic = filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if (! $isPublic) {
throw new MigrationException("Forbidden host: {$host}.");
}
}$host = strtolower(parse_url($url, PHP_URL_HOST) ?? '');
if (filter_var($host, FILTER_VALIDATE_IP)) {
$isPublic = filter_var(
$host,
FILTER_VALIDATE_IP,
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
);
if (! $isPublic) {
throw new MigrationException("Forbidden host: {$host}.");
}
}FILTER_FLAG_NO_PRIV_RANGE clears the private ranges: 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. FILTER_FLAG_NO_RES_RANGE clears the reserved ones, which includes the loopback 127.0.0.0/8 and, crucially, 169.254.0.0/16.
That second flag is the key piece: 169.254.169.254 falls in the reserved range, so the cloud metadata endpoint is blocked outright. The most dangerous attack drops with a single line.
Layer 3: block internal hostnames
A literal IP isn't the only way in. localhost and the usual internal DNS suffixes must be refused too:
if ($host === 'localhost'
|| str_ends_with($host, '.localhost')
|| str_ends_with($host, '.local')
|| str_ends_with($host, '.internal')) { // e.g. metadata.google.internal
throw new MigrationException("Forbidden host: {$host}.");
}if ($host === 'localhost'
|| str_ends_with($host, '.localhost')
|| str_ends_with($host, '.local')
|| str_ends_with($host, '.internal')) { // e.g. metadata.google.internal
throw new MigrationException("Forbidden host: {$host}.");
}Layer 4: don't follow redirects blindly
The trap that struck me the most, because it short-circuits everything above. An attacker supplies a perfectly clean URL:
https://my-trap-store.com <- passes all validationhttps://my-trap-store.com <- passes all validationExcept the server behind that URL answers with:
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/...HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/...By default, most HTTP clients, including Guzzle under the hood of Laravel's Http facade, follow redirects. Your check validated the starting URL, then Guzzle followed the 302 to the internal target. Your entire initial validation just went out the window.
The fix: re-validate every redirect hop and forbid any downgrade to http.
Http::baseUrl($this->storeUrl)
->withOptions([
'allow_redirects' => [
'max' => 5,
'strict' => true,
'protocols' => ['https'], // no downgrade to http
'on_redirect' => function ($request, $response, $uri): void {
// Re-run the exact same validation on the destination URL.
$this->assertSafeUrl((string) $uri);
},
],
])
->get('/wp-json/wc/v3/products');Http::baseUrl($this->storeUrl)
->withOptions([
'allow_redirects' => [
'max' => 5,
'strict' => true,
'protocols' => ['https'], // no downgrade to http
'on_redirect' => function ($request, $response, $uri): void {
// Re-run the exact same validation on the destination URL.
$this->assertSafeUrl((string) $uri);
},
],
])
->get('/wp-json/wc/v3/products');By pulling the validation into an assertSafeUrl() method, I replay it in the constructor and on every hop, through Guzzle's on_redirect callback. The store can no longer bounce the server toward an internal target.
The honest residual: DNS rebinding and IP pinning
Let's be clear, otherwise it'd be dishonest. The layers above stop all direct attacks: hard-coded internal IP, localhost, booby-trapped redirect. That's by far the most common case, the one in F5's example.
One nastier vector remains: DNS rebinding. The attacker owns a domain trap.com which, at the moment you validate it, resolves to a public IP. So it passes validation. But between your check and the actual connection, they flip their DNS record to 169.254.169.254. You validated a name, but the client connects to an IP resolved afterward. This is a TOCTOU, Time-Of-Check to Time-Of-Use.
The only complete fix: resolve the DNS yourself at connection time, validate the resulting IP, then pin it so the connection goes exactly there, via curl's CURLOPT_RESOLVE option. Heavier, and only worth it if the feature is exposed to untrusted users.
In my case, the migration is locked behind authentication and a dedicated permission (migrator.create): to reach it, you already have to be a logged-in administrator. The cost-benefit ratio didn't justify IP pinning. So I documented it in the code, with the upgrade path, for the day the threat model changes. That, to me, is proportionate security: close what's actually reachable, and document the rest instead of pretending it doesn't exist.
Don't just close the app: defense in depth
Application-level validation is your first line, not your only one. F5 stresses complementary layers that hold regardless of your stack.
-
Network segmentation and egress proxy. An egress firewall that forbids your application servers from reaching
169.254.0.0/16and the private ranges. Even if an SSRF gets past the app, it hits the network wall. -
IMDSv2 on AWS. Version 2 of the metadata service requires a session token fetched with a
PUTrequest and limits the network hop count. Most SSRFs, which can only issue aGET, are neutralized. Turn it on. -
WAF. An application firewall filters known malicious URL patterns.
-
Least privilege. If the instance's IAM role has access to nothing sensitive, stealing its credentials is barely worth anything.
An SSRF that has to get past the app, then the network, only to land on a role with no privileges at all, is worth nothing anymore. That's the whole point of stacking layers instead of betting everything on one.
The real lesson: does my server fetch a user-supplied URL?
If there's one question to take away from this review, it's that one. SSRF doesn't only concern migration connectors. The moment your server makes a network request to an address, a host or a URL that comes, even indirectly, from a user, you have a potential SSRF on your hands.
Run your projects through that filter. The features involved are far more numerous than you'd think:
- Webhooks the user configures ("call this URL when this event happens")
- Import an image or file by URL** ("paste your avatar link").
- Link previews, like Slack unfurling a URL pasted in a message.
- PDF or screenshot generators via a headless browser. Those are particularly dangerous: a browser follows redirects and executes JavaScript.
- SSO or SAML metadata URLs to fetch.
- RSS readers, aggregators and oEmbed.
- Any proxy or endpoint of the "go fetch this resource for me" kind.
For each one, the same checklist:
- Restrict the scheme with an allowlist, HTTPS and possibly HTTP.
- Reject private and reserved IPs with
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE. - Reject internal hostnames like
localhost,.localor.internal. - Re-validate redirects and forbid downgrading to HTTP.
- If the surface touches untrusted users, resolve then pin the IP at connection time.
- Add network-level defense in depth: egress proxy, IMDSv2, least privilege.
The best move is to pull that validation into a reusable component, a SafeUrlValidator or a dedicated Laravel validation rule, and wire it in everywhere a user URL turns into an outgoing request. Written once, reused everywhere.
Epilogue
I've been coding for years. CSRF, SQL injection, XSS, I had those covered. SSRF, though, was a blind spot: one of those risks you don't know because you've never run into them, and never run into because you're not looking for them.
What struck me most wasn't the vulnerability, it was how I found it. A code review pointed at a line I'd been re-reading for weeks without seeing anything wrong. Not because a tool is "smarter" than me, but because it didn't share my blind spot. It put a name on a risk I couldn't name, and that name let me go read, understand, then fix it knowingly, instead of pasting a magic patch I didn't understand.
Maybe that's where the real value of these tools lies: not replacing your judgment, but shining a light on what your experience hasn't taught you to see yet.
If this article introduced you to SSRF the way that review introduced it to me, do one thing. Open your project, find the next place where your server fetches a URL a user handed it, and ask yourself the question.
Further reading: F5, Server-Side Request Forgery, OWASP, Server-Side Request Forgery, PHP, filter_var flags.