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_urlidentifies the scheme so the allowlist can rejectfile://,jar://,gopher://, and other non-HTTP protocols - DNS resolution + IP filtering:
dns_get_recordcollects A and AAAA records, thenfilter_varwithFILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGEblocks private, loopback, link-local, reserved, and other non-public ranges - Explicit AWS metadata protection: Direct
169.254.169.254check documents the cloud-metadata threat - Redirect prevention:
CURLOPT_FOLLOWLOCATION = falsestops redirect-based SSRF where validated URL bounces tohttp://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, orCURLOPT_PROTOCOLS = CURLPROTO_HTTPSon older cURL, blocksfile://,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 = falsestops attackers from usinghttps://example.com/redirect?to=http://localhost - SSL verification:
CURLOPT_SSL_VERIFYPEER+CURLOPT_SSL_VERIFYHOSTprevent 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) viastr_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 = 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 - 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');
}
}
}
}