Skip to content

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_HOSTS are the only destinations accepted, so a user-supplied URL cannot choose the target
  • Scheme validation: parse_url identifies the scheme so the allowlist can reject file://, phar://, gopher://, and other non-HTTP protocols
  • DNS resolution + IP filtering: dns_get_record collects A and AAAA records, then ssrf_address_blocked classifies each one. The two filter_var flags do most of the work on plain IPv4 - measured on PHP 8.5.8 they reject 127.0.0.1, 0.0.0.0, RFC 1918, 169.254.169.254 and ::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, because NO_RES_RANGE treats all of ::ffff:0:0/96 as reserved, which is what unwrapping first fixes
  • The whole of 169.254.0.0/16, not a check for 169.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 = false stops redirect-based SSRF, where a validated URL bounces to http://localhost:6379
  • Defense-in-depth: the chain runs allowlist -> scheme -> DNS -> IP -> redirect, and CURLOPT_RESOLVE makes the addresses just checked the ones cURL connects to - a bare curl_exec() would resolve the name again, and CURLOPT_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, or CURLOPT_PROTOCOLS = CURLPROTO_HTTPS on older cURL, blocks file://, 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) accepts example.com and its subdomains and nothing else, so a typosquatted lookalike such as examp1e.com does not match
  • Redirect blocking: CURLOPT_FOLLOWLOCATION = false stops attackers from using https://example.com/redirect?to=http://localhost
  • SSL verification: CURLOPT_SSL_VERIFYPEER + CURLOPT_SSL_VERIFYHOST prevent 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 Host header and CURLOPT_SSL_VERIFYHOST => 2 behave normally - which is what rewriting the URL to the IP would break.
  • CURLINFO_PRIMARY_IP confirms 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 one host:port; a redirect elsewhere would resolve normally and is not covered.
  • CURLOPT_PROXY => '' keeps the pin in the path. CURLOPT_RESOLVE seeds 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 reads http_proxy, https_proxy and all_proxy from 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) via str_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 why ssrf_address_blocked exists - on their own they accept 100.64.0.1, 198.18.0.1, 224.0.0.1 and 64: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 = false stops 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_RESOLVE carries the addresses UrlValidator inspected into cURL, so its own lookup never happens, and proxy => '' keeps HTTPS_PROXY from 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 80 and 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 neither parse_url() nor a strict RFC 3986 one, and the two can disagree on malformed or unusual input (stray backslashes, unexpected @/; characters), so the host parse_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, and filter_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. Use ssrf_address_blocked(), which unwraps the two IPv4-carrying forms first and adds the CIDR list those flags miss.
  • Leaving CURLOPT_FOLLOWLOCATION enabled 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_RESOLVE and leaving proxying enabled - libcurl picks up http_proxy, https_proxy and all_proxy from the environment without being asked, and through a proxy the pinned host:port is 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