Skip to content

Commit fcee234

Browse files
feature #54443 [Security] Add support for dynamic CSRF id with Expression in #[IsCsrfTokenValid] (yguedidi)
This PR was merged into the 7.1 branch. Discussion ---------- [Security] Add support for dynamic CSRF id with Expression in `#[IsCsrfTokenValid]` | Q | A | ------------- | --- | Branch? | 7.1 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | continuation of #52961 from Hackday | License | MIT Use case is for example on a list page with delete action per item, and you want a CSRF token per item, so in the template you have something like the following: ```twig {# in a loop over multiple posts #} <form action="{{ path('post_delete', {post: post.id}) }}" method="POST"> <input type="hidden" name="_token" value="{{ csrf_token('delete-post-' ~ post.id) }}"> ... </form> ``` The new feature will allow: ```php #[IsCsrfTokenValid(new Expression('"delete-post-" ~ args["post"].id'))] public function delete(Request $request, Post $post): Response { // ... delete the post } ``` Maybe this need more tests but need help identify which test cases are useful. Hope this can pass before the feature freeze Commits ------- 8f99ca58a8 Add support for dynamic CSRF id in IsCsrfTokenValid
2 parents 45f43fb + f840e40 commit fcee234

File tree

4 files changed

+71
-4
lines changed

4 files changed

+71
-4
lines changed

Attribute/IsCsrfTokenValid.php

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,14 +11,16 @@
1111

1212
namespace Symfony\Component\Security\Http\Attribute;
1313

14+
use Symfony\Component\ExpressionLanguage\Expression;
15+
1416
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
1517
final class IsCsrfTokenValid
1618
{
1719
public function __construct(
1820
/**
19-
* Sets the id used when generating the token.
21+
* Sets the id, or an Expression evaluated to the id, used when generating the token.
2022
*/
21-
public string $id,
23+
public string|Expression $id,
2224

2325
/**
2426
* Sets the key of the request that contains the actual token value that should be validated.

EventListener/IsCsrfTokenValidAttributeListener.php

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
namespace Symfony\Component\Security\Http\EventListener;
1313

1414
use Symfony\Component\EventDispatcher\EventSubscriberInterface;
15+
use Symfony\Component\ExpressionLanguage\Expression;
16+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
17+
use Symfony\Component\HttpFoundation\Request;
1518
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
1619
use Symfony\Component\HttpKernel\KernelEvents;
1720
use Symfony\Component\Security\Core\Exception\InvalidCsrfTokenException;
@@ -26,6 +29,7 @@ final class IsCsrfTokenValidAttributeListener implements EventSubscriberInterfac
2629
{
2730
public function __construct(
2831
private readonly CsrfTokenManagerInterface $csrfTokenManager,
32+
private ?ExpressionLanguage $expressionLanguage = null,
2933
) {
3034
}
3135

@@ -37,9 +41,12 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
3741
}
3842

3943
$request = $event->getRequest();
44+
$arguments = $event->getNamedArguments();
4045

4146
foreach ($attributes as $attribute) {
42-
if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($attribute->id, $request->request->getString($attribute->tokenKey)))) {
47+
$id = $this->getTokenId($attribute->id, $request, $arguments);
48+
49+
if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $request->request->getString($attribute->tokenKey)))) {
4350
throw new InvalidCsrfTokenException('Invalid CSRF token.');
4451
}
4552
}
@@ -49,4 +56,18 @@ public static function getSubscribedEvents(): array
4956
{
5057
return [KernelEvents::CONTROLLER_ARGUMENTS => ['onKernelControllerArguments', 25]];
5158
}
59+
60+
private function getTokenId(string|Expression $id, Request $request, array $arguments): string
61+
{
62+
if (!$id instanceof Expression) {
63+
return $id;
64+
}
65+
66+
$this->expressionLanguage ??= new ExpressionLanguage();
67+
68+
return (string) $this->expressionLanguage->evaluate($id, [
69+
'request' => $request,
70+
'args' => $arguments,
71+
]);
72+
}
5273
}

Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php

Lines changed: 34 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,9 +9,11 @@
99
* file that was distributed with this source code.
1010
*/
1111

12-
namespace EventListener;
12+
namespace Symfony\Component\Security\Http\Tests\EventListener;
1313

1414
use PHPUnit\Framework\TestCase;
15+
use Symfony\Component\ExpressionLanguage\Expression;
16+
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
1517
use Symfony\Component\HttpFoundation\Request;
1618
use Symfony\Component\HttpKernel\Event\ControllerArgumentsEvent;
1719
use Symfony\Component\HttpKernel\HttpKernelInterface;
@@ -86,6 +88,37 @@ public function testIsCsrfTokenValidCalledCorrectly()
8688
$listener->onKernelControllerArguments($event);
8789
}
8890

91+
public function testIsCsrfTokenValidCalledCorrectlyWithCustomExpressionId()
92+
{
93+
$request = new Request(query: ['id' => '123'], request: ['_token' => 'bar']);
94+
95+
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
96+
$csrfTokenManager->expects($this->once())
97+
->method('isTokenValid')
98+
->with(new CsrfToken('foo_123', 'bar'))
99+
->willReturn(true);
100+
101+
$expressionLanguage = $this->createMock(ExpressionLanguage::class);
102+
$expressionLanguage->expects($this->once())
103+
->method('evaluate')
104+
->with(new Expression('"foo_" ~ args.id'), [
105+
'args' => ['id' => '123'],
106+
'request' => $request,
107+
])
108+
->willReturn('foo_123');
109+
110+
$event = new ControllerArgumentsEvent(
111+
$this->createMock(HttpKernelInterface::class),
112+
[new IsCsrfTokenValidAttributeMethodsController(), 'withCustomExpressionId'],
113+
['123'],
114+
$request,
115+
null
116+
);
117+
118+
$listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager, $expressionLanguage);
119+
$listener->onKernelControllerArguments($event);
120+
}
121+
89122
public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenKey()
90123
{
91124
$request = new Request(request: ['my_token_key' => 'bar']);

Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111

1212
namespace Symfony\Component\Security\Http\Tests\Fixtures;
1313

14+
use Symfony\Component\ExpressionLanguage\Expression;
1415
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
1516

1617
class IsCsrfTokenValidAttributeMethodsController
@@ -24,6 +25,16 @@ public function withDefaultTokenKey()
2425
{
2526
}
2627

28+
#[IsCsrfTokenValid(new Expression('"foo_" ~ args.id'))]
29+
public function withCustomExpressionId(string $id)
30+
{
31+
}
32+
33+
#[IsCsrfTokenValid(new Expression('"foo_" ~ args.slug'))]
34+
public function withInvalidExpressionId(string $id)
35+
{
36+
}
37+
2738
#[IsCsrfTokenValid('foo', tokenKey: 'my_token_key')]
2839
public function withCustomTokenKey()
2940
{

0 commit comments

Comments
 (0)