| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\Enrichment;
- use App\Infrastructure\Enrichment\Downloaders\PrivateHostGuardMiddleware;
- use GuzzleHttp\Exception\TransferException;
- use GuzzleHttp\Psr7\Request;
- use PHPUnit\Framework\Attributes\DataProvider;
- use PHPUnit\Framework\TestCase;
- use Psr\Http\Message\RequestInterface;
- /**
- * SEC_REVIEW F50 — defence-in-depth host guard around the GeoIP
- * downloader's Guzzle client. Refuses to dial loopback, link-local,
- * RFC1918, CGNAT, multicast, instance-metadata hostnames, and
- * IPv6 link-local / unique-local / loopback / multicast / unspec
- * addresses. Public production hosts (`download.db-ip.com`,
- * `download.maxmind.com`, `ipinfo.io`) pass through.
- */
- final class PrivateHostGuardMiddlewareTest extends TestCase
- {
- /**
- * @return iterable<string, array{string}>
- */
- 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<string, array{string}>
- */
- 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);
- }
- }
|