diff --git a/packages/router/src/ValidSignature.php b/packages/router/src/ValidSignature.php new file mode 100644 index 000000000..e624d06c4 --- /dev/null +++ b/packages/router/src/ValidSignature.php @@ -0,0 +1,43 @@ +middleware = [ + ...$route->middleware, + ValidSignatureMiddleware::class, + ]; + + return $route; + } +} diff --git a/packages/router/src/ValidSignatureMiddleware.php b/packages/router/src/ValidSignatureMiddleware.php new file mode 100644 index 000000000..1e1a7d5e1 --- /dev/null +++ b/packages/router/src/ValidSignatureMiddleware.php @@ -0,0 +1,38 @@ +uriGenerator->hasValidSignature($request)) { + return new Forbidden(); + } + + return $next($request); + } +} diff --git a/tests/Integration/Route/Fixtures/SignedUrlController.php b/tests/Integration/Route/Fixtures/SignedUrlController.php new file mode 100644 index 000000000..79e93b146 --- /dev/null +++ b/tests/Integration/Route/Fixtures/SignedUrlController.php @@ -0,0 +1,26 @@ + $token, 'message' => 'Signature valid']); + } + + #[Get('/unsigned-action/{token}')] + public function unsignedAction(string $token): Response + { + return new Ok(['token' => $token]); + } +} diff --git a/tests/Integration/Route/ValidSignatureMiddlewareTest.php b/tests/Integration/Route/ValidSignatureMiddlewareTest.php new file mode 100644 index 000000000..7bcc69c1b --- /dev/null +++ b/tests/Integration/Route/ValidSignatureMiddlewareTest.php @@ -0,0 +1,154 @@ + $this->container->get(UriGenerator::class); + } + + protected function setUp(): void + { + parent::setUp(); + + $this->http->registerRoute([SignedUrlController::class, 'signedAction']); + $this->http->registerRoute([SignedUrlController::class, 'unsignedAction']); + } + + #[Test] + public function valid_signature_allows_request(): void + { + $uri = $this->generator->createSignedUri( + action: [SignedUrlController::class, 'signedAction'], + token: 'abc123', + ); + + $response = $this->http->get($uri); + + $this->assertSame(Status::OK, $response->status); + $body = is_array($response->body) ? $response->body : json_decode($response->body, true); + $this->assertSame('abc123', $body['token']); + $this->assertSame('Signature valid', $body['message']); + } + + #[Test] + public function missing_signature_returns_forbidden(): void + { + $response = $this->http->get('/signed-action/abc123'); + + $this->assertSame(Status::FORBIDDEN, $response->status); + } + + #[Test] + public function invalid_signature_returns_forbidden(): void + { + $uri = $this->generator->createSignedUri( + action: [SignedUrlController::class, 'signedAction'], + token: 'abc123', + ); + + // Tamper with the signature + $tamperedUri = str_replace('signature=', 'signature=tampered', $uri); + + $response = $this->http->get($tamperedUri); + + $this->assertSame(Status::FORBIDDEN, $response->status); + } + + #[Test] + public function tampered_parameter_returns_forbidden(): void + { + $uri = $this->generator->createSignedUri( + action: [SignedUrlController::class, 'signedAction'], + token: 'abc123', + ); + + // Tamper with the token parameter + $tamperedUri = str_replace('abc123', 'tampered', $uri); + + $response = $this->http->get($tamperedUri); + + $this->assertSame(Status::FORBIDDEN, $response->status); + } + + #[Test] + public function expired_signature_returns_forbidden(): void + { + $clock = $this->clock(); + + $uri = $this->generator->createTemporarySignedUri( + action: [SignedUrlController::class, 'signedAction'], + duration: Duration::minutes(10), + token: 'abc123', + ); + + // Advance time past expiration + $clock->plus(Duration::minutes(15)); + + $response = $this->http->get($uri); + + $this->assertSame(Status::FORBIDDEN, $response->status); + } + + #[Test] + public function temporary_signature_valid_before_expiration(): void + { + $clock = $this->clock(); + + $uri = $this->generator->createTemporarySignedUri( + action: [SignedUrlController::class, 'signedAction'], + duration: Duration::minutes(10), + token: 'abc123', + ); + + // Advance time but stay within expiration + $clock->plus(Duration::minutes(5)); + + $response = $this->http->get($uri); + + $this->assertSame(Status::OK, $response->status); + } + + #[Test] + public function unsigned_route_works_without_signature(): void + { + // Routes without #[ValidSignature] should work normally + $response = $this->http->get('/unsigned-action/abc123'); + + $this->assertSame(Status::OK, $response->status); + } + + #[Test] + public function tampered_expiration_returns_forbidden(): void + { + $clock = $this->clock(); + + $uri = $this->generator->createTemporarySignedUri( + action: [SignedUrlController::class, 'signedAction'], + duration: Duration::minutes(10), + token: 'abc123', + ); + + // Get the current timestamp and extend it in the URL + $timestamp = $clock->now()->plusMinutes(10)->getTimestamp()->getSeconds(); + $tamperedUri = str_replace( + 'expires_at=' . $timestamp, + 'expires_at=' . ($timestamp + 3600), + $uri + ); + + $response = $this->http->get($tamperedUri); + + $this->assertSame(Status::FORBIDDEN, $response->status); + } +}