1
0

PrivateHostGuardMiddlewareTest.php 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Enrichment;
  4. use App\Infrastructure\Enrichment\Downloaders\PrivateHostGuardMiddleware;
  5. use GuzzleHttp\Exception\TransferException;
  6. use GuzzleHttp\Psr7\Request;
  7. use PHPUnit\Framework\Attributes\DataProvider;
  8. use PHPUnit\Framework\TestCase;
  9. use Psr\Http\Message\RequestInterface;
  10. /**
  11. * SEC_REVIEW F50 — defence-in-depth host guard around the GeoIP
  12. * downloader's Guzzle client. Refuses to dial loopback, link-local,
  13. * RFC1918, CGNAT, multicast, instance-metadata hostnames, and
  14. * IPv6 link-local / unique-local / loopback / multicast / unspec
  15. * addresses. Public production hosts (`download.db-ip.com`,
  16. * `download.maxmind.com`, `ipinfo.io`) pass through.
  17. */
  18. final class PrivateHostGuardMiddlewareTest extends TestCase
  19. {
  20. /**
  21. * @return iterable<string, array{string}>
  22. */
  23. public static function blockedHosts(): iterable
  24. {
  25. // Literal blocked names.
  26. yield 'localhost' => ['https://localhost/foo'];
  27. yield 'localhost:8080' => ['https://localhost:8080/foo'];
  28. yield 'metadata.google.internal' => ['https://metadata.google.internal/computeMetadata/v1/'];
  29. yield 'cloud metadata IP' => ['https://169.254.169.254/latest/meta-data/'];
  30. // IPv4 ranges.
  31. yield 'loopback v4' => ['https://127.0.0.1/'];
  32. yield 'rfc1918 10/8' => ['https://10.0.0.1/'];
  33. yield 'rfc1918 172.16/12 inside' => ['https://172.16.0.1/'];
  34. yield 'rfc1918 172.31/12 (boundary)' => ['https://172.31.255.255/'];
  35. yield 'rfc1918 192.168/16' => ['https://192.168.1.1/'];
  36. yield 'cgnat 100.64/10' => ['https://100.64.0.1/'];
  37. yield 'multicast' => ['https://224.0.0.1/'];
  38. yield 'all-zero unspec v4' => ['https://0.0.0.0/'];
  39. // IPv6 ranges. Square brackets in URL hosts are stripped
  40. // before lookup so the guard sees the literal address.
  41. yield 'loopback v6' => ['https://[::1]/'];
  42. yield 'unspec v6' => ['https://[::]/'];
  43. yield 'link-local v6' => ['https://[fe80::1]/'];
  44. yield 'unique-local v6' => ['https://[fc00::1]/'];
  45. yield 'multicast v6' => ['https://[ff02::1]/'];
  46. }
  47. #[DataProvider('blockedHosts')]
  48. public function testBlockedHostThrows(string $url): void
  49. {
  50. $request = new Request('GET', $url);
  51. $this->expectException(TransferException::class);
  52. PrivateHostGuardMiddleware::assertHostAllowed($request);
  53. }
  54. /**
  55. * @return iterable<string, array{string}>
  56. */
  57. public static function allowedHosts(): iterable
  58. {
  59. yield 'db-ip download host' => ['https://download.db-ip.com/free/dbip-country-lite-2026-04.mmdb.gz'];
  60. yield 'maxmind download host' => ['https://download.maxmind.com/app/geoip_download'];
  61. yield 'ipinfo host' => ['https://ipinfo.io/data/free/country.mmdb'];
  62. // 172.32.x.x is just outside RFC1918's 172.16/12 range — must pass.
  63. yield 'just outside 172.16/12' => ['https://172.32.0.1/'];
  64. // Public DNS resolver IP (should pass — it's a public address).
  65. yield 'public 1.1.1.1' => ['https://1.1.1.1/'];
  66. // Public IPv6 (RFC 3849 docs prefix; not in any blocked range).
  67. yield 'public v6 2001:db8' => ['https://[2001:db8::1]/'];
  68. }
  69. #[DataProvider('allowedHosts')]
  70. public function testAllowedHostPasses(string $url): void
  71. {
  72. $request = new Request('GET', $url);
  73. PrivateHostGuardMiddleware::assertHostAllowed($request);
  74. // No assertion needed — absence of an exception is success.
  75. $this->expectNotToPerformAssertions();
  76. }
  77. public function testFactoryProducesMiddlewareThatGuardsBeforeHandler(): void
  78. {
  79. // The factory closure should pass through allowed hosts and
  80. // block disallowed hosts — without ever invoking the handler
  81. // for the blocked branch.
  82. $handlerCalled = false;
  83. $handler = static function (RequestInterface $request, array $options) use (&$handlerCalled): mixed {
  84. $handlerCalled = true;
  85. return new \GuzzleHttp\Psr7\Response(200);
  86. };
  87. $middleware = PrivateHostGuardMiddleware::factory()($handler);
  88. // Allowed: handler runs.
  89. $middleware(new Request('GET', 'https://download.db-ip.com/'), []);
  90. self::assertTrue($handlerCalled);
  91. // Blocked: throw before reaching the handler.
  92. $handlerCalled = false;
  93. $threw = false;
  94. try {
  95. $middleware(new Request('GET', 'https://169.254.169.254/'), []);
  96. } catch (TransferException) {
  97. $threw = true;
  98. }
  99. self::assertTrue($threw, 'expected guard to throw for metadata host');
  100. self::assertFalse($handlerCalled, 'handler must not be invoked for blocked host');
  101. }
  102. public function testEmptyHostIsRejected(): void
  103. {
  104. // Construct a Request with no Host part by going through Uri.
  105. $uri = (new \GuzzleHttp\Psr7\Uri('https://download.db-ip.com/'))->withHost('');
  106. $request = (new Request('GET', 'https://download.db-ip.com/'))->withUri($uri);
  107. $this->expectException(TransferException::class);
  108. PrivateHostGuardMiddleware::assertHostAllowed($request);
  109. }
  110. }