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_urlblocks dangerous protocols (file://,jar://,gopher://) that bypass HTTP validation - DNS resolution + IP filtering:
gethostbynameconverts hostnames to IPs, thenfilter_varwithFILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGEblocks 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.254check protects IAM credentials even if filter_var misses edge cases - 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) 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_HTTPSblocksfile://,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:
filter_varwithFILTER_FLAG_NO_PRIV_RANGEaftergethostbynameprevents pointingexample.comto127.0.0.1mid-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) 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:
gethostbynamebefore 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 = 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, 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