diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfFeaturesPass.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfFeaturesPass.php index 5ee7c2268cc32..1d2c0f835dda0 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfFeaturesPass.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/Compiler/RegisterCsrfFeaturesPass.php @@ -13,6 +13,7 @@ use Symfony\Component\DependencyInjection\Compiler\CompilerPassInterface; use Symfony\Component\DependencyInjection\ContainerBuilder; +use Symfony\Component\DependencyInjection\ContainerInterface; use Symfony\Component\DependencyInjection\Reference; use Symfony\Component\Security\Csrf\TokenStorage\ClearableTokenStorageInterface; use Symfony\Component\Security\Http\EventListener\CsrfProtectionListener; @@ -35,6 +36,10 @@ public function process(ContainerBuilder $container): void private function registerCsrfProtectionListener(ContainerBuilder $container): void { + if (!$container->hasDefinition('cache.system')) { + $container->removeDefinition('cache.security_is_csrf_token_valid_attribute_expression_language'); + } + if (!$container->has('security.authenticator.manager') || !$container->has('security.csrf.token_manager')) { return; } @@ -45,6 +50,7 @@ private function registerCsrfProtectionListener(ContainerBuilder $container): vo $container->register('controller.is_csrf_token_valid_attribute_listener', IsCsrfTokenValidAttributeListener::class) ->addArgument(new Reference('security.csrf.token_manager')) + ->addArgument(new Reference('security.is_csrf_token_valid_attribute_expression_language', ContainerInterface::NULL_ON_INVALID_REFERENCE)) ->addTag('kernel.event_subscriber'); } diff --git a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php index 259c40f17f8b4..383c7c41b3c9e 100644 --- a/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php +++ b/src/Symfony/Bundle/SecurityBundle/DependencyInjection/SecurityExtension.php @@ -123,6 +123,7 @@ public function load(array $configs, ContainerBuilder $container): void $container->removeDefinition('security.expression_language'); $container->removeDefinition('security.access.expression_voter'); $container->removeDefinition('security.is_granted_attribute_expression_language'); + $container->removeDefinition('security.is_csrf_token_valid_attribute_expression_language'); } if (!class_exists(PasswordHasherExtension::class)) { diff --git a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php index 8019058e9a55e..852ce968d16d3 100644 --- a/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php +++ b/src/Symfony/Bundle/SecurityBundle/Resources/config/security.php @@ -303,5 +303,12 @@ ->set('cache.security_is_granted_attribute_expression_language') ->parent('cache.system') ->tag('cache.pool') + + ->set('security.is_csrf_token_valid_attribute_expression_language', BaseExpressionLanguage::class) + ->args([service('cache.security_is_csrf_token_valid_attribute_expression_language')->nullOnInvalid()]) + + ->set('cache.security_is_csrf_token_valid_attribute_expression_language') + ->parent('cache.system') + ->tag('cache.pool') ; }; diff --git a/src/Symfony/Component/Security/Http/Attribute/IsCsrfTokenValid.php b/src/Symfony/Component/Security/Http/Attribute/IsCsrfTokenValid.php index 7cdd125473a35..ef598df2925fc 100644 --- a/src/Symfony/Component/Security/Http/Attribute/IsCsrfTokenValid.php +++ b/src/Symfony/Component/Security/Http/Attribute/IsCsrfTokenValid.php @@ -11,14 +11,16 @@ namespace Symfony\Component\Security\Http\Attribute; +use Symfony\Component\ExpressionLanguage\Expression; + #[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)] final class IsCsrfTokenValid { public function __construct( /** - * Sets the id used when generating the token. + * Sets the id, or an Expression evaluated to the id, used when generating the token. */ - public string $id, + public string|Expression $id, /** * Sets the key of the request that contains the actual token value that should be validated. diff --git a/src/Symfony/Component/Security/Http/EventListener/IsCsrfTokenValidAttributeListener.php b/src/Symfony/Component/Security/Http/EventListener/IsCsrfTokenValidAttributeListener.php index 0c24de1ad5da0..269c37709b547 100644 --- a/src/Symfony/Component/Security/Http/EventListener/IsCsrfTokenValidAttributeListener.php +++ b/src/Symfony/Component/Security/Http/EventListener/IsCsrfTokenValidAttributeListener.php @@ -12,6 +12,9 @@ namespace Symfony\Component\Security\Http\EventListener; use Symfony\Component\EventDispatcher\EventSubscriberInterface; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; +use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\KernelEvents; use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException; @@ -26,6 +29,7 @@ final class IsCsrfTokenValidAttributeListener implements EventSubscriberInterfac { public function __construct( private readonly CsrfTokenManagerInterface $csrfTokenManager, + private ?ExpressionLanguage $expressionLanguage = null, ) { } @@ -37,9 +41,12 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo } $request = $event->getRequest(); + $arguments = $event->getNamedArguments(); foreach ($attributes as $attribute) { - if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($attribute->id, $request->request->getString($attribute->tokenKey)))) { + $id = $this->getTokenId($attribute->id, $request, $arguments); + + if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $request->request->getString($attribute->tokenKey)))) { throw new InvalidCsrfTokenException('Invalid CSRF token.'); } } @@ -49,4 +56,18 @@ public static function getSubscribedEvents(): array { return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 25]]; } + + private function getTokenId(string|Expression $id, Request $request, array $arguments): string + { + if (!$id instanceof Expression) { + return $id; + } + + $this->expressionLanguage ??= new ExpressionLanguage(); + + return (string) $this->expressionLanguage->evaluate($id, [ + 'request' => $request, + 'args' => $arguments, + ]); + } } diff --git a/src/Symfony/Component/Security/Http/Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php b/src/Symfony/Component/Security/Http/Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php index e82748b65acde..cbbdc3b15fe62 100644 --- a/src/Symfony/Component/Security/Http/Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php +++ b/src/Symfony/Component/Security/Http/Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php @@ -9,9 +9,11 @@ * file that was distributed with this source code. */ -namespace EventListener; +namespace Symfony\Component\Security\Http\Tests\EventListener; use PHPUnit\Framework\TestCase; +use Symfony\Component\ExpressionLanguage\Expression; +use Symfony\Component\ExpressionLanguage\ExpressionLanguage; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent; use Symfony\Component\HttpKernel\HttpKernelInterface; @@ -86,6 +88,37 @@ public function testIsCsrfTokenValidCalledCorrectly() $listener->onKernelControllerArguments($event); } + public function testIsCsrfTokenValidCalledCorrectlyWithCustomExpressionId() + { + $request = new Request(query: ['id' => '123'], request: ['_token' => 'bar']); + + $csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class); + $csrfTokenManager->expects($this->once()) + ->method('isTokenValid') + ->with(new CsrfToken('foo_123', 'bar')) + ->willReturn(true); + + $expressionLanguage = $this->createMock(ExpressionLanguage::class); + $expressionLanguage->expects($this->once()) + ->method('evaluate') + ->with(new Expression('"foo_" ~ args.id'), [ + 'args' => ['id' => '123'], + 'request' => $request, + ]) + ->willReturn('foo_123'); + + $event = new ControllerArgumentsEvent( + $this->createMock(HttpKernelInterface::class), + [new IsCsrfTokenValidAttributeMethodsController(), 'withCustomExpressionId'], + ['123'], + $request, + null + ); + + $listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager, $expressionLanguage); + $listener->onKernelControllerArguments($event); + } + public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenKey() { $request = new Request(request: ['my_token_key' => 'bar']); diff --git a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php index 80d705cb50967..baf45d77ac100 100644 --- a/src/Symfony/Component/Security/Http/Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php +++ b/src/Symfony/Component/Security/Http/Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php @@ -11,6 +11,7 @@ namespace Symfony\Component\Security\Http\Tests\Fixtures; +use Symfony\Component\ExpressionLanguage\Expression; use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; class IsCsrfTokenValidAttributeMethodsController @@ -24,6 +25,16 @@ public function withDefaultTokenKey() { } + #[IsCsrfTokenValid(new Expression('"foo_" ~ args.id'))] + public function withCustomExpressionId(string $id) + { + } + + #[IsCsrfTokenValid(new Expression('"foo_" ~ args.slug'))] + public function withInvalidExpressionId(string $id) + { + } + #[IsCsrfTokenValid('foo', tokenKey: 'my_token_key')] public function withCustomTokenKey() {