*/ public static function blockedHosts(): iterable { // Literal blocked names. yield 'localhost' => ['https://localhost/foo']; yield 'localhost:8080' => ['https://localhost:8080/foo']; yield 'metadata.google.internal' => ['https://metadata.google.internal/computeMetadata/v1/']; yield 'cloud metadata IP' => ['https://169.254.169.254/latest/meta-data/']; // IPv4 ranges. yield 'loopback v4' => ['https://127.0.0.1/']; yield 'rfc1918 10/8' => ['https://10.0.0.1/']; yield 'rfc1918 172.16/12 inside' => ['https://172.16.0.1/']; yield 'rfc1918 172.31/12 (boundary)' => ['https://172.31.255.255/']; yield 'rfc1918 192.168/16' => ['https://192.168.1.1/']; yield 'cgnat 100.64/10' => ['https://100.64.0.1/']; yield 'multicast' => ['https://224.0.0.1/']; yield 'all-zero unspec v4' => ['https://0.0.0.0/']; // IPv6 ranges. Square brackets in URL hosts are stripped // before lookup so the guard sees the literal address. yield 'loopback v6' => ['https://[::1]/']; yield 'unspec v6' => ['https://[::]/']; yield 'link-local v6' => ['https://[fe80::1]/']; yield 'unique-local v6' => ['https://[fc00::1]/']; yield 'multicast v6' => ['https://[ff02::1]/']; } #[DataProvider('blockedHosts')] public function testBlockedHostThrows(string $url): void { $request = new Request('GET', $url); $this->expectException(TransferException::class); PrivateHostGuardMiddleware::assertHostAllowed($request); } /** * @return iterable */ public static function allowedHosts(): iterable { yield 'db-ip download host' => ['https://download.db-ip.com/free/dbip-country-lite-2026-04.mmdb.gz']; yield 'maxmind download host' => ['https://download.maxmind.com/app/geoip_download']; yield 'ipinfo host' => ['https://ipinfo.io/data/free/country.mmdb']; // 172.32.x.x is just outside RFC1918's 172.16/12 range — must pass. yield 'just outside 172.16/12' => ['https://172.32.0.1/']; // Public DNS resolver IP (should pass — it's a public address). yield 'public 1.1.1.1' => ['https://1.1.1.1/']; // Public IPv6 (RFC 3849 docs prefix; not in any blocked range). yield 'public v6 2001:db8' => ['https://[2001:db8::1]/']; } #[DataProvider('allowedHosts')] public function testAllowedHostPasses(string $url): void { $request = new Request('GET', $url); PrivateHostGuardMiddleware::assertHostAllowed($request); // No assertion needed — absence of an exception is success. $this->expectNotToPerformAssertions(); } public function testFactoryProducesMiddlewareThatGuardsBeforeHandler(): void { // The factory closure should pass through allowed hosts and // block disallowed hosts — without ever invoking the handler // for the blocked branch. $handlerCalled = false; $handler = static function (RequestInterface $request, array $options) use (&$handlerCalled): mixed { $handlerCalled = true; return new \GuzzleHttp\Psr7\Response(200); }; $middleware = PrivateHostGuardMiddleware::factory()($handler); // Allowed: handler runs. $middleware(new Request('GET', 'https://download.db-ip.com/'), []); self::assertTrue($handlerCalled); // Blocked: throw before reaching the handler. $handlerCalled = false; $threw = false; try { $middleware(new Request('GET', 'https://169.254.169.254/'), []); } catch (TransferException) { $threw = true; } self::assertTrue($threw, 'expected guard to throw for metadata host'); self::assertFalse($handlerCalled, 'handler must not be invoked for blocked host'); } public function testEmptyHostIsRejected(): void { // Construct a Request with no Host part by going through Uri. $uri = (new \GuzzleHttp\Psr7\Uri('https://download.db-ip.com/'))->withHost(''); $request = (new Request('GET', 'https://download.db-ip.com/'))->withUri($uri); $this->expectException(TransferException::class); PrivateHostGuardMiddleware::assertHostAllowed($request); } }