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..94a0a1c0 100644 --- a/src/functions.php +++ b/src/functions.php @@ -73,14 +73,86 @@ 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; + } + $parts = parse_url($url); + $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); + } }