|
@@ -4,6 +4,7 @@ declare(strict_types=1);
|
|
|
|
|
|
|
|
namespace App\Tests\Unit\Http;
|
|
namespace App\Tests\Unit\Http;
|
|
|
|
|
|
|
|
|
|
+use App\Domain\Ip\InvalidCidrException;
|
|
|
use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
|
|
use App\Infrastructure\Http\Middleware\InternalNetworkMiddleware;
|
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
use PHPUnit\Framework\Attributes\DataProvider;
|
|
|
use PHPUnit\Framework\TestCase;
|
|
use PHPUnit\Framework\TestCase;
|
|
@@ -14,25 +15,29 @@ use Slim\Psr7\Factory\ResponseFactory;
|
|
|
use Slim\Psr7\Factory\ServerRequestFactory;
|
|
use Slim\Psr7\Factory\ServerRequestFactory;
|
|
|
|
|
|
|
|
/**
|
|
/**
|
|
|
- * Network gate must let RFC1918 + loopback through, 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.
|
|
|
|
|
|
|
+ * 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
|
|
final class InternalNetworkMiddlewareTest extends TestCase
|
|
|
{
|
|
{
|
|
|
/**
|
|
/**
|
|
|
* @return iterable<string, array{string, bool}>
|
|
* @return iterable<string, array{string, bool}>
|
|
|
*/
|
|
*/
|
|
|
- public static function addressProvider(): iterable
|
|
|
|
|
|
|
+ public static function defaultAddressProvider(): iterable
|
|
|
{
|
|
{
|
|
|
yield 'loopback v4' => ['127.0.0.1', true];
|
|
yield 'loopback v4' => ['127.0.0.1', true];
|
|
|
yield 'loopback v6' => ['::1', true];
|
|
yield 'loopback v6' => ['::1', true];
|
|
|
- yield 'rfc1918 10/8' => ['10.5.6.7', true];
|
|
|
|
|
- yield 'rfc1918 172.16/12' => ['172.16.42.1', true];
|
|
|
|
|
- yield 'rfc1918 172.31/12 (boundary)' => ['172.31.255.255', 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 'just outside 172.16/12' => ['172.32.0.1', false];
|
|
|
- yield 'rfc1918 192.168/16' => ['192.168.1.1', true];
|
|
|
|
|
|
|
+ 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 1.1.1.1' => ['1.1.1.1', false];
|
|
|
yield 'public v4' => ['203.0.113.4', false];
|
|
yield 'public v4' => ['203.0.113.4', false];
|
|
|
yield 'public v6' => ['2001:db8::1', false];
|
|
yield 'public v6' => ['2001:db8::1', false];
|
|
@@ -40,36 +45,93 @@ final class InternalNetworkMiddlewareTest extends TestCase
|
|
|
yield 'empty' => ['', false];
|
|
yield 'empty' => ['', false];
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
- #[DataProvider('addressProvider')]
|
|
|
|
|
- public function testNetworkGate(string $remoteAddr, bool $shouldPass): void
|
|
|
|
|
|
|
+ #[DataProvider('defaultAddressProvider')]
|
|
|
|
|
+ public function testDefaultLoopbackOnlyGate(string $remoteAddr, bool $shouldPass): void
|
|
|
{
|
|
{
|
|
|
- $middleware = new InternalNetworkMiddleware(new ResponseFactory());
|
|
|
|
|
|
|
+ $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(
|
|
$req = (new ServerRequestFactory())->createServerRequest(
|
|
|
'POST',
|
|
'POST',
|
|
|
'/internal/jobs/tick',
|
|
'/internal/jobs/tick',
|
|
|
['REMOTE_ADDR' => $remoteAddr],
|
|
['REMOTE_ADDR' => $remoteAddr],
|
|
|
);
|
|
);
|
|
|
-
|
|
|
|
|
$passthrough = new class () implements RequestHandlerInterface {
|
|
$passthrough = new class () implements RequestHandlerInterface {
|
|
|
public bool $reached = false;
|
|
public bool $reached = false;
|
|
|
|
|
|
|
|
public function handle(ServerRequestInterface $request): ResponseInterface
|
|
public function handle(ServerRequestInterface $request): ResponseInterface
|
|
|
{
|
|
{
|
|
|
$this->reached = true;
|
|
$this->reached = true;
|
|
|
- $factory = new ResponseFactory();
|
|
|
|
|
|
|
|
|
|
- return $factory->createResponse(204);
|
|
|
|
|
|
|
+ return (new ResponseFactory())->createResponse(204);
|
|
|
}
|
|
}
|
|
|
};
|
|
};
|
|
|
|
|
|
|
|
$response = $middleware->process($req, $passthrough);
|
|
$response = $middleware->process($req, $passthrough);
|
|
|
|
|
|
|
|
if ($shouldPass) {
|
|
if ($shouldPass) {
|
|
|
- self::assertSame(204, $response->getStatusCode());
|
|
|
|
|
|
|
+ self::assertSame(204, $response->getStatusCode(), $remoteAddr . ' should be allowed');
|
|
|
self::assertTrue($passthrough->reached);
|
|
self::assertTrue($passthrough->reached);
|
|
|
} else {
|
|
} else {
|
|
|
- self::assertSame(404, $response->getStatusCode());
|
|
|
|
|
|
|
+ self::assertSame(404, $response->getStatusCode(), $remoteAddr . ' should be denied');
|
|
|
self::assertFalse($passthrough->reached, 'handler must not see disallowed sources');
|
|
self::assertFalse($passthrough->reached, 'handler must not see disallowed sources');
|
|
|
}
|
|
}
|
|
|
}
|
|
}
|