Repository navigation
Possible SSRF Exploitation via Scheme Validation Bypass #573
Description
Vulnerability Name: Server-Side Request Forgery (SSRF) via Unvalidated URL Schemes
Severity: Critical
CWE: CWE-918 (Server-Side Request Forgery (SSRF))
OWASP Category: OWASP Top 10 - A04:2021 Insecure Deserialization
Description:
The isHttp() function returns true when a URI does not contain an explicit scheme, allowing scheme-less or protocol-relative URLs to bypass validation. When combined with the URI resolution logic in resolveUri(), this may enable requests to internal resources, private IP addresses, or cloud metadata endpoints, potentially leading to Server-Side Request Forgery (SSRF) if applications process untrusted user-supplied URLs.
Affected Files:
Vulnerable Code:
function isHttp(string $uri): bool
{
$result = preg_match('/^(\w+):/', $uri, $matches);
if ($result !== false && $result > 0) {
return in_array(strtolower($matches[1]), ['http', 'https'], true);
}
return true; // VULNERABLE: Returns true for empty/invalid schemes
}
// In Extractor.php:
public function resolveUri($uri): UriInterface
{
if (is_string($uri)) {
if (!isHttp($uri)) { // This check can be bypassed
throw new InvalidArgumentException(sprintf('Uri string must use http or https scheme (%s)', $uri));
}
$uri = $this->crawler->createUri($uri);
}
return resolveUri($this->uri, $uri);
}Root Cause:
The isHttp() function has a logic flaw: it returns true by default for URIs without a scheme. This combined with URI resolution logic allows bypass of scheme validation:
- URL without scheme (e.g.,
//internal.local/admin) passes validation - Protocol-relative URLs are resolved against the base URL scheme
- File:// URLs can be accessed if the base URL is file://
- Attacker can craft URLs targeting:
- Private IP ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16)
- Localhost (127.0.0.1, localhost)
- Cloud metadata endpoints (169.254.169.254)
- Private DNS records
Impact:
- Access Internal Services: Attackers can scan/access internal APIs, databases, admin panels
- Cloud Metadata Access: On AWS/GCP/Azure, can steal temporary credentials from metadata endpoints
- Credential Theft: Access to internal service authentication tokens
- Information Disclosure: Enumeration of internal network topology
- Denial of Service: Attacks on internal services through the library
Exploitation Steps:
- Attacker identifies application uses Embed library
- Attacker submits URLs targeting:
- Private metadata:
http://169.254.169.254/latest/meta-data/iam/security-credentials/ - Internal services:
http://internal-api.local/admin - Localhost:
http://localhost:8080/admin
- Private metadata:
- Library validates URI (passes due to default true return)
- Curl makes request to internal resource
- Response data is parsed and may be disclosed to attacker
- Posible to perfome XSPA (Cross Site Port Attack)
POC Video : https://youtu.be/S8IoZHeaGa0
Proof of Concept:
$embed = new Embed\Embed();
// Attack 1: Access AWS metadata
$info = $embed->get('http://169.254.169.254/latest/meta-data/iam/security-credentials/');
// Attack 2: Protocol-relative URL to internal service
$info = $embed->get('//internal-database.local:5432/');
// Attack 3: Private IP access
$info = $embed->get('http://10.0.0.1/admin');
// Attack 4: XSPA
$info = $embed->get('http://127.0.0.1:8080');//posible to scan internal or lan port with local IP
<!-- Failed to upload "Embed-ssrf-poc.mp4" -->
<!-- Failed to upload "Embed-ssrf-poc.mp4" -->Remediation:
Implement strict URL validation with whitelist approach:
- Validate URL scheme is http/https
- Reject private/internal IP ranges
- Reject cloud metadata endpoints
- Implement request filtering
function isHttp(string $uri): bool
{
$result = preg_match('/^(\w+):/', $uri, $matches);
if ($result === 1) {
$scheme = strtolower($matches[1]);
return in_array($scheme, ['http', 'https'], true);
}
// SECURE: Reject URIs without explicit http/https scheme
return false;
}
function isBlockedUrl(UriInterface $uri): bool
{
$host = $uri->getHost();
// Reject localhost variants
if (in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0'], true)) {
return true;
}
// Reject private IP ranges
$ip = @ip2long($host);
if ($ip !== false) {
// 10.0.0.0/8
if (($ip >= 167772160 && $ip <= 184549375)) return true;
// 172.16.0.0/12
if (($ip >= 2886729728 && $ip <= 2887778303)) return true;
// 192.168.0.0/16
if (($ip >= 3232235520 && $ip <= 3232301055)) return true;
// 127.0.0.0/8
if (($ip >= 2130706432 && $ip <= 2147483647)) return true;
}
// Reject cloud metadata endpoints
if (in_array($host, ['169.254.169.254', 'metadata.google.internal'], true)) {
return true;
}
return false;
}Fixed Code Example:
// src/functions.php
function isHttp(string $uri): bool
{
$result = preg_match('/^(\w+):/', $uri, $matches);
if ($result === 1) {
return in_array(strtolower($matches[1]), ['http', 'https'], true);
}
return false; // SECURE: Default to false for schemeless URIs
}
// src/Extractor.php
public function resolveUri($uri): UriInterface
{
if (is_string($uri)) {
if (!isHttp($uri)) {
throw new InvalidArgumentException(sprintf('Uri string must use http or https scheme (%s)', $uri));
}
$uri = $this->crawler->createUri($uri);
}
$resolved = resolveUri($this->uri, $uri);
// SECURE: Validate resolved URI is safe
if (isBlockedUrl($resolved)) {
throw new InvalidArgumentException(sprintf('Access to this URL is blocked for security reasons (%s)', $resolved));
}
return $resolved;
}
function isBlockedUrl(\Psr\Http\Message\UriInterface $uri): bool
{
$host = $uri->getHost();
if ($host === null || $host === '') {
return true;
}
// Reject localhost variants
if (in_array($host, ['localhost', '127.0.0.1', '::1', '0.0.0.0'], true)) {
return true;
}
// Reject private IP ranges
$ip = @ip2long($host);
if ($ip !== false) {
// 10.0.0.0/8
if (($ip >= 167772160 && $ip <= 184549375)) return true;
// 172.16.0.0/12
if (($ip >= 2886729728 && $ip <= 2887778303)) return true;
// 192.168.0.0/16
if (($ip >= 3232235520 && $ip <= 3232301055)) return true;
// 127.0.0.0/8
if (($ip >= 2130706432 && $ip <= 2147483647)) return true;
}
// Reject cloud metadata endpoints
if (in_array($host, ['169.254.169.254', 'metadata.google.internal'], true)) {
return true;
}
return false;
}References:
- CWE-918: https://cwe.mitre.org/data/definitions/918.html
- OWASP Testing Guide: https://owasp.org/www-project-web-security-testing-guide/latest/4-Web_Application_Security_Testing/07-Input_Validation_Testing/20-Testing_for_Server-side_Request_Forgery
Activity
markfullmer commentedon Oct 1, 2026
I can confirm that this is technically an SSRF vulnerability. I have provided a resolution in #578 for review. Thanks!
Vitorinox commentedon Oct 2, 2026
Thanks for working on this! I tested the branch locally against master (c45a900) and found a few issues:
isValidUrl()is only called fromExtractor::resolveUri(), butEmbed::get()/getMulti()send the request directly via the Crawler.$embed->get('http://127.0.0.1:PORT/secret')(and 169.254.169.254, 10.x, localhost) still fetch and return the internal page. HTTP redirects (curlFOLLOWLOCATION) to internal hosts are also not checked.isHttp()return false for scheme-less strings meansresolveUri('/img.png'),'../x','//cdn.example.com/x'now throw. Relativeog:image, icons, feeds and oEmbed links are silently dropped, and$info->languagesthrows an uncaughtInvalidArgumentExceptionon pages with relativehreflanglinks. The suite goes from 1 failure on master to 18 (17 new failures inPagesTest).FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGEstill allows100.100.100.200(Alibaba metadata),100.64.0.0/10,198.18.0.0/15and64:ff9b::/96. ConsiderFILTER_FLAG_GLOBAL_RANGE(PHP >= 8.2) or an explicit blocklist.FILTER_VALIDATE_URLalso rejects valid hosts with underscores and non-punycode IDNs.example.com,foo.com), which makes them flaky offline/in CI.Suggestion: keep
isHttp()permissive for relative refs and validate the resolved absolute URI afterresolveUri(). Add the same check inEmbed::get()/getMulti()and on every redirect hop (or disableFOLLOWLOCATIONand follow redirects manually). Ideally pin the validated IP withCURLOPT_RESOLVEto avoid DNS rebinding, and restrictCURLOPT_PROTOCOLS/CURLOPT_REDIR_PROTOCOLSto HTTP(S).markfullmer commentedon Oct 5, 2026
Thanks for the feedback, @Vitorinox . I've added a series of commits that address the remaining issues you called out. Full disclosure: some of the reworking of cURL behavior was beyond my area of expertise, so I used an LLM to assist with staging that code. I then performed a code review to confirm that it looked technically accurate and can confirm that automated tests are continuing to behave the same way as they were before. I also added new test coverage to demonstrate that direct calls to
Embed::get()andEmbed::getMulti()have URL validation. Summary of changes:This is done in 31aa303
These range gaps are covered using FILTER_FLAG_GLOBAL_RANGE, with test coverage added, in 3faf69e . I chose to leave this fix for PHP >= 8.2 rather than creating an explicit blocklist; if you think it's important to provide coverage for PHP < 8.2, let me know.
Done in 1bbcb91 , with test coverage demonstrating the fix.
I also added checking after the RedirectUri is obtained in 24661be
This is the most complicated code change, replacing default cURL parameters with custom ones to avoid SSRF rebinding in scenarios where a DNS has an extremely short or nonexistent TTL: 35c8560 . This also adds a
getValidUrlIps()wrapper to return an array of valid IPs, relevant for URLs with redirects.This and other hardening is performed in
d6f6915
3550e83
0810bef
karthi-the-hacker commentedon Oct 8, 2026
Thanks for addressing the SSRF issue and the additional bypasses.
Since the vulnerability has now been patched and regression tests have been added, I'd like to request that this be tracked as a GitHub Security Advisory and assigned a CVE.
Could you please create a draft security advisory for the issue and request a CVE ID through GitHub?
I'm happy to provide any additional information needed for the advisory, including affected versions, the original PoC, impact, and the timeline.
Please also credit
karthi-the-hackeras the security researcher in the advisory.Document the SSRF review notes
Document the SSRF review notes