| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138 |
- <?php
- declare(strict_types=1);
- namespace App\Tests\Unit\Http;
- use App\Domain\Ip\InvalidCidrException;
- use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
- use PHPUnit\Framework\Attributes\DataProvider;
- use PHPUnit\Framework\TestCase;
- use Psr\Http\Message\ResponseInterface;
- use Psr\Http\Message\ServerRequestInterface;
- use Psr\Http\Server\RequestHandlerInterface;
- use Slim\Psr7\Factory\ResponseFactory;
- use Slim\Psr7\Factory\ServerRequestFactory;
- /**
- * Network gate must let only loopback through by default (SEC_REVIEW F25),
- * 404 everything else, and never leak via 403 (which would tell attackers
- * the endpoint exists). The handler is stubbed to a marker response so we
- * can confirm whether the middleware short-circuited or passed through.
- *
- * RFC1918 is no longer in the default allowlist: an operator who needs
- * non-loopback sources must opt in via `INTERNAL_CIDR_ALLOWLIST`.
- */
- final class InternalNetworkMiddlewareTest extends TestCase
- {
- /**
- * @return iterable<string, array{string, bool}>
- */
- public static function defaultAddressProvider(): iterable
- {
- yield 'loopback v4' => ['127.0.0.1', true];
- yield 'loopback v6' => ['::1', true];
- // RFC1918 sources are no longer allowed by default — F25.
- yield 'rfc1918 10/8 rejected by default' => ['10.5.6.7', false];
- yield 'rfc1918 172.16/12 rejected by default' => ['172.16.42.1', false];
- yield 'rfc1918 172.31/12 (boundary) rejected by default' => ['172.31.255.255', false];
- yield 'just outside 172.16/12' => ['172.32.0.1', false];
- yield 'rfc1918 192.168/16 rejected by default' => ['192.168.1.1', false];
- yield 'public 1.1.1.1' => ['1.1.1.1', false];
- yield 'public v4' => ['203.0.113.4', false];
- yield 'public v6' => ['2001:db8::1', false];
- yield 'malformed' => ['not-an-ip', false];
- yield 'empty' => ['', false];
- }
- #[DataProvider('defaultAddressProvider')]
- public function testDefaultLoopbackOnlyGate(string $remoteAddr, bool $shouldPass): void
- {
- $this->assertGate(new InternalNetworkMiddleware(new ResponseFactory()), $remoteAddr, $shouldPass);
- }
- public function testNullAllowedCidrsFallsBackToLoopbackDefault(): void
- {
- $middleware = new InternalNetworkMiddleware(new ResponseFactory(), null);
- $this->assertGate($middleware, '127.0.0.1', true);
- $this->assertGate($middleware, '10.0.0.1', false);
- }
- public function testEmptyAllowedCidrsFallsBackToLoopbackDefault(): void
- {
- $middleware = new InternalNetworkMiddleware(new ResponseFactory(), []);
- $this->assertGate($middleware, '::1', true);
- $this->assertGate($middleware, '192.168.1.1', false);
- }
- public function testCustomAllowlistAdmitsConfiguredSourcesOnly(): void
- {
- // Operator opt-in: extend allowlist to a single bridge IP.
- $middleware = new InternalNetworkMiddleware(
- new ResponseFactory(),
- ['127.0.0.1/32', '::1/128', '172.20.0.5/32'],
- );
- $this->assertGate($middleware, '127.0.0.1', true);
- $this->assertGate($middleware, '::1', true);
- $this->assertGate($middleware, '172.20.0.5', true);
- // Still narrow — 172.20.0.6 is one off and rejected.
- $this->assertGate($middleware, '172.20.0.6', false);
- // The wider 10/8 block was not configured.
- $this->assertGate($middleware, '10.0.0.1', false);
- }
- public function testInvalidCidrInConstructorFailsClosed(): void
- {
- $this->expectException(\Throwable::class);
- new InternalNetworkMiddleware(new ResponseFactory(), ['not-a-cidr']);
- }
- public function testParseCidrListAcceptsMixedSeparators(): void
- {
- self::assertSame(
- ['127.0.0.1/32', '::1/128', '10.0.0.0/8'],
- InternalNetworkMiddleware::parseCidrList('127.0.0.1/32, ::1/128 10.0.0.0/8'),
- );
- }
- public function testParseCidrListReturnsEmptyForBlank(): void
- {
- self::assertSame([], InternalNetworkMiddleware::parseCidrList(''));
- self::assertSame([], InternalNetworkMiddleware::parseCidrList(' '));
- }
- public function testParseCidrListThrowsOnInvalidEntry(): void
- {
- $this->expectException(InvalidCidrException::class);
- InternalNetworkMiddleware::parseCidrList('127.0.0.1/32, garbage');
- }
- private function assertGate(InternalNetworkMiddleware $middleware, string $remoteAddr, bool $shouldPass): void
- {
- $req = (new ServerRequestFactory())->createServerRequest(
- 'POST',
- '/internal/jobs/tick',
- ['REMOTE_ADDR' => $remoteAddr],
- );
- $passthrough = new class () implements RequestHandlerInterface {
- public bool $reached = false;
- public function handle(ServerRequestInterface $request): ResponseInterface
- {
- $this->reached = true;
- return (new ResponseFactory())->createResponse(204);
- }
- };
- $response = $middleware->process($req, $passthrough);
- if ($shouldPass) {
- self::assertSame(204, $response->getStatusCode(), $remoteAddr . ' should be allowed');
- self::assertTrue($passthrough->reached);
- } else {
- self::assertSame(404, $response->getStatusCode(), $remoteAddr . ' should be denied');
- self::assertFalse($passthrough->reached, 'handler must not see disallowed sources');
- }
- }
- }
|