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 {
        // Resolve hostname to IP
        $ip = gethostbyname($host);

        // Check if resolution failed (returns hostname if failed)
        if ($ip === $host && !filter_var($ip, FILTER_VALIDATE_IP)) {
            return true;  // Block if DNS fails
        }

        // Validate IP is not private
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            return true;
        }

        // Additional check for AWS metadata
        if ($ip === '169.254.169.254') {
            return true;
        }

        return false;
    }
}

Why this works:

  • Host allowlist: Pre-approved domains prevent arbitrary target selection
  • Scheme validation: parse_url blocks dangerous protocols (file://, jar://, gopher://) that bypass HTTP validation
  • DNS resolution + IP filtering: gethostbyname converts hostnames to IPs, then filter_var with FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE blocks RFC 1918 private ranges (10.x, 172.16-31.x, 192.168.x), loopback (127.x), link-local (169.254.x)
  • Explicit AWS metadata protection: Direct 169.254.169.254 check protects IAM credentials even if filter_var misses edge cases
  • 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) prevents access to internal services, cloud metadata, local files even with URL encoding or DNS rebinding
  • Fail-closed behavior: Blocks on DNS errors to prevent TOCTOU races

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_PROTOCOLS => CURLPROTO_HTTPS, // Only HTTPS
            CURLOPT_SSL_VERIFYPEER => true,
            CURLOPT_SSL_VERIFYHOST => 2,
            CURLOPT_HTTPHEADER => [
                'Content-Type: application/json'
            ]
        ]);

        $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 {
        $ip = gethostbyname($host);

        // Block if DNS resolution fails
        if ($ip === $host && !filter_var($ip, FILTER_VALIDATE_IP)) {
            return true;
        }

        // Check for private/reserved ranges
        if (!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 = CURLPROTO_HTTPS 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: filter_var with FILTER_FLAG_NO_PRIV_RANGE after gethostbyname prevents pointing example.com to 127.0.0.1 mid-request
  • 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 {
        // Resolve hostname to IP
        $ip = gethostbyname($host);

        // Block if DNS fails
        if ($ip === $host && !filter_var($ip, FILTER_VALIDATE_IP)) {
            return true;
        }

        // Check for private/reserved IP ranges
        if (!filter_var($ip, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE)) {
            return true;
        }

        // AWS metadata endpoint
        if ($this->isAwsMetadata($ip)) {
            return true;
        }

        // Docker internal network
        if ($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: gethostbyname before validation resolves IPs first, preventing attackers from changing DNS after validation; fail-closed on DNS errors prevents TOCTOU
  • Testable architecture: Object-oriented design with dependency injection enables unit testing (mock gethostbyname), 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, checks private IPs to defeat DNS rebinding; 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
        $ip = gethostbyname($host);

        if (filter_var($ip, FILTER_VALIDATE_IP)) {
            $parts = explode('.', $ip);

            // 169.254.x.x
            if (count($parts) === 4 && $parts[0] === '169' && $parts[1] === '254') {
                throw new InvalidArgumentException('Link-local addresses blocked');
            }
        }
    }
}

Testing and Validation

SSRF vulnerabilities should be identified through:

  • Static Analysis Tools: Use tools like Psalm, PHPStan, SonarQube, or Snyk to identify potential SSRF sinks
  • Dynamic Application Security Testing (DAST): Tools like OWASP ZAP, Burp Suite, or Acunetix can test for SSRF by manipulating URL parameters
  • Manual Penetration Testing: Test with internal IP addresses (127.0.0.1, 192.168.x.x), cloud metadata endpoints (169.254.169.254), and file:// protocols
  • Code Review: Ensure all HTTP client usage includes URL validation against an allowlist and blocks private IP ranges
  • Network Monitoring: Monitor outbound requests to detect unexpected internal network access

Additional Resources