InternalNetworkMiddlewareTest.php 5.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138
  1. <?php
  2. declare(strict_types=1);
  3. namespace App\Tests\Unit\Http;
  4. use App\Domain\Ip\InvalidCidrException;
  5. use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
  6. use PHPUnit\Framework\Attributes\DataProvider;
  7. use PHPUnit\Framework\TestCase;
  8. use Psr\Http\Message\ResponseInterface;
  9. use Psr\Http\Message\ServerRequestInterface;
  10. use Psr\Http\Server\RequestHandlerInterface;
  11. use Slim\Psr7\Factory\ResponseFactory;
  12. use Slim\Psr7\Factory\ServerRequestFactory;
  13. /**
  14. * Network gate must let only loopback through by default (SEC_REVIEW F25),
  15. * 404 everything else, and never leak via 403 (which would tell attackers
  16. * the endpoint exists). The handler is stubbed to a marker response so we
  17. * can confirm whether the middleware short-circuited or passed through.
  18. *
  19. * RFC1918 is no longer in the default allowlist: an operator who needs
  20. * non-loopback sources must opt in via `INTERNAL_CIDR_ALLOWLIST`.
  21. */
  22. final class InternalNetworkMiddlewareTest extends TestCase
  23. {
  24. /**
  25. * @return iterable<string, array{string, bool}>
  26. */
  27. public static function defaultAddressProvider(): iterable
  28. {
  29. yield 'loopback v4' => ['127.0.0.1', true];
  30. yield 'loopback v6' => ['::1', true];
  31. // RFC1918 sources are no longer allowed by default — F25.
  32. yield 'rfc1918 10/8 rejected by default' => ['10.5.6.7', false];
  33. yield 'rfc1918 172.16/12 rejected by default' => ['172.16.42.1', false];
  34. yield 'rfc1918 172.31/12 (boundary) rejected by default' => ['172.31.255.255', false];
  35. yield 'just outside 172.16/12' => ['172.32.0.1', false];
  36. yield 'rfc1918 192.168/16 rejected by default' => ['192.168.1.1', false];
  37. yield 'public 1.1.1.1' => ['1.1.1.1', false];
  38. yield 'public v4' => ['203.0.113.4', false];
  39. yield 'public v6' => ['2001:db8::1', false];
  40. yield 'malformed' => ['not-an-ip', false];
  41. yield 'empty' => ['', false];
  42. }
  43. #[DataProvider('defaultAddressProvider')]
  44. public function testDefaultLoopbackOnlyGate(string $remoteAddr, bool $shouldPass): void
  45. {
  46. $this->assertGate(new InternalNetworkMiddleware(new ResponseFactory()), $remoteAddr, $shouldPass);
  47. }
  48. public function testNullAllowedCidrsFallsBackToLoopbackDefault(): void
  49. {
  50. $middleware = new InternalNetworkMiddleware(new ResponseFactory(), null);
  51. $this->assertGate($middleware, '127.0.0.1', true);
  52. $this->assertGate($middleware, '10.0.0.1', false);
  53. }
  54. public function testEmptyAllowedCidrsFallsBackToLoopbackDefault(): void
  55. {
  56. $middleware = new InternalNetworkMiddleware(new ResponseFactory(), []);
  57. $this->assertGate($middleware, '::1', true);
  58. $this->assertGate($middleware, '192.168.1.1', false);
  59. }
  60. public function testCustomAllowlistAdmitsConfiguredSourcesOnly(): void
  61. {
  62. // Operator opt-in: extend allowlist to a single bridge IP.
  63. $middleware = new InternalNetworkMiddleware(
  64. new ResponseFactory(),
  65. ['127.0.0.1/32', '::1/128', '172.20.0.5/32'],
  66. );
  67. $this->assertGate($middleware, '127.0.0.1', true);
  68. $this->assertGate($middleware, '::1', true);
  69. $this->assertGate($middleware, '172.20.0.5', true);
  70. // Still narrow — 172.20.0.6 is one off and rejected.
  71. $this->assertGate($middleware, '172.20.0.6', false);
  72. // The wider 10/8 block was not configured.
  73. $this->assertGate($middleware, '10.0.0.1', false);
  74. }
  75. public function testInvalidCidrInConstructorFailsClosed(): void
  76. {
  77. $this->expectException(\Throwable::class);
  78. new InternalNetworkMiddleware(new ResponseFactory(), ['not-a-cidr']);
  79. }
  80. public function testParseCidrListAcceptsMixedSeparators(): void
  81. {
  82. self::assertSame(
  83. ['127.0.0.1/32', '::1/128', '10.0.0.0/8'],
  84. InternalNetworkMiddleware::parseCidrList('127.0.0.1/32, ::1/128 10.0.0.0/8'),
  85. );
  86. }
  87. public function testParseCidrListReturnsEmptyForBlank(): void
  88. {
  89. self::assertSame([], InternalNetworkMiddleware::parseCidrList(''));
  90. self::assertSame([], InternalNetworkMiddleware::parseCidrList(' '));
  91. }
  92. public function testParseCidrListThrowsOnInvalidEntry(): void
  93. {
  94. $this->expectException(InvalidCidrException::class);
  95. InternalNetworkMiddleware::parseCidrList('127.0.0.1/32, garbage');
  96. }
  97. private function assertGate(InternalNetworkMiddleware $middleware, string $remoteAddr, bool $shouldPass): void
  98. {
  99. $req = (new ServerRequestFactory())->createServerRequest(
  100. 'POST',
  101. '/internal/jobs/tick',
  102. ['REMOTE_ADDR' => $remoteAddr],
  103. );
  104. $passthrough = new class () implements RequestHandlerInterface {
  105. public bool $reached = false;
  106. public function handle(ServerRequestInterface $request): ResponseInterface
  107. {
  108. $this->reached = true;
  109. return (new ResponseFactory())->createResponse(204);
  110. }
  111. };
  112. $response = $middleware->process($req, $passthrough);
  113. if ($shouldPass) {
  114. self::assertSame(204, $response->getStatusCode(), $remoteAddr . ' should be allowed');
  115. self::assertTrue($passthrough->reached);
  116. } else {
  117. self::assertSame(404, $response->getStatusCode(), $remoteAddr . ' should be denied');
  118. self::assertFalse($passthrough->reached, 'handler must not see disallowed sources');
  119. }
  120. }
  121. }