From 85320cf6d0cc02240a6ba06f0f744f4ba815e2a4 Mon Sep 17 00:00:00 2001 From: Santiago San Martin Date: Fri, 9 May 2025 17:31:16 -0300 Subject: [PATCH] feat(HttpKernel): add `#[IsSignatureValid]` attribute with exception-based handling --- .../Resources/config/security.php | 7 + .../Http/Attribute/IsSignatureValid.php | 38 +++ .../Component/Security/Http/CHANGELOG.md | 5 + .../IsSignatureValidAttributeListener.php | 56 ++++ .../IsSignatureValidAttributeListenerTest.php | 241 ++++++++++++++++++ .../IsSignatureValidAttributeController.php | 22 ++ ...gnatureValidAttributeMethodsController.php | 52 ++++ .../Component/Security/Http/composer.json | 2 +- 8 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/Symfony/Component/Security/Http/Attribute/IsSignatureValid.php create mode 100644 src/Symfony/Component/Security/Http/EventListener/IsSignatureValidAttributeListener.php create mode 100644 src/Symfony/Component/Security/Http/Tests/EventListener/IsSignatureValidAttributeListenerTest.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeController.php create mode 100644 src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeMethodsController.php diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 7b08ebe5fa35d..1ad99bb290041 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -48,6 +48,7 @@ use Symfony\Component\Security\Http\Controller\SecurityTokenValueResolver; use Symfony\Component\Security\Http\Controller\UserValueResolver; use Symfony\Component\Security\Http\EventListener\IsGrantedAttributeListener; +use Symfony\Component\Security\Http\EventListener\IsSignatureValidAttributeListener; use Symfony\Component\Security\Http\Firewall; use Symfony\Component\Security\Http\FirewallMapInterface; use Symfony\Component\Security\Http\HttpUtils; @@ -323,5 +324,11 @@ ->set('cache.security_is_csrf_token_valid_attribute_expression_language') ->parent('cache.system') ->tag('cache.pool') + + ->set('controller.is_signature_valid_attribute_listener', IsSignatureValidAttributeListener::class) + ->args([ + service('uri_signer'), + ]) + ->tag('kernel.event_subscriber') ; }; diff --git a/src/Symfony/Component/Security/Http/Attribute/IsSignatureValid.php b/src/Symfony/Component/Security/Http/Attribute/IsSignatureValid.php new file mode 100644 index 0000000000000..528b1aa5ff361 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Attribute/IsSignatureValid.php @@ -0,0 +1,38 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Attribute; + +use Symfony\Component\HttpFoundation\Response; + +/** + * Attribute to ensure the request URI contains a valid signature before allowing controller execution. + * + * When applied, this attribute verifies that the request is signed and the signature is still valid (e.g., not expired). + * Behavior can be customized to either return a specific HTTP status code or throw an exception to be handled globally. + * + * @author Santiago San Martin + */ +#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] +final class IsSignatureValid +{ + /** + * @param int|null $validationFailedStatusCode The HTTP status code to return if the signature is invalid. + * Ignored when 'throw' is true. If null, error code 404 is used. + * @param bool|null $throw If true, an exception is thrown on signature failure instead of returning a response. + * Useful for global exception handling or listener-based workflows. + */ + public function __construct( + public ?int $validationFailedStatusCode = Response::HTTP_NOT_FOUND, + public ?bool $throw = null, + ) { + } +} diff --git a/src/Symfony/Component/Security/Http/CHANGELOG.md b/src/Symfony/Component/Security/Http/CHANGELOG.md index 275180ff87b3b..735b9c6b213f9 100644 --- a/src/Symfony/Component/Security/Http/CHANGELOG.md +++ b/src/Symfony/Component/Security/Http/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.4 +--- + + * Add `#[IsSignatureValid]` attribute + 7.3 --- diff --git a/src/Symfony/Component/Security/Http/EventListener/IsSignatureValidAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsSignatureValidAttributeListener.php new file mode 100644 index 0000000000000..68819c560e1ff --- /dev/null +++ b/src/Symfony/Component/Security/Http/EventListener/IsSignatureValidAttributeListener.php @@ -0,0 +1,56 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\EventListener; + +use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\KernelEvents; +use Symfony\Component\Security\Http\Attribute\IsSignatureValid; + +/** + * Handles the IsSignatureValid attribute. + * + * @author Santiago San Martin + */ +class IsSignatureValidAttributeListener implements EventSubscriberInterface +{ + public function __construct( + private readonly UriSigner $uriSigner, + ) { + } + + public function onKernelControllerArguments(ControllerArgumentsEvent $event): void + { + /** @var IsSignatureValid[] $attributes */ + if (!\is_array($attributes = $event->getAttributes()[IsSignatureValid::class] ?? null)) { + return; + } + + $request = $event->getRequest(); + foreach ($attributes as $attribute) { + if ($attribute->throw) { + $this->uriSigner->verify($request); + continue; + } + if (!$this->uriSigner->checkRequest($request)) { + throw new HttpException($attribute->validationFailedStatusCode, 'The URI signature is invalid.'); + } + } + } + + public static function getSubscribedEvents(): array + { + return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 30]]; + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/IsSignatureValidAttributeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/IsSignatureValidAttributeListenerTest.php new file mode 100644 index 0000000000000..57fcbf0180efc --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/IsSignatureValidAttributeListenerTest.php @@ -0,0 +1,241 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\EventListener; + +use PHPUnit\Framework\MockObject\MockObject; +use PHPUnit\Framework\TestCase; +use Symfony\Component\HttpFoundation\Exception\UnsignedUriException; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\UriSigner; +use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; +use Symfony\Component\HttpKernel\Exception\HttpException; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Http\EventListener\IsSignatureValidAttributeListener; +use Symfony\Component\Security\Http\Tests\Fixtures\IsSignatureValidAttributeController; +use Symfony\Component\Security\Http\Tests\Fixtures\IsSignatureValidAttributeMethodsController; + +class IsSignatureValidAttributeListenerTest extends TestCase +{ + public function testInvokableControllerWithValidSignature() + { + $request = new Request(); + + /** @var UriSigner&MockObject $signer */ + $signer = $this->createMock(UriSigner::class); + $signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(true); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + new IsSignatureValidAttributeController(), + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + $listener->onKernelControllerArguments($event); + } + + public function testNoAttributeSkipsValidation() + { + /** @var UriSigner&MockObject $signer */ + $signer = $this->createMock(UriSigner::class); + $signer->expects($this->never())->method('checkRequest'); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'noAttribute'], + [], + new Request(), + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + $listener->onKernelControllerArguments($event); + } + + public function testDefaultCheckRequestSucceeds() + { + $request = new Request(); + /** @var UriSigner&MockObject $signer */ + $signer = $this->createMock(UriSigner::class); + $signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(true); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'withDefaultBehavior'], + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + $listener->onKernelControllerArguments($event); + } + + public function testCheckRequestFailsThrowsHttpException() + { + $request = new Request(); + /** @var UriSigner&MockObject $signer */ + $signer = $this->createMock(UriSigner::class); + $signer->expects($this->once())->method('checkRequest')->willReturn(false); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'withDefaultBehavior'], + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + + try { + $listener->onKernelControllerArguments($event); + } catch (HttpException $e) { + $this->assertSame(404, $e->getStatusCode()); + $this->assertSame('The URI signature is invalid.', $e->getMessage()); + } + } + + public function testVerifyThrowsCustomException() + { + $request = new Request(); + $signer = new UriSigner('foobar'); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'withExceptionThrowing'], + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + + $this->expectException(UnsignedUriException::class); + $listener->onKernelControllerArguments($event); + } + + public function testCustomStatusCodeReturnedOnInvalidSignature() + { + $request = new Request(); + + /** @var UriSigner&MockObject $signer */ + $signer = $this->createMock(UriSigner::class); + $signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(false); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'withCustomStatusCode'], + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + + try { + $listener->onKernelControllerArguments($event); + } catch (HttpException $e) { + $this->assertSame(401, $e->getStatusCode()); + $this->assertSame('The URI signature is invalid.', $e->getMessage()); + } + } + + public function testWithThrowAndCustomStatusFails() + { + $request = new Request(); + $signer = new UriSigner('foobar'); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'withThrowAndCustomStatus'], + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + + $this->expectException(UnsignedUriException::class); + $listener->onKernelControllerArguments($event); + } + + public function testWithExplicitNoThrowIgnoresSignatureFailure() + { + $request = new Request(); + + /** @var UriSigner&MockObject $signer */ + $signer = $this->createMock(UriSigner::class); + $signer->expects($this->once())->method('checkRequest')->with($request)->willReturn(false); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'withExplicitNoThrow'], + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + $this->expectException(HttpException::class); + $listener->onKernelControllerArguments($event); + } + + public function testMultipleAttributesAllValid() + { + $request = new Request(); + + /** @var UriSigner&MockObject $signer */ + $signer = $this->createMock(UriSigner::class); + $signer->expects($this->exactly(2))->method('checkRequest')->with($request)->willReturn(true); + + /** @var HttpKernelInterface&MockObject $kernel */ + $kernel = $this->createMock(HttpKernelInterface::class); + + $event = new ControllerArgumentsEvent( + $kernel, + [new IsSignatureValidAttributeMethodsController(), 'withMultiple'], + [], + $request, + null + ); + + $listener = new IsSignatureValidAttributeListener($signer); + $listener->onKernelControllerArguments($event); + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeController.php new file mode 100644 index 0000000000000..c5dd5ac87c671 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeController.php @@ -0,0 +1,22 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Http\Attribute\IsSignatureValid; + +#[IsSignatureValid] +class IsSignatureValidAttributeController +{ + public function __invoke() + { + } +} diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeMethodsController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeMethodsController.php new file mode 100644 index 0000000000000..6bbe69cbb75d7 --- /dev/null +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsSignatureValidAttributeMethodsController.php @@ -0,0 +1,52 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Http\Tests\Fixtures; + +use Symfony\Component\Security\Http\Attribute\IsSignatureValid; + +class IsSignatureValidAttributeMethodsController +{ + public function noAttribute() + { + } + + #[IsSignatureValid] + public function withDefaultBehavior() + { + } + + #[IsSignatureValid(validationFailedStatusCode: 401)] + public function withCustomStatusCode() + { + } + + #[IsSignatureValid(throw: true)] + public function withExceptionThrowing() + { + } + + #[IsSignatureValid(throw: true, validationFailedStatusCode: 410)] + public function withThrowAndCustomStatus() + { + } + + #[IsSignatureValid(throw: false)] + public function withExplicitNoThrow() + { + } + + #[IsSignatureValid] + #[IsSignatureValid] + public function withMultiple() + { + } +} diff --git a/src/Symfony/Component/Security/Http/composer.json b/src/Symfony/Component/Security/Http/composer.json index 77f6af87395ec..f90d561b79c59 100644 --- a/src/Symfony/Component/Security/Http/composer.json +++ b/src/Symfony/Component/Security/Http/composer.json @@ -18,7 +18,7 @@ "require": { "php": ">=8.2", "symfony/deprecation-contracts": "^2.5|^3", - "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-foundation": "^7.3", "symfony/http-kernel": "^6.4|^7.0", "symfony/polyfill-mbstring": "~1.0", "symfony/property-access": "^6.4|^7.0",