Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@
#[\Attribute(\Attribute::IS_REPEATABLE | \Attribute::TARGET_CLASS | \Attribute::TARGET_METHOD | \Attribute::TARGET_FUNCTION)]
final class IsCsrfTokenValid
{
public const SOURCE_PAYLOAD = 0b0001;
public const SOURCE_QUERY = 0b0010;
public const SOURCE_HEADER = 0b0100;

public function __construct(
/**
* Sets the id, or an Expression evaluated to the id, used when generating the token.
Expand All @@ -32,6 +36,13 @@ public function __construct(
* If not set, the token will be validated for all methods.
*/
public array|string $methods = [],

/**
* Sets the source targeted to read the tokenKey.
*
* @var int-mask-of<self::SOURCE_*>
*/
public int $tokenSource = self::SOURCE_PAYLOAD,
) {
}
}
1 change: 1 addition & 0 deletions src/Symfony/Component/Security/Http/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ CHANGELOG
* Deprecate `AbstractListener::__invoke`
* Add `$methods` argument to `#[IsGranted]` to restrict validation to specific HTTP methods
* Allow subclassing `#[IsGranted]`
* Add `$tokenSource` argument to `#[IsCsrfTokenValid]` to support reading tokens from the query string or headers
* Deprecate `RememberMeDetails::getUserFqcn()`, the user FQCN will be removed from the remember-me cookie in 8.0

7.3
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,11 @@ public function onKernelControllerArguments(ControllerArgumentsEvent $event): vo
continue;
}

if (!$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $request->getPayload()->getString($attribute->tokenKey)))) {
$tokenValue = $this->getTokenValue($request, $attribute->tokenSource, $attribute->tokenKey);
if (
null === $tokenValue
|| !$this->csrfTokenManager->isTokenValid(new CsrfToken($id, $tokenValue))
) {
throw new InvalidCsrfTokenException('Invalid CSRF token.');
}
}
Expand All @@ -74,4 +78,25 @@ private function getTokenId(string|Expression $id, Request $request, array $argu
'args' => $arguments,
]);
}

private function getTokenValue(Request $request, int $tokenSource, string $tokenKey): ?string
{
$sources = [
IsCsrfTokenValid::SOURCE_PAYLOAD => static fn () => $request->getPayload()->get($tokenKey),
IsCsrfTokenValid::SOURCE_QUERY => static fn () => $request->query->get($tokenKey),
IsCsrfTokenValid::SOURCE_HEADER => static fn () => $request->headers->get($tokenKey),
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would we really expect the header name to be _token like payload fields ?

If the name cannot be common, we cannot really support a bitfield IMO.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we can yes, that'd be just a custom header. Ppl that want something else can always do it the procedural way I'd say

];

foreach ($sources as $source => $getter) {
if (!($tokenSource & $source)) {
continue;
}

if (null !== $token = $getter()) {
return $token;
}
}

return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@

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

use PHPUnit\Framework\Attributes\DataProvider;
use PHPUnit\Framework\TestCase;
use Symfony\Component\ExpressionLanguage\Expression;
use Symfony\Component\ExpressionLanguage\ExpressionLanguage;
Expand Down Expand Up @@ -90,7 +91,7 @@ public function testIsCsrfTokenValidCalledCorrectly()

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

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->once())
Expand Down Expand Up @@ -163,15 +164,15 @@ public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenKey()
$listener->onKernelControllerArguments($event);
}

public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKey()
public function testIsCsrfTokenValidThrowExceptionWhenInvalidMatchingToken()
{
$this->expectException(InvalidCsrfTokenException::class);

$request = new Request(request: ['_token' => 'bar']);

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->once())
->method('isTokenValid')
->with(new CsrfToken('foo', ''))
->willReturn(true);
$csrfTokenManager->expects($this->never())
->method('isTokenValid');

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

public function testExceptionWhenInvalidToken()
public function testIsCsrfTokenValidThrowExceptionWhenMissingRequestToken()
{
$this->expectException(InvalidCsrfTokenException::class);

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->once())
->method('isTokenValid')
->withAnyParameters()
->willReturn(false);
$csrfTokenManager->expects($this->never())
->method('isTokenValid');

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

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->never())
->method('isTokenValid')
->with(new CsrfToken('foo', 'bar'));
->method('isTokenValid');

$event = new ControllerArgumentsEvent(
$this->createMock(HttpKernelInterface::class),
Expand Down Expand Up @@ -275,15 +273,14 @@ public function testIsCsrfTokenValidCalledCorrectlyWithGetOrPostMethodWithGetMet
$listener->onKernelControllerArguments($event);
}

public function testIsCsrfTokenValidNoIgnoredWithGetOrPostMethodWithPutMethod()
public function testIsCsrfTokenValidIgnoredWithGetOrPostMethodWithPutMethod()
{
$request = new Request(request: ['_token' => 'bar']);
$request->setMethod('PUT');

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->never())
->method('isTokenValid')
->with(new CsrfToken('foo', 'bar'));
->method('isTokenValid');

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

public function testIsCsrfTokenValidCalledCorrectlyWithInvalidTokenKeyAndPostMethod()
public function testIsCsrfTokenValidThrowExceptionWithInvalidTokenKeyAndPostMethod()
{
$this->expectException(InvalidCsrfTokenException::class);

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

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->once())
->method('isTokenValid')
->withAnyParameters()
->willReturn(false);
$csrfTokenManager->expects($this->never())
->method('isTokenValid');

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

$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->never())
->method('isTokenValid')
->withAnyParameters();
->method('isTokenValid');

$event = new ControllerArgumentsEvent(
$this->createMock(HttpKernelInterface::class),
Expand All @@ -343,4 +337,63 @@ public function testIsCsrfTokenValidIgnoredWithInvalidTokenKeyAndUnavailableMeth
$listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager);
$listener->onKernelControllerArguments($event);
}

#[DataProvider('provideTokenSourceScenarios')]
public function testIsCsrfTokenValidCalledCorrectlyWithCustomTokenSource(Request $request, string $attributeMethod, string $expectedTokenValue)
{
$csrfTokenManager = $this->createMock(CsrfTokenManagerInterface::class);
$csrfTokenManager->expects($this->once())
->method('isTokenValid')
->with(new CsrfToken('foo', $expectedTokenValue))
->willReturn(true);

$event = new ControllerArgumentsEvent(
$this->createMock(HttpKernelInterface::class),
[new IsCsrfTokenValidAttributeMethodsController(), $attributeMethod],
[],
$request,
null
);

$listener = new IsCsrfTokenValidAttributeListener($csrfTokenManager);
$listener->onKernelControllerArguments($event);
}

public static function provideTokenSourceScenarios(): \Generator
{
yield 'tokenSource Payload (default)' => [
new Request(
request: ['_token' => 'bar_payload'],
query: ['_token' => 'bar_query']
),
'withDefaultTokenKey',
'bar_payload',
];
yield 'tokenSource Query' => [
new Request(
request: ['_token' => 'bar_payload'],
query: ['_token' => 'bar_query']
),
'withCustomTokenSourceQuery',
'bar_query',
];
yield 'tokenSource Query|Payload' => [
new Request(
server: ['CONTENT_TYPE' => 'application/json'],
content: json_encode(['_token' => 'bar_payload']),
query: ['_token' => 'bar_query']
),
'withCustomTokenSourceQueryPayload',
'bar_payload',
];
yield 'tokenSource Header and custom sourceToken' => [
new Request(
server: ['HTTP_MY_TOKEN_KEY' => 'bar_header'],
request: ['my_token_key' => 'bar_payload'],
query: ['my_token_key' => 'bar_query']
),
'withCustomTokenSourceHeaderAndCustomSourceToken',
'bar_header',
];
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -59,4 +59,19 @@ public function withGetOrPostMethod()
public function withPostMethodAndInvalidTokenKey()
{
}

#[IsCsrfTokenValid('foo', tokenSource: IsCsrfTokenValid::SOURCE_QUERY)]
public function withCustomTokenSourceQuery()
{
}

#[IsCsrfTokenValid('foo', tokenSource: IsCsrfTokenValid::SOURCE_QUERY | IsCsrfTokenValid::SOURCE_PAYLOAD)]
public function withCustomTokenSourceQueryPayload()
{
}

#[IsCsrfTokenValid('foo', tokenKey: 'my_token_key', tokenSource: IsCsrfTokenValid::SOURCE_HEADER)]
public function withCustomTokenSourceHeaderAndCustomSourceToken()
{
}
}
Loading