Skip to content

Commit b960820

Browse files
webda2lnicolas-grekas
authored andcommitted
[Security] Add $tokenSource argument to #[IsCsrfTokenValid] to support reading tokens from the query string or headers
1 parent 82aec05 commit b960820

File tree

5 files changed

+129
-24
lines changed

5 files changed

+129
-24
lines changed

Attribute/IsCsrfTokenValid.php

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,10 @@
1616
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
1717
final class IsCsrfTokenValid
1818
{
19+
public const SOURCE_PAYLOAD = 0b0001;
20+
public const SOURCE_QUERY = 0b0010;
21+
public const SOURCE_HEADER = 0b0100;
22+
1923
public function __construct(
2024
/**
2125
* Sets the id, or an Expression evaluated to the id, used when generating the token.
@@ -32,6 +36,13 @@ public function __construct(
3236
* If not set, the token will be validated for all methods.
3337
*/
3438
public array|string $methods = [],
39+
40+
/**
41+
* Sets the source targeted to read the tokenKey.
42+
*
43+
* @var int-mask-of<self::SOURCE_*>
44+
*/
45+
public int $tokenSource = self::SOURCE_PAYLOAD,
3546
) {
3647
}
3748
}

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ CHANGELOG
99
* Deprecate `AbstractListener::__invoke`
1010
* Add `$methods` argument to `#[IsGranted]` to restrict validation to specific HTTP methods
1111
* Allow subclassing `#[IsGranted]`
12+
* Add `$tokenSource` argument to `#[IsCsrfTokenValid]` to support reading tokens from the query string or headers
1213
* Deprecate `RememberMeDetails::getUserFqcn()`, the user FQCN will be removed from the remember-me cookie in 8.0
1314

1415
7.3

EventListener/IsCsrfTokenValidAttributeListener.php

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,11 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
5050
continue;
5151
}
5252

53-
if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $request->getPayload()->getString($attribute->tokenKey)))) {
53+
$tokenValue = $this->getTokenValue($request, $attribute->tokenSource, $attribute->tokenKey);
54+
if (
55+
null === $tokenValue
56+
|| !$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $tokenValue))
57+
) {
5458
throw new InvalidCsrfTokenException('Invalid CSRF token.');
5559
}
5660
}
@@ -74,4 +78,25 @@ private function getTokenId(string|Expression $id, Request $request, array $argu
7478
'args' => $arguments,
7579
]);
7680
}
81+
82+
private function getTokenValue(Request $request, int $tokenSource, string $tokenKey): ?string
83+
{
84+
$sources = [
85+
IsCsrfTokenValid::SOURCE_PAYLOAD => static fn () => $request->getPayload()->get($tokenKey),
86+
IsCsrfTokenValid::SOURCE_QUERY => static fn () => $request->query->get($tokenKey),
87+
IsCsrfTokenValid::SOURCE_HEADER => static fn () => $request->headers->get($tokenKey),
88+
];
89+
90+
foreach ($sources as $source => $getter) {
91+
if (!($tokenSource & $source)) {
92+
continue;
93+
}
94+
95+
if (null !== $token = $getter()) {
96+
return $token;
97+
}
98+
}
99+
100+
return null;
101+
}
77102
}

Tests/EventListener/IsCsrfTokenValidAttributeListenerTest.php

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

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

14+
use PHPUnit\Framework\Attributes\DataProvider;
1415
use PHPUnit\Framework\TestCase;
1516
use Symfony\Component\ExpressionLanguage\Expression;
1617
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
@@ -90,7 +91,7 @@ public function testIsCsrfTokenValidCalledCorrectly()
9091

9192
public function testIsCsrfTokenValidCalledCorrectlyInPayload()
9293
{
93-
$request = new Request(server: ['headers' => ['content-type' => 'application/json']], content: json_encode(['_token' => 'bar']));
94+
$request = new Request(server: ['CONTENT_TYPE' => 'application/json'], content: json_encode(['_token' => 'bar']));
9495

9596
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
9697
$csrfTokenManager->expects($this->once())
@@ -163,15 +164,15 @@ public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenKey()
163164
$listener->onKernelControllerArguments($event);
164165
}
165166

166-
public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKey()
167+
public function testIsCsrfTokenValidThrowExceptionWhenInvalidMatchingToken()
167168
{
169+
$this->expectException(InvalidCsrfTokenException::class);
170+
168171
$request = new Request(request: ['_token' => 'bar']);
169172

170173
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
171-
$csrfTokenManager->expects($this->once())
172-
->method('isTokenValid')
173-
->with(new CsrfToken('foo', ''))
174-
->willReturn(true);
174+
$csrfTokenManager->expects($this->never())
175+
->method('isTokenValid');
175176

176177
$event = new ControllerArgumentsEvent(
177178
$this->createMock(HttpKernelInterface::class),
@@ -185,15 +186,13 @@ public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKey()
185186
$listener->onKernelControllerArguments($event);
186187
}
187188

188-
public function testExceptionWhenInvalidToken()
189+
public function testIsCsrfTokenValidThrowExceptionWhenMissingRequestToken()
189190
{
190191
$this->expectException(InvalidCsrfTokenException::class);
191192

192193
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
193-
$csrfTokenManager->expects($this->once())
194-
->method('isTokenValid')
195-
->withAnyParameters()
196-
->willReturn(false);
194+
$csrfTokenManager->expects($this->never())
195+
->method('isTokenValid');
197196

198197
$event = new ControllerArgumentsEvent(
199198
$this->createMock(HttpKernelInterface::class),
@@ -237,8 +236,7 @@ public function testIsCsrfTokenValidIgnoredWithNonMatchingMethod()
237236

238237
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
239238
$csrfTokenManager->expects($this->never())
240-
->method('isTokenValid')
241-
->with(new CsrfToken('foo', 'bar'));
239+
->method('isTokenValid');
242240

243241
$event = new ControllerArgumentsEvent(
244242
$this->createMock(HttpKernelInterface::class),
@@ -275,15 +273,14 @@ public function testIsCsrfTokenValidCalledCorrectlyWithGetOrPostMethodWithGetMet
275273
$listener->onKernelControllerArguments($event);
276274
}
277275

278-
public function testIsCsrfTokenValidNoIgnoredWithGetOrPostMethodWithPutMethod()
276+
public function testIsCsrfTokenValidIgnoredWithGetOrPostMethodWithPutMethod()
279277
{
280278
$request = new Request(request: ['_token' => 'bar']);
281279
$request->setMethod('PUT');
282280

283281
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
284282
$csrfTokenManager->expects($this->never())
285-
->method('isTokenValid')
286-
->with(new CsrfToken('foo', 'bar'));
283+
->method('isTokenValid');
287284

288285
$event = new ControllerArgumentsEvent(
289286
$this->createMock(HttpKernelInterface::class),
@@ -297,18 +294,16 @@ public function testIsCsrfTokenValidNoIgnoredWithGetOrPostMethodWithPutMethod()
297294
$listener->onKernelControllerArguments($event);
298295
}
299296

300-
public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKeyAndPostMethod()
297+
public function testIsCsrfTokenValidThrowExceptionWithInvalidTokenKeyAndPostMethod()
301298
{
302299
$this->expectException(InvalidCsrfTokenException::class);
303300

304301
$request = new Request(request: ['_token' => 'bar']);
305302
$request->setMethod('POST');
306303

307304
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
308-
$csrfTokenManager->expects($this->once())
309-
->method('isTokenValid')
310-
->withAnyParameters()
311-
->willReturn(false);
305+
$csrfTokenManager->expects($this->never())
306+
->method('isTokenValid');
312307

313308
$event = new ControllerArgumentsEvent(
314309
$this->createMock(HttpKernelInterface::class),
@@ -329,8 +324,7 @@ public function testIsCsrfTokenValidIgnoredWithInvalidTokenKeyAndUnavailableMeth
329324

330325
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
331326
$csrfTokenManager->expects($this->never())
332-
->method('isTokenValid')
333-
->withAnyParameters();
327+
->method('isTokenValid');
334328

335329
$event = new ControllerArgumentsEvent(
336330
$this->createMock(HttpKernelInterface::class),
@@ -343,4 +337,63 @@ public function testIsCsrfTokenValidIgnoredWithInvalidTokenKeyAndUnavailableMeth
343337
$listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager);
344338
$listener->onKernelControllerArguments($event);
345339
}
340+
341+
#[DataProvider('provideTokenSourceScenarios')]
342+
public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenSource(Request $request, string $attributeMethod, string $expectedTokenValue)
343+
{
344+
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
345+
$csrfTokenManager->expects($this->once())
346+
->method('isTokenValid')
347+
->with(new CsrfToken('foo', $expectedTokenValue))
348+
->willReturn(true);
349+
350+
$event = new ControllerArgumentsEvent(
351+
$this->createMock(HttpKernelInterface::class),
352+
[new IsCsrfTokenValidAttributeMethodsController(), $attributeMethod],
353+
[],
354+
$request,
355+
null
356+
);
357+
358+
$listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager);
359+
$listener->onKernelControllerArguments($event);
360+
}
361+
362+
public static function provideTokenSourceScenarios(): \Generator
363+
{
364+
yield 'tokenSource Payload (default)' => [
365+
new Request(
366+
request: ['_token' => 'bar_payload'],
367+
query: ['_token' => 'bar_query']
368+
),
369+
'withDefaultTokenKey',
370+
'bar_payload',
371+
];
372+
yield 'tokenSource Query' => [
373+
new Request(
374+
request: ['_token' => 'bar_payload'],
375+
query: ['_token' => 'bar_query']
376+
),
377+
'withCustomTokenSourceQuery',
378+
'bar_query',
379+
];
380+
yield 'tokenSource Query|Payload' => [
381+
new Request(
382+
server: ['CONTENT_TYPE' => 'application/json'],
383+
content: json_encode(['_token' => 'bar_payload']),
384+
query: ['_token' => 'bar_query']
385+
),
386+
'withCustomTokenSourceQueryPayload',
387+
'bar_payload',
388+
];
389+
yield 'tokenSource Header and custom sourceToken' => [
390+
new Request(
391+
server: ['HTTP_MY_TOKEN_KEY' => 'bar_header'],
392+
request: ['my_token_key' => 'bar_payload'],
393+
query: ['my_token_key' => 'bar_query']
394+
),
395+
'withCustomTokenSourceHeaderAndCustomSourceToken',
396+
'bar_header',
397+
];
398+
}
346399
}

Tests/Fixtures/IsCsrfTokenValidAttributeMethodsController.php

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,4 +59,19 @@ public function withGetOrPostMethod()
5959
public function withPostMethodAndInvalidTokenKey()
6060
{
6161
}
62+
63+
#[IsCsrfTokenValid('foo', tokenSource: IsCsrfTokenValid::SOURCE_QUERY)]
64+
public function withCustomTokenSourceQuery()
65+
{
66+
}
67+
68+
#[IsCsrfTokenValid('foo', tokenSource: IsCsrfTokenValid::SOURCE_QUERY | IsCsrfTokenValid::SOURCE_PAYLOAD)]
69+
public function withCustomTokenSourceQueryPayload()
70+
{
71+
}
72+
73+
#[IsCsrfTokenValid('foo', tokenKey: 'my_token_key', tokenSource: IsCsrfTokenValid::SOURCE_HEADER)]
74+
public function withCustomTokenSourceHeaderAndCustomSourceToken()
75+
{
76+
}
6277
}

0 commit comments

Comments
 (0)