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, block private IP ranges using filter_var() with FILTER_FLAG_NO_PRIV_RANGE and FILTER_FLAG_NO_RES_RANGE, and restrict protocols to https://.

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

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"

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://localhost:6379/SET/key/value" (Redis)

Secure Patterns

URL Allowlist Validation

<?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'];

    public function fetchImage(string $imageUrl): string {
        $validatedUrl = $this->validateUrl($imageUrl);

        $ch = curl_init($validatedUrl);
        curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
        curl_setopt($ch, CURLOPT_TIMEOUT, 10);
        curl_setopt($ch, CURLOPT_FOLLOWLOCATION, false);  // No redirects

        $result = curl_exec($ch);
        curl_close($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 {
        foreach ($this->resolveHostAddresses($host) as $ip) {
            if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return true;
            }

            if ($ip === '169.254.169.254') {
                return true;
            }
        }

        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: Pre-approved domains prevent arbitrary target selection
  • Scheme validation: parse_url identifies the scheme so the allowlist can reject file://, jar://, gopher://, and other non-HTTP protocols
  • DNS resolution + IP filtering: dns_get_record collects A and AAAA records, then filter_var with FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE blocks private, loopback, link-local, reserved, and other non-public ranges
  • Explicit AWS metadata protection: Direct 169.254.169.254 check documents the cloud-metadata threat
  • Redirect prevention: CURLOPT_FOLLOWLOCATION = false stops redirect-based SSRF where validated URL bounces to http://localhost:6379
  • Defense-in-depth: Multi-layer validation (allowlist -> scheme -> DNS -> IP -> redirect) blocks common SSRF targets; use network egress controls or a client that pins the validated address where DNS rebinding is in scope
  • Fail-closed behavior: Blocks on DNS errors instead of falling back to a best-effort fetch

cURL with Comprehensive Validation

<?php
// SECURE - cURL with URL validation and restrictions
class SecureWebhookHandler {
    private const ALLOWED_URL_PATTERN = '/^https:\/\/([a-z0-9-]+\.)*example\.com\/.*/i';

    public function sendWebhook(string $webhookUrl, array $data): int {
        $validatedUrl = $this->validateWebhookUrl($webhookUrl);

        $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,
            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);
        curl_close($ch);

        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;
        }

        foreach ($records as $record) {
            $ip = $record['ip'] ?? $record['ipv6'] ?? null;
            if ($ip !== null && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return true;
            }
        }

        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://, jar:// and other dangerous protocols used to bypass hostname validation
  • Strict domain matching: Regex pattern (/^https:\/\/([a-z0-9-]+\.)*example\.com\/.*/i) prevents lookalike domains (examp1e.com) and typosquatting
  • 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 attacker controls DNS and serves malicious certificate
  • DNS rebinding defense: DNS/IP validation catches unsafe current resolutions; use connection pinning, a trusted outbound proxy, or egress firewall rules for full rebinding protection
  • DoS prevention: 10-second timeout prevents denial-of-service via slow internal services
  • Ideal for webhooks: Combines regex domain validation with comprehensive cURL security for user-provided URLs

URL Validator Class

<?php
// SECURE - Reusable URL validator
class UrlValidator {
    private array $allowedSchemes;
    private array $allowedHosts;
    private bool $blockPrivateIps;

    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;
    }

    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;
        }

        foreach ($records as $record) {
            $ip = $record['ip'] ?? $record['ipv6'] ?? null;
            if ($ip === null) {
                continue;
            }

            if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
                return true;
            }

            if ($this->isAwsMetadata($ip) || $this->isDockerInternal($ip)) {
                return true;
            }
        }

        return false;
    }

    private function isAwsMetadata(string $ip): bool {
        return $ip === '169.254.169.254';
    }

    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
  • Comprehensive private IP detection: Catches localhost variants (127.0.0.1, ::1, 0.0.0.0), AWS metadata (169.254.169.254), Docker networks (172.17.x) beyond RFC 1918
  • DNS rebinding defense: DNS/IP validation catches unsafe current answers; pair it with egress controls, a trusted outbound proxy, or connection pinning when attacker-controlled DNS is in scope
  • Testable architecture: Object-oriented design with dependency injection enables unit testing, per-environment config without duplicating 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;

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);

            // Make request with timeout and no redirects
            $response = Http::timeout(10)

                ->withoutRedirecting()
                ->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;

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);

            // Make request with restrictions
            $response = $this->httpClient->request('GET', $validatedUrl, [
                'timeout' => 10,
                'max_redirects' => 0
            ]);

            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;

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);

        try {
            $response = $this->client->get($validatedUrl);

            return [
                'status' => $response->getStatusCode(),
                'body' => $response->getBody()->getContents()
            ];
        } catch (\GuzzleHttp\Exception\GuzzleException $e) {
            throw new RuntimeException('Request failed: ' . $e->getMessage());
        }
    }
}

Why this works:

  • Global security settings: Constructor options (TIMEOUT, ALLOW_REDIRECTS, VERIFY) enforce security across all requests, preventing per-request disabling of protections
  • 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
  • DoS protection: 10-second timeout prevents hangs on slow internal services (Redis, Memcached)
  • Information hiding: Exception wrapping provides generic errors without revealing network topology; PSR-7 compliance enables testing and framework integration

Protecting Cloud Metadata Endpoints

<?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',
        'metadata.azure.com'
    ];

    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');
            }
        }

        // Block link-local addresses
        $records = dns_get_record($host, DNS_A | DNS_AAAA);
        if ($records === false) {
            throw new InvalidArgumentException('DNS resolution failed');
        }

        foreach ($records as $record) {
            $ip = $record['ip'] ?? $record['ipv6'] ?? null;
            if ($ip !== null && !filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_RES_RANGE)) {
                throw new InvalidArgumentException('Link-local addresses blocked');
            }
        }
    }
}

Additional Resources