Originally published on Kriosa
How server-side URL fetching can expose internal services and how to build these features without turning your server into an attacker's proxy.
Why this is everywhere now
Ten years ago SSRF was a niche finding buried in the appendix of a pentest report. Today it's one of the highest-impact bugs in the OWASP Top 10, and the reason is boring: modern apps fetch URLs on the user's behalf, constantly.
- Webhooks — "give us a URL and we'll POST to it when something happens."
- Image proxies / avatar uploaders — "paste a link to your profile picture."
- PDF and screenshot generation — "render this URL as a PDF/image for the user."
- Link previews — "paste a link in chat and we'll show a rich preview card."
Every one of these features has the same shape: a user supplies a URL, and the server not the user's browser fetches it. That's the entire vulnerability class in one sentence. The server has network access the user doesn't: internal services, admin panels, databases on private IPs, and — on every major cloud provider a metadata endpoint that hands out credentials to anyone who can reach it.
A previous piece on this account touched on SSRF only in passing, as a side note inside a discussion of insecure deserialization. It deserves its own treatment, because it doesn't require deserializing anything, doesn't require a memory-corruption bug, and doesn't even require authentication in a lot of real-world cases. It just requires a text box that accepts a URL and a server willing to fetch it.
The attack narrative
Say your app has an image proxy: paste a URL, the server fetches it, resizes it, and serves it back. The naive implementation:
// DON'T DO THIS
$url = $_GET['url'];
$image = file_get_contents($url);
header('Content-Type: image/jpeg');
echo $image;
This works perfectly for https://example.com/cat.jpg. It also works perfectly for:
http://169.254.169.254/latest/meta-data/iam/security-credentials/
169.254.169.254 is the cloud instance metadata endpoint — on AWS, GCP, Azure, and most others, any process on the instance (including, in this case, your web server acting on the attacker's behalf) can query it without authentication. On AWS specifically, that endpoint can hand back temporary IAM credentials for whatever role the instance is running as. If that role has S3, RDS, or Lambda permissions, the attacker didn't just read an image — they now have working AWS credentials, exfiltrated through your image proxy, without ever touching your app's actual authentication.
Even without a cloud metadata endpoint in play, the same request can be pointed at:
http://localhost:6379/ # an unauthenticated Redis instance
http://192.168.1.50:8080/admin # an internal admin panel with no auth wall,
# because "it's internal, nobody can reach it"
http://10.0.0.5:5432/ # a database port, for banner-grabbing / probing
None of this requires the attacker to be on your network. It requires your server to make one HTTP request on their behalf, to an address they chose.
Where SSRF actually hides
It's rarely the obvious "image proxy" example in a real codebase. It's usually one of these, added months apart by different people, none of whom were thinking about the other three:
- Webhook registration — a user configures a callback URL, and your job queue fetches it later, with no user present to notice anything odd about where the request goes.
-
PDF/screenshot generation — "render this invoice as a PDF" often means spinning up a headless browser and pointing it at a URL. A headless Chrome instance following
window.locationredirects is a full SSRF engine with a UI. - Link unfurling — chat apps, note-taking tools, and CMSs that show a title/image/description for a pasted link all fetch that link server-side, unauthenticated, the instant it's pasted — often before the user even sends the message.
- "Import from URL" — CSV imports, avatar-from-URL, "sync this doc from a link" — any feature phrased as fetch this and do something with it is a candidate.
If you can grep your codebase for curl_exec, file_get_contents(, Http::get(, GuzzleHttp\Client, or a headless-browser goto()/->visit() call and find one where the URL traces back to user input, you've found a candidate.
Why the obvious fixes don't work
The first instinct is usually a blocklist: reject localhost, 127.0.0.1, and private IP ranges. This is necessary but nowhere near sufficient, and it's worth walking through why, because every bypass here has shown up in real bug bounty reports.
Encoding tricks. 127.0.0.1 has a lot of equivalent spellings a naive string-match blocklist won't catch:
http://2130706433/ # decimal IP encoding of 127.0.0.1
http://0x7f000001/ # hex encoding
http://0177.0.0.1/ # octal encoding
http://127.1/ # shorthand form
http://[::ffff:127.0.0.1]/ # IPv4-mapped IPv6
Redirects. Even if the initial URL passes validation, an attacker-controlled server can respond with a 302 to an internal address:
GET /redirect-me HTTP/1.1
Host: attacker.com
HTTP/1.1 302 Found
Location: http://169.254.169.254/latest/meta-data/
If your HTTP client follows redirects by default (most do, including Guzzle and cURL), you validated a URL that was never actually fetched — the one that got fetched was chosen by the attacker's server after your check ran.
DNS rebinding. This is the one that breaks "resolve the hostname, check the IP, then fetch it" logic specifically. An attacker sets up a domain with a very short DNS TTL. The first resolution — the one your validation code sees — returns a public, harmless IP. By the time the actual HTTP client re-resolves the same hostname a moment later to make the connection, the DNS record has changed to point at 169.254.169.254 or an internal address. The check and the fetch ran against two different IPs for the same hostname, because DNS resolution isn't pinned between them.
None of these are exotic. They're the standard toolkit, and a blocklist-only defense falls to at least one of them almost every time.
Building it correctly
1. Prefer an allowlist over a blocklist wherever the feature allows it. If your image proxy only ever needs to fetch from a handful of known CDNs, allowlist those hostnames explicitly. This closes the entire class of bypass above in one step, because there's no "everything except X" logic to route around.
2. When an allowlist isn't possible, validate the IP you're actually connecting to — not the hostname you started with.
function isBlockedIp(string $ip): bool
{
// Reject private, loopback, link-local, and reserved ranges —
// this must cover IPv6 equivalents too, not just IPv4.
$blocked = [
'127.0.0.0/8', '10.0.0.0/8', '172.16.0.0/12', '192.168.0.0/16',
'169.254.0.0/16', // link-local — this is what covers the cloud
// metadata endpoint on AWS/GCP/Azure
'::1/128', 'fc00::/7', 'fe80::/10',
];
foreach ($blocked as $range) {
if (ipInRange($ip, $range)) {
return true;
}
}
return false;
}
3. Resolve the hostname, validate the resulting address, and make sure the HTTP client actually connects using that validated address — not a fresh resolution of its own. This is the part that closes DNS rebinding: the gap isn't "did you check the IP," it's "did the connection use the IP you checked." A simple gethostbyname() call is a starting point, but it isn't the whole fix on its own — it only returns an IPv4 address, so it does nothing for IPv6, and calling it doesn't guarantee anything about which address your HTTP client ends up connecting to if that client performs its own independent DNS lookup afterward. In production, use an HTTP client or network layer that lets you control DNS resolution directly, or otherwise pin the destination for the connection — for example, a custom resolver/handler in your HTTP client, or a curl CURLOPT_RESOLVE mapping — so the same address you validated is the one actually opened.
4. Disable automatic redirect-following, or validate every hop. With Guzzle:
$client->request('GET', $url, [
'allow_redirects' => false, // handle redirects yourself, re-validating
// the Location header through the same
// isBlockedIp() check before following it
]);
5. Restrict scheme and port. file://, gopher://, and dict:// handlers in some HTTP libraries can be abused for local file reads or protocol smuggling if a URL parser accepts them at all. Explicitly allow only http/https, and consider restricting to standard ports (80/443) unless the feature genuinely needs otherwise.
6. For anything render-heavy (headless browser PDF/screenshot generation), run it in a network-isolated environment. A headless Chrome instance that can reach your internal network is SSRF with JavaScript execution attached. Give it its own egress rules, separate from the rest of your infrastructure, and block the metadata endpoint at the network layer (or require IMDSv2 on AWS, which requires a token from a PUT request that most simple SSRF payloads can't perform) as a second line of defense.
The pattern across all of this: validate what you're actually going to connect to, at the moment you connect to it — not a proxy for that (the hostname, the first DNS answer, the first URL before a redirect). Every SSRF bypass technique above is a variation on attacking the gap between what got validated and what got fetched.
Detection, as a second layer
Even a correctly built fetcher benefits from visibility into who's testing its edges. Patterns worth flagging:
- Repeated requests where the target resolves to a private, loopback, or link-local range, especially
169.254.169.254specifically. - A burst of URLs using IP-encoding tricks (decimal, hex, octal, IPv6-mapped) against the same feature in a short window — a strong signal of deliberate blocklist probing rather than a legitimate one-off.
- A webhook or import URL that resolves differently on successive requests in a way consistent with DNS rebinding (same hostname, IP changing between requests).
- Fetches to non-standard ports or non-HTTP(S) schemes on an endpoint that should only ever see image or document URLs.
This is exactly the kind of traffic pattern a detection layer like Kriosa is positioned to flag. Kriosa can help detect and surface SSRF probing, but it does not replace destination validation, redirect controls, DNS handling, or network-level isolation — it watches for the probing that suggests someone is testing whether those controls can be bypassed, after they're in place, not instead of them.
What Is Kriosa?
Kriosa is an application-level security layer for PHP and Laravel applications. It sits at the application boundary and analyzes incoming requests for suspicious traffic before that traffic reaches sensitive application logic.
For features that fetch a user-supplied URL webhooks, image proxies, PDF/screenshot generation, link previews Kriosa provides an additional detection and visibility layer by helping surface suspicious request patterns associated with attempts to probe, bypass, or abuse those fetches.
But Kriosa is not a replacement for building the fetch correctly.
If your application exposes any feature where the server requests a URL on a user's behalf, the primary fix is to get that fetch itself right allowlisting where possible, validating the resolved address rather than just the hostname, pinning the connection to that validated address, disabling or re-validating redirects, and restricting scheme and port.
Think of Kriosa as defense in depth, not a substitute for a correctly implemented fetch.
How Kriosa Can Help Detect Suspicious SSRF Activity
SSRF is fundamentally an application-layer problem.
The dangerous condition is not simply that someone submitted a URL to be fetched. Legitimate users paste webhook URLs, image links, and documents to render constantly, and a single fetch is completely normal.
The real problem is when a pattern of requests suggests someone is probing the fetch itself — testing IP-encoding tricks, chaining redirects toward internal addresses, exploiting DNS rebinding, or repeatedly targeting the cloud metadata endpoint.
That makes detection useful as an additional layer, but it does not change where the vulnerability must ultimately be fixed: in the fetch logic itself.
Patterns worth investigating include:
- URLs resolving to private, loopback, or link-local ranges, especially 169.254.169.254
- A burst of decimal/hex/octal/IPv6-mapped IP encodings against the same feature
- The same hostname resolving to different IPs across successive requests (rebinding)
- Fetches to non-standard ports or non-HTTP(S) schemes on an image/document-only endpoint
For example, a burst of requests carrying encoded loopback addresses against a single image-proxy endpoint may be worth investigating even though no individual request looks obviously malicious.
The important word is pattern.
A single unusual URL is not automatically an attack. Context and volume matter.
Kriosa can add another layer of visibility by helping developers identify suspicious request patterns, unusual redirect chains, and repeated probing of URL-fetching endpoints.
The distinction is important:
A detection signal is not proof of an attack, and detection is not the same as prevention.
Prevention Comes First
The application should still:
- Prefer an allowlist of known hostnames over a blocklist wherever the feature allows it.
- Validate the IP actually being connected to, not just the original hostname.
- Pin the resolved address between validation and connection so DNS can't change underneath the check.
- Disable automatic redirect-following, or re-validate every hop before following it.
- Restrict the URL scheme to
http/httpsonly, and consider restricting ports. - Run any headless-browser rendering (PDF/screenshot generation) in a network-isolated environment.
- Block the cloud metadata endpoint at the network layer, or require a token-based metadata API (like IMDSv2 on AWS), as a second line of defense.
These controls address the vulnerability itself.
Detection Adds Another Layer
A correctly built fetcher closes off the known failure modes, but it does not guarantee that every future change to the feature preserves that correctness.
An attacker sweeping a feature with encoded IPs, or repeatedly chaining redirects toward internal addresses, may be attempting to find a gap in the implementation or attempting to reach the metadata endpoint live.
That activity is useful security telemetry.
Kriosa is designed to provide additional application-level protection and visibility into suspicious traffic helping developers detect, log, investigate, and respond to activity that may indicate an SSRF attempt.
Even a well-built Guzzle or cURL setup won't, on its own, flag a burst of probing across dozens of encoding variants against the same endpoint — that's a pattern-over-time observation, not a single-request validation decision, and it's exactly the kind of traffic pattern a detection layer is positioned to flag.
The goal is not to make an unvalidated fetch safe.
The goal is to get the fetch right first, then add visibility around the traffic attempting to reach it.
Why Kriosa?
Security controls can fail.
A developer can add a new "import from URL" feature months after the original allowlist was written and forget to route it through the same validation. A proxy change can stop stripping a forged header that redirect-validation logic relied on. A dependency upgrade can change an HTTP client's default redirect behavior without anyone noticing. A headless-browser feature can get added without anyone considering its network access is a fetch surface too.
That is why defense in depth matters.
A practical security model can look like:
Secure fetch → Destination validation → Detection → Logging & response
Kriosa fits into the detection layer.
Your fetch logic should prevent the vulnerability.
Your validation should reject unresolved, private, or malformed destinations.
Kriosa can provide additional visibility into suspicious application traffic.
Your logs and monitoring can help you investigate and respond.
Kriosa does not replace a secure fetch. It adds another layer for detecting and monitoring the traffic that reaches it.
SSRF Prevention Checklist
Before shipping any feature where the server fetches a user-supplied URL:
- [ ] Can this be an allowlist of known hostnames instead of a blocklist? If yes, do that and skip the rest of this list.
- [ ] Is every private, loopback, link-local (
169.254.0.0/16), and reserved IP range blocked — for both IPv4 and IPv6? - [ ] Is the IP actually being connected to validated, not just the original hostname?
- [ ] Is the resolved IP pinned between validation and connection, so DNS can't change underneath the check (rebinding)?
- [ ] Is automatic redirect-following disabled, or is every redirect hop re-validated before being followed?
- [ ] Is the URL scheme restricted to
http/httpsonly — nofile://,gopher://,dict://? - [ ] Are decimal/hex/octal/IPv6-mapped IP encodings normalized before the blocklist check runs, not bypassed by them?
- [ ] If this feature uses a headless browser (PDF/screenshot generation), does it run in a network-isolated environment with its own egress rules?
- [ ] Is the cloud metadata endpoint blocked at the network layer, or is IMDSv2 (token-required) enforced, as defense in depth beyond the application-level check?
- [ ] Are requests to internal ranges or the metadata endpoint logged and monitored, even when blocked?
The goal isn't to write one clever check. It's to make sure that at the exact moment your server opens a connection, it's opening it to an address the application chose to trust — not an address an attacker steered it toward through a redirect, a DNS trick, or an encoding quirk.
Try Kriosa
If you want an additional application-level security layer for your PHP or Laravel application:
Try Kriosa: kriosa.com
Install it with Composer:
composer require kriosa-ai/kriosa-php
Documentation: Kriosa Documentation
Built by a developer from Cameroon, for developers who want to understand their security — not just outsource it.
Kriosa — sleep better, we're awake.
Top comments (0)