Web
SSRF: when your server sends requests on someone else's behalf
Image import by URL, webhooks and link previews can point your server at the internal network. How SSRF works, 169.254.169.254, DNS rebinding, testing and fixing it.
· 10 min read

Server-Side Request Forgery (SSRF) is a vulnerability that lets an attacker make your server send a request to an address of their choosing. From the outside the feature looks harmless: "Upload an image from a URL", "Enter your webhook address", "Link preview". But if the server opens the given URL without checking it, the attacker can point it not at an external site but at http://127.0.0.1:6379, an internal admin panel, or a cloud metadata service.
What makes it serious is that a server usually sits in a privileged position on the network: it can see internal services, it is behind the firewall, often among systems configured on the assumption that "requests from inside can be trusted". SSRF hands that trust to the attacker.
Where SSRF appears
Anywhere a user enters a URL, or a URL reaches the server indirectly:
- Importing an image or file by URL — avatars, product photos, documents.
- Webhooks — the user enters their own address and the server sends events to it.
- Link previews — when a link is pasted into a chat or post, the server fetches the title and image.
- HTML-to-PDF generation — the generator fetches
<img>,<link>and<iframe>sources from the HTML on the server side. - Integrations — "enter your API address", RSS import, iCal sync.
- XML processing — with external entities enabled (XXE), the parser sends requests too.
- Proxy or "redirect" endpoints —
/fetch?url=...,/image-proxy?src=....
What an attacker can achieve
- Access to internal services. Redis, Elasticsearch or Memcached on
127.0.0.1, a control panel, or an API exposed only to the internal network. Many internal services have no authentication at all. - Cloud metadata. Cloud providers run an instance metadata service at
http://169.254.169.254/. It can reveal information about the instance and, in some configurations, temporary access credentials. - Internal network scanning. Response timing and error types reveal which internal addresses and ports are open.
- Other protocols. If the HTTP library supports
file://,gopher://ordict://, reading local files or sending raw commands to internal services becomes possible. - A "mask" for external attacks. Requests leave from your server's IP address.
The most dangerous case is when the attacker can see the response (full SSRF). But even when the response is invisible (blind SSRF), state-changing requests to internal services are still sent.
Why it matters for the expertise
SSRF has its own category in the OWASP Top 10 (2021) — A10 "Server-Side Request Forgery" — and is classified as CWE-918. OWASP ASVS also requires an allowlist of destinations for server-side requests. In the expertise, every field that accepts a URL is usually probed with internal addresses, the metadata address and alternative IP notations. Such a finding is often rated high risk because it bypasses the external perimeter.
How to check it yourself
1. Find the places that accept URLs
Search the codebase for calls that make outbound requests:
# PHP / Laravel
grep -rnE "Http::(get|post|send)|file_get_contents\(|curl_init|fopen\(\s*\\\$|new Client\(" app/
# Node
grep -rnE "axios\.(get|post)|fetch\(|got\(|http\.request|https\.get" src/
For each one, trace where the URL comes from. If it comes from the user, from a user setting stored in the database, or from a third-party response, it is an SSRF candidate.
2. Test with a receiver you control
Open an address on your own server or in a service that logs test requests, and give it to the application. If the request arrives, the server really does reach out — now you can test internal addresses.
3. Test internal addresses (only in your own test environment)
http://127.0.0.1/
http://localhost:6379/
http://169.254.169.254/
http://[::1]/
http://0.0.0.0/
http://2130706433/ (decimal form of 127.0.0.1)
http://127.1/
file:///etc/passwd
Differences in response time, error messages ("connection refused" versus "timeout") or response content indicate a vulnerability.
4. Check redirects
Set up a page on your server that redirects with 302 to http://127.0.0.1/. If the application checks only the initial URL and then follows redirects automatically, the protection is bypassed.
Why a "simple check" doesn't work
Many developers start with something like this:
if (str_contains($url, 'localhost') || str_contains($url, '127.0.0.1')) {
abort(422);
}
It is almost useless. An IP address can be written in many forms (127.1, 2130706433, 0x7f000001, [::ffff:127.0.0.1]), any domain can resolve to 127.0.0.1, and a redirect changes the address after the check.
DNS rebinding
Even resolving the domain and checking the IP may not be enough. The attacker controls their domain's DNS record with a very short TTL: on the first lookup (your check) it returns a public IP, on the second (the actual connection) it returns 127.0.0.1. The address changes between the check and the connection.
The fix is to resolve once, check the IP, and connect to exactly that IP (IP pinning).
The right question for SSRF is not "what does the URL say?" but "which IP is the server actually connecting to?".
How to fix it
General principles
- An allowlist, where possible. If the application only needs to reach a few known services, allow only their domains.
- Only
httpandhttps. Reject all other schemes. - Check every resolved IP. Block private (10/8, 172.16/12, 192.168/16), loopback (127/8, ::1), link-local (169.254/16, fe80::/10),
0.0.0.0/8, multicast and reserved ranges. - Pin the IP and connect to the checked address.
- Disable redirects, or re-check each one.
- Limits: a short timeout, a response size cap, an expected
Content-Type. - Never return the raw response or error text to the user.
Laravel
use Illuminate\Support\Facades\Http;
function safeFetch(string $url)
{
$parts = parse_url($url);
if (!in_array($parts['scheme'] ?? '', ['http', 'https'], true) || empty($parts['host'])) {
throw new InvalidArgumentException('Invalid URL');
}
$host = $parts['host'];
$ips = gethostbynamel($host) ?: [];
if (!$ips) {
throw new InvalidArgumentException('Unresolvable host');
}
foreach ($ips as $ip) {
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
throw new InvalidArgumentException('Blocked address');
}
}
$port = $parts['port'] ?? ($parts['scheme'] === 'https' ? 443 : 80);
return Http::timeout(5)
->withOptions([
'allow_redirects' => false,
'curl' => [
CURLOPT_RESOLVE => ["{$host}:{$port}:{$ips[0]}"],
CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS,
],
])
->get($url);
}
CURLOPT_RESOLVE forces cURL to connect to the checked IP and closes DNS rebinding. gethostbynamel returns IPv4 only; if you need IPv6, also check AAAA records with dns_get_record. PHP 8.2+ also has the FILTER_FLAG_GLOBAL_RANGE flag, which only lets global addresses through.
Node
import dns from 'node:dns/promises';
import ipaddr from 'ipaddr.js';
import axios from 'axios';
import http from 'node:http';
import https from 'node:https';
async function resolveSafe(hostname) {
const records = await dns.lookup(hostname, { all: true });
for (const { address } of records) {
if (ipaddr.process(address).range() !== 'unicast') {
throw new Error('Blocked address');
}
}
return records[0];
}
export async function safeFetch(rawUrl) {
const url = new URL(rawUrl);
if (!['http:', 'https:'].includes(url.protocol)) throw new Error('Invalid scheme');
const pinned = await resolveSafe(url.hostname);
const lookup = (_h, opts, cb) =>
opts?.all
? cb(null, [{ address: pinned.address, family: pinned.family }])
: cb(null, pinned.address, pinned.family);
const agentOpts = { lookup };
return axios.get(url.href, {
timeout: 5000,
maxRedirects: 0,
maxContentLength: 5 * 1024 * 1024,
httpAgent: new http.Agent(agentOpts),
httpsAgent: new https.Agent(agentOpts),
});
}
In ipaddr.js, range() returns unicast for public internet addresses; private, loopback, linkLocal and the rest are blocked. The custom lookup function pins the connection to the checked IP.
At the network and infrastructure level
Code-level protection can have mistakes, so a second layer is needed:
- Restrict outbound traffic (egress). Move the process that fetches external URLs into a separate worker or container and use the firewall to block it from reaching the internal network.
- Authentication on internal services too. A password on Redis, a token on internal APIs. "Only reachable from inside" is not protection.
- Cloud metadata. If you are in the cloud, enable the token-based version of the metadata service (for example, IMDSv2 on AWS) and give the instance a least-privilege role.
- Disable unneeded services and make sure they listen on
127.0.0.1, not0.0.0.0. More: open ports and server hardening.
Webhooks specifically
- Check the webhook address both when it is saved and on every delivery — the DNS record can change later.
- Do not show the response body to the user; log only the status code.
- Sign outgoing requests so the receiver can verify their authenticity.
Short checklist
- There is a list of every feature that accepts URLs: import, webhooks, previews, PDF, integrations.
- Only
http/httpsschemes are allowed. - Every resolved IP is checked against private, loopback, link-local and reserved ranges.
- The connection is pinned to the checked IP (DNS rebinding closed).
- Redirects are disabled or each one is re-checked.
- Timeout, response size and
Content-Typeare limited. - Raw responses and error details are not returned to the user.
- The process making outbound requests cannot reach the internal network.
- Internal services require authentication; the metadata service is protected.
Summary
SSRF hides inside a product's most useful features — import, webhooks, previews. It cannot be closed by blacklisting strings: you need to resolve the IP, check it, pin it and control redirects, and at the network level restrict outbound traffic. This is not hard at the start of a project, but if the expertise finds it, the architecture has to be changed in a hurry.
Want to check your URL-handling features before the expertise? Request a security scan — the Claude AI agent finds the places in your code that make outbound requests, and our specialist tests them and helps close the holes.


