From 35b71351a617aabfd7d98ecfc72e038b9a6ab34c Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Thu, 1 Oct 2026 15:36:48 -0500 Subject: [PATCH 01/11] 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 02/11] 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 03/11] 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); From 31aa303a81eaffe206d9901ade6a6d03be8dcefd Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 10:06:34 -0500 Subject: [PATCH 04/11] Revert allow-list logic for isHttp, retaining original support for relative URLs --- src/functions.php | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/src/functions.php b/src/functions.php index 94a0a1c0..d9e301f1 100644 --- a/src/functions.php +++ b/src/functions.php @@ -146,13 +146,11 @@ function isValidUrl(string $url): bool 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); + if ($result !== false && $result > 0) { + return in_array(strtolower($matches[1]), ['http', 'https'], true); } - // SECURE: Reject URIs without explicit http/https scheme - return false; + return true; } function resolvePath(string $base, string $path): string From 3faf69e29c617e31ad029df9d1276757230fadf9 Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 10:17:51 -0500 Subject: [PATCH 05/11] Add FILTER_FLAG_GLOBAL_RANGE for PHP > 8.2 --- src/functions.php | 2 +- tests/FunctionsTest.php | 36 +++++++++++++++++++++++------------- 2 files changed, 24 insertions(+), 14 deletions(-) diff --git a/src/functions.php b/src/functions.php index d9e301f1..86591c8a 100644 --- a/src/functions.php +++ b/src/functions.php @@ -133,7 +133,7 @@ function isValidUrl(string $url): bool $isPublic = filter_var( $ip, FILTER_VALIDATE_IP, - FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE + FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_GLOBAL_RANGE ); if (!$isPublic) { // The IP belongs to a restricted/private range. diff --git a/tests/FunctionsTest.php b/tests/FunctionsTest.php index 54cc433e..72e495df 100644 --- a/tests/FunctionsTest.php +++ b/tests/FunctionsTest.php @@ -18,35 +18,45 @@ public function urlsProvider(): array ['mailto:foo@example.com', false], ['tel:+1234567890', false], ['data:foo', false], - ['./foo', false], - ['/foo', false], - ['../foo', false], - ['foo.com', false], - ['//foo.com', false], - ['//internal.local/admin', false], + ['./foo', true], + ['/foo', true], + ['../foo', true], + ['foo.com', true], + ['//foo.com', true], ]; } public function invalidUrlsProvider(): array { return [ ['https://169.254.0.0/SSRF_PATH', false], + ['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], + ['https://172.16.0.0/SSRF_PATH', false], ['172.16.0.0/SSRF_PATH', false], + ['https://192.168.0.0/SSRF_PATH', false], ['192.168.0.0/SSRF_PATH', false], + ['http://localhost:8080/admin', false], + ['https://100.100.100.200', false], // Alibaba metadata + ['100.100.100.200', false], // Alibaba metadata + ['64:ff9b::192.0.2.1', false], // embedded IPv4: 192.0.2.1 + ['64:ff9b::192.0.2.1', false], // embedded IPv4: 192.0.2.1 + ['https://64:ff9b::198.51.100.1', false], // embedded IPv4: 198.51.100.1 + ['64:ff9b::198.51.100.1', false], // embedded IPv4: 198.51.100.1 + ['https://198.18.50.25', false], + ['198.18.50.25', false], + ['https://198.19.200.10', false], + ['198.19.200.10', false], + ['https://100.64.0.1', false], + ['100.64.0.1', false], + ['https://100.100.50.25', false], + ['100.100.50.25', false], ['./foo', false], ['/foo', false], ['../foo', false], - ['foo.com', false], - ['https://foo.com', true], - ['https://example.com', true], ]; } From 1bbcb91aba34309e76baabcdb856f17f5cf8d9ee Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 10:45:35 -0500 Subject: [PATCH 06/11] Check for invalid URLs in Embed::get() and Embed::getMulti() and add minimal test coverage; full test converage for isInvalidUrl() already exists --- src/Embed.php | 14 +++++++++++++- tests/MultipleRequestsTest.php | 11 +++++++++++ tests/PagesTest.php | 10 ++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/src/Embed.php b/src/Embed.php index 6f2d583a..8a7f2f1f 100644 --- a/src/Embed.php +++ b/src/Embed.php @@ -4,6 +4,7 @@ namespace Embed; use Embed\Http\Crawler; +use InvalidArgumentException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; @@ -20,6 +21,9 @@ public function __construct(?Crawler $crawler = null, ?ExtractorFactory $extract public function get(string $url): Extractor { + if (!isValidUrl($url)) { + throw new InvalidArgumentException(sprintf('Access to this URL is blocked for security reasons (%s)', $url)); + } $request = $this->crawler->createRequest('GET', $url); $response = $this->crawler->sendRequest($request); @@ -32,7 +36,15 @@ public function get(string $url): Extractor public function getMulti(string ...$urls): array { $requests = array_map( - fn ($url) => $this->crawler->createRequest('GET', $url), + function ($url): RequestInterface { + if (!isValidUrl($url)) { + throw new InvalidArgumentException(sprintf( + 'Access to this URL is blocked for security reasons (%s)', + $url + )); + } + return $this->crawler->createRequest('GET', $url); + }, $urls ); diff --git a/tests/MultipleRequestsTest.php b/tests/MultipleRequestsTest.php index 42348449..af9a4478 100644 --- a/tests/MultipleRequestsTest.php +++ b/tests/MultipleRequestsTest.php @@ -4,10 +4,12 @@ namespace Embed\Tests; use Embed\Embed; +use InvalidArgumentException; use PHPUnit\Framework\TestCase; class MultipleRequestsTest extends TestCase { + public function testParallel() { $embed = new Embed(); @@ -27,4 +29,13 @@ public function testParallel() $this->assertEquals('https://x.com/misteroom', (string) $infos[2]->url); $this->assertEquals('en', $infos[2]->language); } + + public function testInvalid(): void { + $this->expectException(InvalidArgumentException::class); + + (new Embed())->getMulti( + 'https://twitter.com/misteroom', + 'https://64:ff9b::198.51.100.1' + ); + } } diff --git a/tests/PagesTest.php b/tests/PagesTest.php index 441b15b5..59e04ced 100644 --- a/tests/PagesTest.php +++ b/tests/PagesTest.php @@ -3,6 +3,9 @@ namespace Embed\Tests; +use Embed\Embed; +use InvalidArgumentException; + class PagesTest extends PagesTestCase { /** @@ -210,4 +213,11 @@ public function testBBCNews() { $this->assertEmbed('https://www.bbc.co.uk/news/uk-54222286'); } + + public function testInvalid(): void + { + $this->expectException(InvalidArgumentException::class); + $embed = new Embed(); + $embed->get('https://64:ff9b::198.51.100.1'); + } } From 24661be086fff5877e4ff811bc18f8d3550371e3 Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 10:50:03 -0500 Subject: [PATCH 07/11] Add URL checking after retrieving Redirect --- src/Embed.php | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/src/Embed.php b/src/Embed.php index 8a7f2f1f..05b9fbaf 100644 --- a/src/Embed.php +++ b/src/Embed.php @@ -99,6 +99,12 @@ private function extract(RequestInterface $request, ResponseInterface $response, return $extractor; } + if (!isValidUrl((string) $redirectUri)) { + throw new InvalidArgumentException(sprintf( + 'Access to this URL is blocked for security reasons (%s)', + $redirectUri + )); + } $request = $this->crawler->createRequest('GET', (string) $redirectUri); $response = $this->crawler->sendRequest($request); From 35c8560881ebed57d3be95ddcd003e4ad547275e Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 10:56:42 -0500 Subject: [PATCH 08/11] Replace CURLOPT_IPRESOLVE with manual CURLOPT_RESOLVE to avoid DNS rebinding; Add a getValidUrlIps() wrapper to return an array of valid IPs, relevant for URLs with redirects --- src/Http/CurlDispatcher.php | 27 ++++++++++++++++++-- src/functions.php | 49 ++++++++++++++++++++++++++----------- 2 files changed, 60 insertions(+), 16 deletions(-) diff --git a/src/Http/CurlDispatcher.php b/src/Http/CurlDispatcher.php index 0658c0fd..01b2721b 100644 --- a/src/Http/CurlDispatcher.php +++ b/src/Http/CurlDispatcher.php @@ -9,6 +9,8 @@ use Psr\Http\Message\ResponseInterface; use Psr\Http\Message\StreamFactoryInterface; use Psr\Http\Message\StreamInterface; +use function Embed\getValidUrlIps; +use InvalidArgumentException; /** * Class to fetch html pages @@ -115,7 +117,23 @@ public static function fetch(array $settings, ResponseFactoryInterface $response private function __construct(array $settings, RequestInterface $request, ?StreamFactoryInterface $streamFactory = null) { $this->request = $request; - $this->curl = curl_init((string) $request->getUri()); + $uri = $request->getUri(); + $url = (string) $uri; + $ips = getValidUrlIps($url); + + if ($ips === []) { + throw new InvalidArgumentException(sprintf('Access to this URL is blocked for security reasons (%s)', $url)); + } + + $host = $uri->getHost(); + $port = $uri->getPort() ?? ($uri->getScheme() === 'https' ? 443 : 80); + $resolveHost = strpos($host, ':') === false ? $host : "[{$host}]"; + $resolveAddresses = array_values(array_map( + static fn (string $ip): string => strpos($ip, ':') === false ? $ip : "[{$ip}]", + $ips + )); + + $this->curl = curl_init($url); $this->settings = $settings; $this->streamFactory = $streamFactory ?? FactoryDiscovery::getStreamFactory(); @@ -134,13 +152,18 @@ private function __construct(array $settings, RequestInterface $request, ?Stream CURLOPT_CAINFO => CaBundle::getSystemCaRootBundlePath(), CURLOPT_AUTOREFERER => true, CURLOPT_FOLLOWLOCATION => $settings['follow_location'] ?? true, - CURLOPT_IPRESOLVE => CURL_IPRESOLVE_V4, CURLOPT_USERAGENT => $settings['user_agent'] ?? $request->getHeaderLine('User-Agent'), CURLOPT_COOKIEJAR => $cookies, CURLOPT_COOKIEFILE => $cookies, CURLOPT_HEADERFUNCTION => [$this, 'writeHeader'], CURLOPT_WRITEFUNCTION => [$this, 'writeBody'], ]); + + curl_setopt( + $this->curl, + CURLOPT_RESOLVE, + [sprintf('%s:%d:%s', $resolveHost, $port, implode(',', $resolveAddresses))] + ); } private function getResponse(ResponseFactoryInterface $responseFactory): ResponseInterface diff --git a/src/functions.php b/src/functions.php index 86591c8a..856d496f 100644 --- a/src/functions.php +++ b/src/functions.php @@ -74,15 +74,26 @@ function resolveUri(UriInterface $base, UriInterface $uri): UriInterface } /** - * Check if the DNS associated with the URL is valid (SSRF). + * Return the public IP addresses associated with an HTTP(S) URL. + * + * @return string[] An empty array means that the URL is not safe to fetch. */ -function isValidUrl(string $url): bool +function getValidUrlIps(string $url): array { - // First, use standard PHP URL filtering. - if (!filter_var($url, FILTER_VALIDATE_URL)) { - return false; + if (filter_var($url, FILTER_VALIDATE_URL) === false) { + return []; } + $parts = parse_url($url); + if ($parts === false) { + return []; + } + + $scheme = strtolower($parts['scheme'] ?? ''); + if (!in_array($scheme, ['http', 'https'], true)) { + return []; + } + $host = $parts['host'] ?? ''; // Normalize IPv6 literal formatting wrapping (e.g., [::1] -> ::1) if (strpos($host, '[') === 0 && strpos($host, ']') === (strlen($host) - 1)) { @@ -90,7 +101,8 @@ function isValidUrl(string $url): bool } // Collect all IPs the host resolves to. $ips = []; - if (filter_var($host, FILTER_VALIDATE_IP)) { + $ips = []; + if (filter_var($host, FILTER_VALIDATE_IP) !== false) { // The host is already a direct IP address literal. $ips[] = $host; } @@ -100,27 +112,27 @@ function isValidUrl(string $url): bool $dnsAAAA = @dns_get_record($host, DNS_AAAA); if (is_array($dnsA)) { foreach ($dnsA as $record) { - if (isset($record['ip'])) { - $ips[] = $record['ip']; + if (isset($record['ip']) && is_string($record['ip'])) { + $ips[] = $record['ip']; } } } if (is_array($dnsAAAA)) { foreach ($dnsAAAA as $record) { - if (isset($record['ipv6'])) { + if (isset($record['ipv6']) && is_string($record['ipv6'])) { $ips[] = $record['ipv6']; } } } // Fallback. If dns_get_record fails but gethostbyname finds something. - if (empty($ips)) { + if ($ips === []) { $fallbackIp = @gethostbyname($host); if ($fallbackIp !== $host) { $ips[] = $fallbackIp; } else { // If DNS resolution fails, treat URL as invalid. - return false; + return []; } } } @@ -135,12 +147,21 @@ function isValidUrl(string $url): bool FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE | FILTER_FLAG_GLOBAL_RANGE ); - if (!$isPublic) { + if ($isPublic === false) { // The IP belongs to a restricted/private range. - return false; + return []; } } - return true; + + return array_values(array_unique($ips)); +} + +/** + * Check if the DNS associated with the URL is valid (SSRF). + */ +function isValidUrl(string $url): bool +{ + return getValidUrlIps($url) !== []; } function isHttp(string $uri): bool From d6f6915d4f858012783b71a6317e3edb2540676c Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 10:58:56 -0500 Subject: [PATCH 09/11] Restrict CURLOPT_PROTOCOLS/CURLOPT_REDIR_PROTOCOLS to HTTP(S) --- src/Http/CurlDispatcher.php | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/Http/CurlDispatcher.php b/src/Http/CurlDispatcher.php index 01b2721b..e220aa7d 100644 --- a/src/Http/CurlDispatcher.php +++ b/src/Http/CurlDispatcher.php @@ -152,6 +152,8 @@ private function __construct(array $settings, RequestInterface $request, ?Stream CURLOPT_CAINFO => CaBundle::getSystemCaRootBundlePath(), CURLOPT_AUTOREFERER => true, CURLOPT_FOLLOWLOCATION => $settings['follow_location'] ?? true, + CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, + CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_USERAGENT => $settings['user_agent'] ?? $request->getHeaderLine('User-Agent'), CURLOPT_COOKIEJAR => $cookies, CURLOPT_COOKIEFILE => $cookies, From 3550e833eb20ae191f52e900198208b13fd00d93 Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 11:05:05 -0500 Subject: [PATCH 10/11] Set CURLOPT_FOLLOWLOCATION to false to prevent internal redirects --- src/Http/CurlDispatcher.php | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/Http/CurlDispatcher.php b/src/Http/CurlDispatcher.php index e220aa7d..06b0a018 100644 --- a/src/Http/CurlDispatcher.php +++ b/src/Http/CurlDispatcher.php @@ -151,7 +151,7 @@ private function __construct(array $settings, RequestInterface $request, ?Stream CURLOPT_ENCODING => '', CURLOPT_CAINFO => CaBundle::getSystemCaRootBundlePath(), CURLOPT_AUTOREFERER => true, - CURLOPT_FOLLOWLOCATION => $settings['follow_location'] ?? true, + CURLOPT_FOLLOWLOCATION => false, CURLOPT_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_REDIR_PROTOCOLS => CURLPROTO_HTTP | CURLPROTO_HTTPS, CURLOPT_USERAGENT => $settings['user_agent'] ?? $request->getHeaderLine('User-Agent'), From 0810bef8e8b0a0603949d8f06f1f375332224c88 Mon Sep 17 00:00:00 2001 From: jmf3658 Date: Mon, 5 Oct 2026 11:11:57 -0500 Subject: [PATCH 11/11] Implement bounded HTTP Location handling for redirects --- src/Embed.php | 47 +++++++++++++++++++++++++++++++++++++++-------- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/src/Embed.php b/src/Embed.php index 05b9fbaf..625e95f4 100644 --- a/src/Embed.php +++ b/src/Embed.php @@ -7,9 +7,12 @@ use InvalidArgumentException; use Psr\Http\Message\RequestInterface; use Psr\Http\Message\ResponseInterface; +use Psr\Http\Message\UriInterface; class Embed { + private const MAX_HTTP_REDIRECTS = 10; + private Crawler $crawler; private ExtractorFactory $extractorFactory; @@ -80,8 +83,20 @@ public function setSettings(array $settings): void $this->extractorFactory->setSettings($settings); } - private function extract(RequestInterface $request, ResponseInterface $response, bool $redirect = true): Extractor + private function extract(RequestInterface $request, ResponseInterface $response, bool $redirect = true, int $httpRedirects = 0): Extractor { + $httpRedirectUri = $this->getHttpRedirectUri($request, $response); + if ($httpRedirectUri !== null) { + if ($httpRedirects >= self::MAX_HTTP_REDIRECTS) { + throw new InvalidArgumentException('Maximum number of HTTP redirects exceeded'); + } + + $request = $this->createSafeRequest($httpRedirectUri); + $response = $this->crawler->sendRequest($request); + + return $this->extract($request, $response, $redirect, $httpRedirects + 1); + } + $uri = $this->crawler->getResponseUri($response); if ($uri === null) { $uri = $request->getUri(); @@ -99,13 +114,7 @@ private function extract(RequestInterface $request, ResponseInterface $response, return $extractor; } - if (!isValidUrl((string) $redirectUri)) { - throw new InvalidArgumentException(sprintf( - 'Access to this URL is blocked for security reasons (%s)', - $redirectUri - )); - } - $request = $this->crawler->createRequest('GET', (string) $redirectUri); + $request = $this->createSafeRequest($redirectUri); $response = $this->crawler->sendRequest($request); return $this->extract($request, $response, false); @@ -121,4 +130,26 @@ private function mustRedirect(Extractor $extractor): bool $redirectUri = $extractor->redirect; return $redirectUri instanceof \Psr\Http\Message\UriInterface; } + + private function getHttpRedirectUri(RequestInterface $request, ResponseInterface $response): ?UriInterface + { + $status = $response->getStatusCode(); + $location = $response->getHeaderLine('Location'); + + if ($status < 300 || $status >= 400 || $location === '') { + return null; + } + + return resolveUri($request->getUri(), $this->crawler->createUri($location)); + } + + private function createSafeRequest(UriInterface $uri): RequestInterface + { + $url = (string) $uri; + if (!isValidUrl($url)) { + throw new InvalidArgumentException(sprintf('Access to this URL is blocked for security reasons (%s)', $url)); + } + + return $this->crawler->createRequest('GET', $url); + } }