From 35b71351a617aabfd7d98ecfc72e038b9a6ab34c Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Thu, 1 Oct 2026 15:36:48 -0500 Subject: [PATCH 1/3] Check URLs are valid before attempting to perform HTTP request --- src/Extractor.php | 4 +- src/functions.php | 83 +++++++++++++++++++++++++++++++++++++++-- tests/FunctionsTest.php | 45 +++++++++++++++++++--- 3 files changed, 123 insertions(+), 9 deletions(-) diff --git a/src/Extractor.php b/src/Extractor.php index 641968a9..4db64e89 100644 --- a/src/Extractor.php +++ b/src/Extractor.php @@ -253,7 +253,9 @@ public function resolveUri($uri): UriInterface if (!isHttp($uri)) { throw new InvalidArgumentException(sprintf('Uri string must use http or https scheme (%s)', $uri)); } - + if (!isValidUrl($uri)) { + throw new InvalidArgumentException(sprintf('Access to this URL is blocked for security reasons (%s)', $uri)); + } $uri = $this->crawler->createUri($uri); } diff --git a/src/functions.php b/src/functions.php index 9965ee63..fcd89c36 100644 --- a/src/functions.php +++ b/src/functions.php @@ -73,14 +73,91 @@ function resolveUri(UriInterface $base, UriInterface $uri): UriInterface ->withFragment(''); } +/** + * Check if the DNS associated with the URL is valid (SSRF). + */ +function isValidUrl(string $url): bool +{ + // First, use standard PHP url filtering. + if (!filter_var($url, FILTER_VALIDATE_URL)) { + return false; + } + // Next, check the host for problematic IPs. + $parts = parse_url($url); + if (empty($parts['host'])) { + // This would be an internal Url, which is valid. + return false; + } + $host = $parts['host']; + // Normalize IPv6 literal formatting wrapping (e.g., [::1] -> ::1) + if (strpos($host, '[') === 0 && strpos($host, ']') === (strlen($host) - 1)) { + $host = substr($host, 1, -1); + } + // Collect all IPs the host resolves to. + $ips = []; + if (filter_var($host, FILTER_VALIDATE_IP)) { + // The host is already a direct IP address literal. + $ips[] = $host; + } + else { + // Resolve DNS records for both IPv4 (A) and IPv6 (AAAA). + $dnsA = @dns_get_record($host, DNS_A); + $dnsAAAA = @dns_get_record($host, DNS_AAAA); + if (is_array($dnsA)) { + foreach ($dnsA as $record) { + if (isset($record['ip'])) { + $ips[] = $record['ip']; + } + } + } + if (is_array($dnsAAAA)) { + foreach ($dnsAAAA as $record) { + if (isset($record['ipv6'])) { + $ips[] = $record['ipv6']; + } + } + } + // Fallback. If dns_get_record fails but gethostbyname finds something. + if (empty($ips)) { + $fallbackIp = @gethostbyname($host); + if ($fallbackIp !== $host) { + $ips[] = $fallbackIp; + } + else { + // If DNS resolution fails, treat URL as invalid. + return false; + } + } + } + // 4. Validate resolved IPs against standard restricted ranges + foreach ($ips as $ip) { + // Check if the IP is valid and falls outside reserved/private scopes + // FILTER_FLAG_NO_PRIV_RANGE: Blocks RFC1918 (10/8, 172.16/12, 192.168/16) + // FILTER_FLAG_NO_RES_RANGE: Blocks Loopback (127.0.0.0/8, ::1) + // and Link-Local (169.254.0.0/16). + $isPublic = filter_var( + $ip, + FILTER_VALIDATE_IP, + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + ); + if (!$isPublic) { + // The IP belongs to a restricted/private range. + return false; + } + } + return true; +} + 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); + if ($result === 1) { + $scheme = strtolower($matches[1]); + return in_array($scheme, ['http', 'https'], true); } - return true; + // SECURE: Reject URIs without explicit http/https scheme + return false; } function resolvePath(string $base, string $path): string diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index bb5e959d..54cc433e 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -4,8 +4,10 @@ namespace Embed\Tests; use function Embed\isHttp; +use function Embed\isValidUrl; use PHPUnit\Framework\TestCase; + class FunctionsTest extends TestCase { public function urlsProvider(): array @@ -16,11 +18,35 @@ public function urlsProvider(): array ['mailto:foo@example.com', false], ['tel:+1234567890', false], ['data:foo', false], - ['./foo', true], - ['/foo', true], - ['../foo', true], - ['foo.com', true], - ['//foo.com', true], + ['./foo', false], + ['/foo', false], + ['../foo', false], + ['foo.com', false], + ['//foo.com', false], + ['//internal.local/admin', false], + ]; + } + + public function invalidUrlsProvider(): array { + return [ + ['https://169.254.0.0/SSRF_PATH', false], + ['https://169.254.169.254/latest/meta-data/iam/security-credentials/', false], + ['https://127.0.0.1:9999/SSRF_PATH', false], + ['http://127.0.0.1:9999/SSRF_PATH', false], + ['https://10.0.0.0/SSRF_PATH', false], + ['https://172.16.0.0/SSRF_PATH', false], + ['https://192.168.0.0/SSRF_PATH', false], + ['http://localhost:8080/admin', false], + ['169.254.0.0/SSRF_PATH', false], + ['10.0.0.0/SSRF_PATH', false], + ['172.16.0.0/SSRF_PATH', false], + ['192.168.0.0/SSRF_PATH', false], + ['./foo', false], + ['/foo', false], + ['../foo', false], + ['foo.com', false], + ['https://foo.com', true], + ['https://example.com', true], ]; } @@ -32,4 +58,13 @@ public function testIsHttp(string $url, bool $expected) $result = isHttp($url); $this->assertSame($expected, $result); } + + /** + * @dataProvider invalidUrlsProvider + */ + public function testIsValidUrl(string $url, bool $expected) + { + $result = isValidUrl($url); + $this->assertSame($expected, $result); + } } From 874d6cd0d3d57d7ca0c110c7348ddacb1c309f4b Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Thu, 1 Oct 2026 15:58:42 -0500 Subject: [PATCH 2/3] Simplify logic --- src/functions.php | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/functions.php b/src/functions.php index fcd89c36..52821ed7 100644 --- a/src/functions.php +++ b/src/functions.php @@ -78,16 +78,11 @@ function resolveUri(UriInterface $base, UriInterface $uri): UriInterface */ function isValidUrl(string $url): bool { - // First, use standard PHP url filtering. + // First, use standard PHP URL filtering. if (!filter_var($url, FILTER_VALIDATE_URL)) { return false; } - // Next, check the host for problematic IPs. $parts = parse_url($url); - if (empty($parts['host'])) { - // This would be an internal Url, which is valid. - return false; - } $host = $parts['host']; // Normalize IPv6 literal formatting wrapping (e.g., [::1] -> ::1) if (strpos($host, '[') === 0 && strpos($host, ']') === (strlen($host) - 1)) { From 4bc547b72ee3e920b9eeeb154bc013057aa2406e Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Thu, 1 Oct 2026 15:59:07 -0500 Subject: [PATCH 3/3] Fallback for empty types --- src/functions.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/functions.php b/src/functions.php index 52821ed7..94a0a1c0 100644 --- a/src/functions.php +++ b/src/functions.php @@ -83,7 +83,7 @@ function isValidUrl(string $url): bool return false; } $parts = parse_url($url); - $host = $parts['host']; + $host = $parts['host'] ?? ''; // Normalize IPv6 literal formatting wrapping (e.g., [::1] -> ::1) if (strpos($host, '[') === 0 && strpos($host, ']') === (strlen($host) - 1)) { $host = substr($host, 1, -1);