CWE-918: Server-Side Request Forgery (SSRF) - PHP
Overview
Server-Side Request Forgery (SSRF) allows attackers to make the server perform HTTP requests to arbitrary destinations, potentially accessing internal services, cloud metadata endpoints, or bypassing firewalls. Always validate URLs against an allowlist, block private IP ranges, and restrict protocols.
Primary Defence: Validate URLs against an allowlist of permitted domains, restrict protocols to https://, and check every resolved address with ssrf_address_blocked() below - filter_var() with FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE is the first half of that check and not the whole of it. Measured on PHP 8.5.8, those two flags accept 100.64.0.1, 198.18.0.1, 224.0.0.1, ::7f00:1 and 64:ff9b::7f00:1, and reject ::ffff:8.8.8.8, which is public - so the helper unwraps the IPv4-carrying IPv6 forms first and adds the CIDR list the flags miss.
Common Vulnerable Patterns
Direct URL Usage from User Input
<?php
// VULNERABLE - No validation on user-provided URL
function fetchImage($imageUrl) {
// No validation - SSRF vulnerability!
$content = file_get_contents($imageUrl);
return $content;
}
// Attack examples:
// http://localhost/admin
// http://169.254.169.254/latest/meta-data/iam/security-credentials/
// file:///etc/passwd
Why this is vulnerable: file_get_contents() is not an HTTP function - it dispatches on the stream wrapper, so the same call serves file://, php://, ftp:// and data:. That makes it strictly worse than a dedicated HTTP client for this purpose: the attacker chooses the protocol as well as the destination, and file:///etc/passwd needs no network access at all. allow_url_fopen being on by default is what makes the remote half reachable.
Unvalidated cURL Requests
<?php
// VULNERABLE - cURL without URL validation
function downloadFile($url) {
// No validation - SSRF vulnerability!
$ch = curl_init($url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
// Attack: $url = "http://internal-api.local/sensitive-endpoint"
Why this is vulnerable: cURL will follow whatever scheme the URL names unless told otherwise, and CURLOPT_PROTOCOLS_STR is not set here. Beyond http and https that includes file, ftp, dict and gopher in most builds - dict and gopher are the ones that turn a fetch into arbitrary bytes on a socket, which reaches services that never intended to speak HTTP.
Unvalidated Webhook Handler
<?php
// VULNERABLE - Webhook without validation
function sendWebhook($webhookUrl, $data) {
// No validation - SSRF vulnerability!
$ch = curl_init($webhookUrl);
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($data));
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
curl_close($ch);
return $result;
}
// Attack: $webhookUrl = "http://billing.internal/api/v1/refunds" (an internal HTTP API)
Why this is vulnerable: A user-supplied callback is the case where fetching an arbitrary address is the intended feature, so there is no "unexpected input" to reject - only a destination policy to enforce. The URL is typically saved once and used by a background job later, which means the check has to happen at send time rather than at configuration time; validating it in the form handler leaves the stored value free to resolve elsewhere by the time it is used.
Secure Patterns
URL Allowlist Validation
<?php
// SECURE - one address policy, called by every example on this page, which
// require it as 'ssrf-address-policy.php'. Keeping it in one place is the
// point: four copies of a range list is four chances for one of them to be
// missing a range.
//
// Measured on PHP 8.5.8, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE
// misses in both directions, which is why the unwrap and the CIDR list are
// both here. It lets through 100.64.0.1, 198.18.0.1, 224.0.0.1, the
// IPv4-compatible form ::7f00:1, the IPv4-translated form ::ffff:0:7f00:1,
// the deprecated site-local range fec0::/10, and the NAT64, 6to4 and Teredo
// prefixes - 64:ff9b:1::7f00:1 is 127.0.0.1 through a NAT64 translator using
// the RFC 8215 local-use prefix; RFC 6052 section 3.1 makes a compliant one
// drop the well-known-prefix form 64:ff9b::7f00:1. And it over-rejects
// the mapped form: ::ffff:8.8.8.8 is a public address and the flags refuse it,
// because NO_RES_RANGE treats the whole of ::ffff:0:0/96 as reserved. On the
// plain IPv4 spellings it is right - 127.0.0.1, 0.0.0.0, 10/8, 169.254.169.254
// and ::1 are all rejected.
const SSRF_EXTRA_BLOCKED_CIDRS = [
'100.64.0.0/10', // carrier-grade NAT (RFC 6598)
'198.18.0.0/15', // benchmarking (RFC 2544)
'192.0.0.0/24', // IETF protocol assignments (RFC 6890)
'192.0.2.0/24', // documentation (RFC 5737) - nothing legitimate lives here
'198.51.100.0/24', // documentation (RFC 5737)
'203.0.113.0/24', // documentation (RFC 5737)
'224.0.0.0/4', // multicast
'ff00::/8', // IPv6 multicast - the flags reject none of ff00::/8
'64:ff9b::/96', // NAT64 (RFC 6052)
'64:ff9b:1::/48', // NAT64 local use (RFC 8215)
'2002::/16', // 6to4 (RFC 3056)
'2001::/32', // Teredo (RFC 4380)
'::ffff:0:0:0/96', // IPv4-translated (RFC 6145) - one zero group on from the mapped form, which the unwrap does not fold
'fec0::/10', // site-local, deprecated in 2004 and still a private range
'2001:db8::/32', // documentation (RFC 3849)
'100::/64', // discard-only (RFC 6666)
];
function ssrf_address_blocked(string $ip): bool {
$ip = ssrf_unwrap_ipv4($ip);
if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
return true;
}
foreach (SSRF_EXTRA_BLOCKED_CIDRS as $cidr) {
if (ssrf_ip_in_cidr($ip, $cidr)) {
return true;
}
}
return false;
}
// The two IPv6 forms that hold an IPv4 address in their low 32 bits, written
// back as dotted quad so one set of IPv4 rules decides both. Measured on PHP
// 8.5.8, this matters in both directions: the compatible form `::7f00:1` is
// 127.0.0.1 and filter_var accepts it, and `::ffff:8.8.8.8` is a public
// address that filter_var rejects, because FILTER_FLAG_NO_RES_RANGE treats the
// whole of ::ffff:0:0/96 as reserved. Unwrapping first fixes both.
function ssrf_unwrap_ipv4(string $ip): string {
$binary = inet_pton($ip);
if ($binary === false || strlen($binary) !== 16) {
return $ip;
}
$mapped = substr($binary, 0, 10) === str_repeat("\0", 10) && substr($binary, 10, 2) === "\xff\xff";
$compatible = substr($binary, 0, 12) === str_repeat("\0", 12);
if (!$mapped && !$compatible) {
return $ip;
}
// :: and ::1 are the unspecified and loopback addresses rather than an
// embedded IPv4 one, and unwrapping ::1 to 0.0.0.1 would lose what made it
// worth blocking
$low = substr($binary, 12, 4);
if ($compatible && ($low === "\0\0\0\0" || $low === "\0\0\0\x01")) {
return $ip;
}
return inet_ntop($low);
}
// One CURLOPT_RESOLVE entry - [+]HOST:PORT:ADDRESS[,ADDRESS] - for the
// addresses that passed. libcurl documents IPv6 addresses in brackets here;
// every example on this page that pins a connection builds its entry with this
function ssrf_resolve_entry(string $host, int $port, array $addresses): string {
$formatted = array_map(
fn(string $ip) => str_contains($ip, ':') ? "[$ip]" : $ip,
$addresses
);
return "$host:$port:" . implode(',', $formatted);
}
function ssrf_ip_in_cidr(string $ip, string $cidr): bool {
[$subnet, $bits] = explode('/', $cidr);
$ipBin = inet_pton($ip);
$subnetBin = inet_pton($subnet);
// 4 bytes for IPv4 and 16 for IPv6: different lengths cannot overlap
if ($ipBin === false || $subnetBin === false || strlen($ipBin) !== strlen($subnetBin)) {
return false;
}
$bytes = intdiv((int) $bits, 8);
$remainder = (int) $bits % 8;
if (substr($ipBin, 0, $bytes) !== substr($subnetBin, 0, $bytes)) {
return false;
}
if ($remainder === 0) {
return true;
}
$mask = chr(0xFF << (8 - $remainder) & 0xFF);
return ($ipBin[$bytes] & $mask) === ($subnetBin[$bytes] & $mask);
}
<?php
// The address policy from the top of this page, as its own file
require_once 'ssrf-address-policy.php';
// SECURE - Validate URLs against allowlist
class SafeImageFetcher {
private const ALLOWED_HOSTS = [
'api.example.com',
'cdn.example.com',
'images.example.com'
];
private const ALLOWED_SCHEMES = ['https'];
/** The addresses the last validateUrl() inspected; fetchImage pins to them */
private array $checkedAddresses = [];
public function fetchImage(string $imageUrl): string {
$validatedUrl = $this->validateUrl($imageUrl);
$parsed = parse_url($validatedUrl);
$port = $parsed['port'] ?? 443;
$ch = curl_init($validatedUrl);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false, // No redirects
// Connect to the addresses validateUrl checked rather than to a
// second lookup, and take no proxy from the environment - the
// mechanism is under "Pinning cURL to the validated address" below
CURLOPT_RESOLVE => [ssrf_resolve_entry(strtolower($parsed['host']), $port, $this->checkedAddresses)],
CURLOPT_PROXY => '',
]);
$result = curl_exec($ch);
return $result;
}
private function validateUrl(string $url): string {
$parsed = parse_url($url);
if ($parsed === false) {
throw new InvalidArgumentException('Invalid URL');
}
// Validate scheme
$scheme = $parsed['scheme'] ?? '';
if (!in_array(strtolower($scheme), self::ALLOWED_SCHEMES)) {
throw new InvalidArgumentException("Invalid URL scheme: $scheme");
}
// Validate host
$host = $parsed['host'] ?? '';
if (!in_array(strtolower($host), self::ALLOWED_HOSTS)) {
throw new InvalidArgumentException("Host not allowed: $host");
}
// Block private IP ranges
if ($this->isPrivateIp($host)) {
throw new InvalidArgumentException('Private IP addresses not allowed');
}
return $url;
}
private function isPrivateIp(string $host): bool {
$addresses = $this->resolveHostAddresses($host);
foreach ($addresses as $ip) {
if (ssrf_address_blocked($ip)) {
return true;
}
}
$this->checkedAddresses = $addresses;
return false;
}
private function resolveHostAddresses(string $host): array {
if (filter_var($host, FILTER_VALIDATE_IP)) {
return [$host];
}
$records = dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false || $records === []) {
return ['0.0.0.0']; // fail closed
}
$ips = [];
foreach ($records as $record) {
if (isset($record['ip'])) {
$ips[] = $record['ip'];
}
if (isset($record['ipv6'])) {
$ips[] = $record['ipv6'];
}
}
return $ips ?: ['0.0.0.0'];
}
}
Why this works:
- Host allowlist: the three hosts in
ALLOWED_HOSTSare the only destinations accepted, so a user-supplied URL cannot choose the target - Scheme validation:
parse_urlidentifies the scheme so the allowlist can rejectfile://,phar://,gopher://, and other non-HTTP protocols - DNS resolution + IP filtering:
dns_get_recordcollects A and AAAA records, thenssrf_address_blockedclassifies each one. The twofilter_varflags do most of the work on plain IPv4 - measured on PHP 8.5.8 they reject127.0.0.1,0.0.0.0, RFC 1918,169.254.169.254and::1- and the other two steps exist because they are wrong at both ends for IPv6. They let through carrier-grade NAT, the benchmarking range, multicast, the compatible form::7f00:1, and the NAT64, 6to4 and Teredo prefixes, which is what the CIDR list covers; and they reject::ffff:8.8.8.8, a public address, becauseNO_RES_RANGEtreats all of::ffff:0:0/96as reserved, which is what unwrapping first fixes - The whole of
169.254.0.0/16, not a check for169.254.169.254: the flags already cover the range, which takes in the Azure and Alibaba metadata addresses and anything else on that interface, so a named-address check adds nothing but a second place to keep current - Redirect prevention:
CURLOPT_FOLLOWLOCATION = falsestops redirect-based SSRF, where a validated URL bounces tohttp://localhost:6379 - Defense-in-depth: the chain runs allowlist -> scheme -> DNS -> IP -> redirect, and
CURLOPT_RESOLVEmakes the addresses just checked the ones cURL connects to - a barecurl_exec()would resolve the name again, andCURLOPT_PROXY => ''keeps an environment proxy from resolving it elsewhere. Pinning cURL to the validated address explains both - Fail-closed behavior: Blocks on DNS errors instead of falling back to a best-effort fetch
cURL with Comprehensive Validation
<?php
// The address policy from the top of this page, as its own file
require_once 'ssrf-address-policy.php';
// SECURE - cURL with URL validation and restrictions
class SecureWebhookHandler {
private const ALLOWED_URL_PATTERN = '/^https:\/\/([a-z0-9-]+\.)*example\.com\/.*/i';
/** The addresses the last validateWebhookUrl() inspected */
private array $checkedAddresses = [];
public function sendWebhook(string $webhookUrl, array $data): int {
$validatedUrl = $this->validateWebhookUrl($webhookUrl);
$parsed = parse_url($validatedUrl);
$host = strtolower($parsed['host']);
$port = $parsed['port'] ?? 443;
$ch = curl_init($validatedUrl);
// Security settings
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($data),
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false, // Disable redirects
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
// The addresses validateWebhookUrl checked are the ones connected
// to, and no environment proxy gets to resolve the name instead
CURLOPT_RESOLVE => [ssrf_resolve_entry($host, $port, $this->checkedAddresses)],
CURLOPT_PROXY => '',
CURLOPT_HTTPHEADER => [
'Content-Type: application/json'
]
]);
if (defined('CURLOPT_PROTOCOLS_STR')) {
curl_setopt($ch, CURLOPT_PROTOCOLS_STR, 'HTTPS');
} else {
curl_setopt($ch, CURLOPT_PROTOCOLS, CURLPROTO_HTTPS);
}
$result = curl_exec($ch);
$httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
return $httpCode;
}
private function validateWebhookUrl(string $url): string {
if (empty($url)) {
throw new InvalidArgumentException('URL cannot be empty');
}
// Check against allowlist pattern
if (!preg_match(self::ALLOWED_URL_PATTERN, $url)) {
throw new InvalidArgumentException("URL not allowed: $url");
}
$parsed = parse_url($url);
if ($parsed === false) {
throw new InvalidArgumentException('Invalid URL');
}
// Only HTTPS
if (($parsed['scheme'] ?? '') !== 'https') {
throw new InvalidArgumentException('Only HTTPS allowed');
}
// Block private IPs
if ($this->isPrivateAddress($parsed['host'] ?? '')) {
throw new InvalidArgumentException('Private IP addresses not allowed');
}
return $url;
}
private function isPrivateAddress(string $host): bool {
$records = dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false || $records === []) {
return true;
}
$checked = [];
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if ($ip === null) {
// A CNAME-only answer has no ip/ipv6 key. Skipping it and
// falling out of the loop would return "not private" without
// having checked an address at all.
continue;
}
if (ssrf_address_blocked($ip)) {
return true;
}
$checked[] = $ip;
}
// Fail closed when no address was actually inspected
if ($checked === []) {
return true;
}
$this->checkedAddresses = $checked;
return false;
}
}
Why this works:
- Protocol restriction:
CURLOPT_PROTOCOLS_STR = 'HTTPS'on current cURL, orCURLOPT_PROTOCOLS = CURLPROTO_HTTPSon older cURL, blocksfile://,ftp://,gopher://and the other protocols libcurl is built with used to bypass hostname validation - Strict domain matching: the regex (
/^https:\/\/([a-z0-9-]+\.)*example\.com\/.*/i) acceptsexample.comand its subdomains and nothing else, so a typosquatted lookalike such asexamp1e.comdoes not match - Redirect blocking:
CURLOPT_FOLLOWLOCATION = falsestops attackers from usinghttps://example.com/redirect?to=http://localhost - SSL verification:
CURLOPT_SSL_VERIFYPEER+CURLOPT_SSL_VERIFYHOSTprevent MitM attacks where an attacker controls DNS and serves a malicious certificate - DoS prevention: the 10-second timeout stops a slow internal service holding the request open indefinitely
- Suited to webhooks: the regex domain check runs inside
sendWebhook(), so a stored, user-provided URL is validated each time it is used rather than once when it was configured - Fail-closed DNS: an empty answer blocks, and so does an answer containing no address record - a CNAME-only response must not be read as "no private address found"
Two lookups, reconciled: dns_get_record() and cURL would each resolve the
name on their own. CURLOPT_RESOLVE hands cURL the answers that were checked,
so there is one lookup rather than two. How the entry works, and how it fails
when it is written wrong, is next.
Pinning cURL to the validated address
CURLOPT_RESOLVE pre-seeds cURL's resolver cache for one host:port, so the
transfer uses the address you checked instead of resolving again. It is the
right tool rather than rewriting the URL to the IP, because the hostname is
preserved for SNI, the Host header and certificate verification:
<?php
// The address policy from the top of this page, as its own file
require_once 'ssrf-address-policy.php';
// SECURE - validate the addresses, then make cURL use exactly those
function fetch_pinned(string $url, array $allowedHosts): string {
$parsed = parse_url($url);
$host = strtolower($parsed['host'] ?? '');
$port = $parsed['port'] ?? (($parsed['scheme'] ?? '') === 'https' ? 443 : 80);
if (($parsed['scheme'] ?? '') !== 'https' || !in_array($host, $allowedHosts, true)) {
throw new InvalidArgumentException('URL not allowed');
}
// Resolve once, and keep the addresses that passed
$safe = [];
foreach (dns_get_record($host, DNS_A | DNS_AAAA) ?: [] as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if ($ip === null) {
continue;
}
if (ssrf_address_blocked($ip)) {
throw new InvalidArgumentException("Host resolves to a non-public address: $ip");
}
$safe[] = $ip;
}
if ($safe === []) {
throw new InvalidArgumentException('No usable address for host');
}
$ch = curl_init($url);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_TIMEOUT => 10,
CURLOPT_FOLLOWLOCATION => false,
CURLOPT_SSL_VERIFYPEER => true,
CURLOPT_SSL_VERIFYHOST => 2,
// Bind this host:port to the addresses that passed validation; the
// helper puts brackets round IPv6 addresses, as libcurl documents
CURLOPT_RESOLVE => [ssrf_resolve_entry($host, $port, $safe)],
// Empty string disables proxying, including a proxy libcurl would
// otherwise take from http_proxy/https_proxy/all_proxy. Through a proxy
// the socket goes to the proxy, so the pin never applies
CURLOPT_PROXY => '',
]);
$body = curl_exec($ch);
if ($body === false) {
$error = curl_error($ch);
throw new RuntimeException("Request failed: $error");
}
// Belt and braces: confirm where the transfer actually went
$peer = curl_getinfo($ch, CURLINFO_PRIMARY_IP);
if (!in_array($peer, $safe, true)) {
throw new RuntimeException("Connected to unexpected address: $peer");
}
return $body;
}
Why this works:
- cURL does not resolve again. With the mapping seeded, the transfer uses a validated address, so a second DNS answer has nothing to influence.
- TLS still verifies the hostname. The request URL keeps the name, so SNI,
the
Hostheader andCURLOPT_SSL_VERIFYHOST => 2behave normally - which is what rewriting the URL to the IP would break. CURLINFO_PRIMARY_IPconfirms the outcome rather than assuming it, which also catches a stale pin if the option is ever dropped in a refactor.- Keep
CURLOPT_FOLLOWLOCATION => false. The pin covers onehost:port; a redirect elsewhere would resolve normally and is not covered. CURLOPT_PROXY => ''keeps the pin in the path.CURLOPT_RESOLVEseeds the resolver cache for the connection libcurl makes, and through a proxy that connection is to the proxy - the target hostname is sent in the request line and resolved at the other end, where nothing has been validated. libcurl readshttp_proxy,https_proxyandall_proxyfrom the environment on its own, so this is not limited to code that configures a proxy deliberately: a variable in a container image is enough. Setting the option to an empty string overrides those. Where a proxy is required for egress, the destination control has to move to the proxy.
Get the entry format exactly right. libcurl specifies it as
[+]HOST:PORT:ADDRESS[,ADDRESS]: the port is mandatory, multiple addresses in
one entry must be comma-separated (supported since 7.59.0), and IPv6 addresses
go inside brackets - example.com:443:[2606:4700::1111]. Measured on libcurl
8.12.1 the parser also accepts them bare, but the bracketed form is the
documented one and ssrf_resolve_entry() produces it.
The two ways to get it wrong fail differently, and only one of them is loud:
well-formed pin body='LOCAL-SERVER-OK' peer=127.0.0.1
malformed entry (failed) err: Could not parse CURLOPT_RESOLVE entry
pin with wrong port (failed) err: Could not resolve host
A malformed entry is rejected outright, so a typo cannot silently disable the
pin. A well-formed entry for the wrong host or port is worse: it parses, it
simply never matches the request, and the transfer resolves normally as though
no pin had been set. Omitting :443 is the common way to land there.
CURLINFO_PRIMARY_IP is what distinguishes the two. Checking the address the
transfer actually used turns "the pin did not apply" into a visible failure
rather than a quiet fallback to ordinary DNS.
URL Validator Class
<?php
// The address policy from the top of this page, as its own file
require_once 'ssrf-address-policy.php';
// SECURE - Reusable URL validator. The framework examples below require this
// file as 'url-validator.php'.
class UrlValidator {
private array $allowedSchemes;
private array $allowedHosts;
private bool $blockPrivateIps;
private array $checkedAddresses = [];
public function __construct(
array $allowedSchemes,
array $allowedHosts,
bool $blockPrivateIps = true
) {
$this->allowedSchemes = array_map('strtolower', $allowedSchemes);
$this->allowedHosts = array_map('strtolower', $allowedHosts);
$this->blockPrivateIps = $blockPrivateIps;
}
public function validate(string $url): string {
$parsed = parse_url($url);
if ($parsed === false || empty($parsed['scheme']) || empty($parsed['host'])) {
throw new InvalidArgumentException('Invalid URL');
}
// Validate scheme
if (!in_array(strtolower($parsed['scheme']), $this->allowedSchemes)) {
throw new InvalidArgumentException("Scheme not allowed: {$parsed['scheme']}");
}
$host = strtolower($parsed['host']);
// Validate host against allowlist
if (!$this->isHostAllowed($host)) {
throw new InvalidArgumentException("Host not allowed: $host");
}
// Block private IPs
if ($this->blockPrivateIps && $this->isPrivateIp($host)) {
throw new InvalidArgumentException('Private IP addresses not allowed');
}
// Block localhost variants
if ($this->isLocalhost($host)) {
throw new InvalidArgumentException('Localhost not allowed');
}
return $url;
}
/** The addresses the last validate() inspected, for pinning the connection to them */
public function checkedAddresses(): array {
return $this->checkedAddresses;
}
private function isHostAllowed(string $host): bool {
// Exact match
if (in_array($host, $this->allowedHosts)) {
return true;
}
// Wildcard subdomain match (*.example.com)
foreach ($this->allowedHosts as $allowedHost) {
if (str_starts_with($allowedHost, '*.')) {
$domain = substr($allowedHost, 1);
if (str_ends_with($host, $domain)) {
return true;
}
}
}
return false;
}
private function isPrivateIp(string $host): bool {
$records = filter_var($host, FILTER_VALIDATE_IP)
? [['ip' => $host]]
: dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false || $records === []) {
return true;
}
$checked = [];
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if ($ip === null) {
// A CNAME-only answer has no ip/ipv6 key. Skipping it and
// falling out of the loop would return "not private" without
// having checked an address at all.
continue;
}
if (ssrf_address_blocked($ip)) {
return true;
}
if ($this->isDockerInternal($ip)) {
return true;
}
$checked[] = $ip;
}
// Fail closed when no address was actually inspected
if ($checked === []) {
return true;
}
$this->checkedAddresses = $checked;
return false;
}
// No isAwsMetadata() here: ssrf_address_blocked() already rejects the whole
// of 169.254.0.0/16, which covers the Azure and Alibaba metadata addresses
// as well as the AWS one. A named-address check beside it is a second list
// to keep current, not a second layer.
private function isDockerInternal(string $ip): bool {
// Docker default bridge: 172.17.0.0/16
return str_starts_with($ip, '172.17.');
}
private function isLocalhost(string $host): bool {
return in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0']);
}
}
// Usage:
$validator = new UrlValidator(
allowedSchemes: ['https'],
allowedHosts: ['api.example.com', '*.cdn.example.com'],
blockPrivateIps: true
);
$safeUrl = $validator->validate($userInput);
Why this works:
- Centralized configuration: Constructor-based setup (allowed schemes/hosts, private IP blocking) ensures consistent SSRF policies across cURL, Guzzle, Laravel HTTP
- Flexible allowlists: Wildcard subdomain support (
*.cdn.example.com) viastr_starts_with/str_ends_with; case-normalization (strtolower) prevents bypass - Private IP detection, and what it does not reach: catches localhost variants (
127.0.0.1,::1,0.0.0.0), AWS metadata (169.254.169.254) and Docker networks (172.17.x) beyond RFC 1918.filter_var's flags stop there, which is whyssrf_address_blockedexists - on their own they accept100.64.0.1,198.18.0.1,224.0.0.1and64:ff9b::7f00:1 - DNS rebinding defense: DNS/IP validation catches unsafe current answers, and
checkedAddresses()hands the framework examples below the answers it inspected so each can pin its connection to them - validating and then letting the client resolve the name again would leave the race open - Testable architecture: the policy arrives as constructor arguments, so tests and per-environment config change the inputs rather than duplicating the logic
Framework-Specific Guidance
Laravel
<?php
// SECURE - Laravel controller with URL validation
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Http;
// The validator from the section above, as its own file. It declares no
// namespace, so inside this one it has to be named with a leading backslash -
// an unqualified class name does not fall back to global scope the way an
// unqualified function call does.
require_once 'url-validator.php';
// The address policy, for ssrf_resolve_entry()
require_once 'ssrf-address-policy.php';
class ProxyController extends Controller {
private \UrlValidator $urlValidator;
public function __construct() {
$this->urlValidator = new \UrlValidator(
allowedSchemes: ['https'],
allowedHosts: config('ssrf.allowed_hosts', []),
blockPrivateIps: true
);
}
public function proxy(Request $request) {
$url = $request->query('url');
if (empty($url)) {
return response()->json(['error' => 'URL parameter required'], 400);
}
try {
// Validate URL
$validatedUrl = $this->urlValidator->validate($url);
$parsed = parse_url($validatedUrl);
$port = $parsed['port'] ?? 443;
// Laravel's client is Guzzle: it takes HTTPS_PROXY from the
// environment unless 'proxy' is set, and cURL resolves the name
// again unless CURLOPT_RESOLVE hands it the addresses that passed
$response = Http::timeout(10)
->withoutRedirecting()
->withOptions([
'proxy' => '',
'curl' => [CURLOPT_RESOLVE => [ssrf_resolve_entry(
strtolower($parsed['host']), $port, $this->urlValidator->checkedAddresses()
)]],
])
->get($validatedUrl);
return response()->json([
'status' => $response->status(),
'content' => $response->body()
]);
} catch (\InvalidArgumentException $e) {
return response()->json(['error' => 'Invalid URL: ' . $e->getMessage()], 400);
} catch (\Exception $e) {
return response()->json(['error' => 'Request failed'], 500);
}
}
}
// config/ssrf.php
return [
'allowed_hosts' => [
'api.example.com',
'*.cdn.example.com'
]
];
Symfony
<?php
// SECURE - Symfony controller with URL validation
namespace App\Controller;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Contracts\HttpClient\HttpClientInterface;
// The validator from the section above, as its own file. It declares no
// namespace, so inside this one it needs the leading backslash.
require_once 'url-validator.php';
class ProxyController extends AbstractController {
private \UrlValidator $urlValidator;
private HttpClientInterface $httpClient;
public function __construct(HttpClientInterface $httpClient) {
$this->httpClient = $httpClient;
$this->urlValidator = new \UrlValidator(
allowedSchemes: ['https'],
allowedHosts: ['api.example.com'],
blockPrivateIps: true
);
}
#[Route('/api/proxy', methods: ['GET'])]
public function proxy(Request $request): JsonResponse {
$url = $request->query->get('url');
if (empty($url)) {
return new JsonResponse(['error' => 'URL parameter required'], 400);
}
try {
// Validate URL
$validatedUrl = $this->urlValidator->validate($url);
$host = strtolower(parse_url($validatedUrl, PHP_URL_HOST));
[$pinned] = $this->urlValidator->checkedAddresses();
// 'resolve' hands the client an address that was checked instead
// of letting it look the name up again. 'no_proxy' keeps this host
// off the http_proxy/https_proxy the component honours from the
// environment by default - through a proxy the resolve map never
// applies, because the proxy does the resolving
$response = $this->httpClient->request('GET', $validatedUrl, [
'timeout' => 10,
'max_redirects' => 0,
'resolve' => [$host => $pinned],
'no_proxy' => $host,
]);
return new JsonResponse([
'status' => $response->getStatusCode(),
'content' => $response->getContent()
]);
} catch (\InvalidArgumentException $e) {
return new JsonResponse(['error' => 'Invalid URL'], 400);
} catch (\Exception $e) {
return new JsonResponse(['error' => 'Request failed'], 500);
}
}
}
Guzzle HTTP Client
<?php
// SECURE - Guzzle with URL validation
use GuzzleHttp\Client;
use GuzzleHttp\RequestOptions;
// The validator from the section above, as its own file. This block declares
// no namespace, so the plain name reaches it.
require_once 'url-validator.php';
// The address policy, for ssrf_resolve_entry()
require_once 'ssrf-address-policy.php';
class SecureApiClient {
private Client $client;
private UrlValidator $urlValidator;
public function __construct() {
$this->client = new Client([
RequestOptions::TIMEOUT => 10,
RequestOptions::ALLOW_REDIRECTS => false,
RequestOptions::VERIFY => true
]);
$this->urlValidator = new UrlValidator(
allowedSchemes: ['https'],
allowedHosts: ['api.example.com'],
blockPrivateIps: true
);
}
public function fetchData(string $url): array {
// Validate URL
$validatedUrl = $this->urlValidator->validate($url);
$parsed = parse_url($validatedUrl);
try {
$response = $this->client->get($validatedUrl, [
// Guzzle reads HTTPS_PROXY from the environment unless 'proxy'
// is set, and cURL resolves the name again unless told which
// addresses passed validation
RequestOptions::PROXY => '',
'curl' => [CURLOPT_RESOLVE => [ssrf_resolve_entry(
strtolower($parsed['host']), $parsed['port'] ?? 443, $this->urlValidator->checkedAddresses()
)]],
]);
return [
'status' => $response->getStatusCode(),
'body' => $response->getBody()->getContents()
];
} catch (\GuzzleHttp\Exception\GuzzleException $e) {
// The message names the address cURL could not reach - a map of
// the internal network. Log it; the caller gets a fixed string
error_log('outbound request rejected: ' . $e->getMessage());
throw new RuntimeException('Request failed');
}
}
}
Why this works:
- Global security settings: the constructor options (TIMEOUT, ALLOW_REDIRECTS, VERIFY) apply to every request this client makes, so no call site has to remember them
- Redirect blocking:
ALLOW_REDIRECTS = falsestops redirect-based SSRF (e.g.,example.com/redirect?to=169.254.169.254/latest/meta-data) - Layered validation: UrlValidator before Guzzle blocks
file://, enforces host allowlist, and checks private IPs; SSL verification prevents MitM - The connection is pinned:
CURLOPT_RESOLVEcarries the addressesUrlValidatorinspected into cURL, so its own lookup never happens, andproxy => ''keepsHTTPS_PROXYfrom the environment out of the path - Guzzle reads it on its own when the option is unset - DoS protection: 10-second timeout prevents hangs on slow internal services (Redis, Memcached)
- Information hiding: the Guzzle message -
cURL error 7: Failed to connect to 10.0.0.5 port 80and the like - goes to the log, and the exception the caller sees carries a fixed string
Protecting Cloud Metadata Endpoints
<?php
// The address policy from the top of this page, as its own file
require_once 'ssrf-address-policy.php';
// SECURE - Block AWS/Azure/GCP metadata endpoints
class MetadataProtection {
private const BLOCKED_HOSTS = [
'169.254.169.254', // AWS/Azure metadata
'metadata.google.internal', // GCP metadata
'metadata'
];
private const BLOCKED_PATHS = [
'/latest/meta-data',
'/latest/user-data',
'/latest/dynamic',
'/computeMetadata/v1',
'/metadata/instance'
];
public function validateNotMetadata(string $url): void {
$parsed = parse_url($url);
if ($parsed === false) {
throw new InvalidArgumentException('Invalid URL');
}
$host = strtolower($parsed['host'] ?? '');
$path = $parsed['path'] ?? '';
// Block metadata service hostnames
if (in_array($host, self::BLOCKED_HOSTS)) {
throw new InvalidArgumentException('Access to metadata service blocked');
}
// Block metadata paths
foreach (self::BLOCKED_PATHS as $blockedPath) {
if (str_starts_with($path, $blockedPath)) {
throw new InvalidArgumentException('Access to metadata endpoint blocked');
}
}
// Every answer goes through the one policy. FILTER_FLAG_NO_RES_RANGE
// on its own accepts 64:ff9b:1::a9fe:a9fe, the NAT64 local-use spelling
// of 169.254.169.254 - the address this method exists to block
$records = dns_get_record($host, DNS_A | DNS_AAAA);
if ($records === false) {
throw new InvalidArgumentException('DNS resolution failed');
}
$checked = 0;
foreach ($records as $record) {
$ip = $record['ip'] ?? $record['ipv6'] ?? null;
if ($ip === null) {
continue; // a CNAME-only record carries no address to check
}
$checked++;
if (ssrf_address_blocked($ip)) {
throw new InvalidArgumentException('Non-public address blocked');
}
}
// Fail closed when no address was actually inspected
if ($checked === 0) {
throw new InvalidArgumentException('Host does not resolve to an address');
}
}
}
Common Pitfalls
- Validating the hostname with
parse_url()and checking it against an allowlist, then handing the original URL string to cURL - libcurl has a URL parser of its own, which is neitherparse_url()nor a strict RFC 3986 one, and the two can disagree on malformed or unusual input (stray backslashes, unexpected@/;characters), so the hostparse_url()validated is not guaranteed to be the host cURL actually connects to. - Using
filter_var($url, FILTER_VALIDATE_URL)as if it also excludes private or internal destinations - it only checks that the string is a well-formed URL, not what it resolves to. Excluding internal addresses means running a check against the resolved address, andfilter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)is only the first half of one: measured on PHP 8.5.8 it accepts carrier-grade NAT, the benchmarking range, multicast, the IPv4-compatible form::7f00:1, and the NAT64, 6to4 and Teredo prefixes - while rejecting::ffff:8.8.8.8, which is public. Usessrf_address_blocked(), which unwraps the two IPv4-carrying forms first and adds the CIDR list those flags miss. - Leaving
CURLOPT_FOLLOWLOCATIONenabled after validating only the initial URL - cURL follows the redirect chain internally, so a validated, allowlisted URL that redirects to an internal address is fetched without the redirect target ever being checked. - Pinning with
CURLOPT_RESOLVEand leaving proxying enabled - libcurl picks uphttp_proxy,https_proxyandall_proxyfrom the environment without being asked, and through a proxy the pinnedhost:portis never the one connected to, so resolution of the real target happens at the proxy where none of the validation ran.CURLOPT_PROXY => ''disables it.
Additional Resources
- CWE-918 Details
- OWASP SSRF Prevention Cheat Sheet
- PHP cURL Documentation
- PHP filter_var Documentation
- libcurl CURLOPT_RESOLVE - the entry format used for connection pinning, and the rules on ports and multiple addresses
- PortSwigger SSRF