Skip to content

Commit 42233a3

Browse files
[Security] Implement double-submit CSRF protection
1 parent f654df3 commit 42233a3

File tree

6 files changed

+211
-1
lines changed

6 files changed

+211
-1
lines changed

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,8 @@ private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableI
237237
->children()
238238
->booleanNode('enabled')->defaultNull()->end() // defaults to framework.csrf_protection.enabled
239239
->scalarNode('field_name')->defaultValue('_token')->end()
240+
->scalarNode('header_name')->defaultValue('x-csrf-token')->end()
241+
->booleanNode('accept_as_fallback')->defaultFalse()->end()
240242
->end()
241243
->end()
242244
->end()

src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -149,6 +149,7 @@
149149
use Symfony\Component\Security\Core\AuthenticationEvents;
150150
use Symfony\Component\Security\Core\Exception\AuthenticationException;
151151
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
152+
use Symfony\Component\Security\Csrf\DoubleSubmitCsrfTokenManager;
152153
use Symfony\Component\Semaphore\PersistingStoreInterface as SemaphoreStoreInterface;
153154
use Symfony\Component\Semaphore\Semaphore;
154155
use Symfony\Component\Semaphore\SemaphoreFactory;
@@ -763,6 +764,12 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont
763764

764765
$container->setParameter('form.type_extension.csrf.enabled', true);
765766
$container->setParameter('form.type_extension.csrf.field_name', $config['form']['csrf_protection']['field_name']);
767+
$container->setParameter('form.type_extension.csrf.header_name', $config['form']['csrf_protection']['header_name']);
768+
$container->setParameter('form.type_extension.csrf.accept_as_fallback', $config['form']['csrf_protection']['accept_as_fallback']);
769+
770+
if (!$config['form']['csrf_protection']['header_name'] || !class_exists(DoubleSubmitCsrfTokenManager::class)) {
771+
$container->setAlias('form.type_extension.csrf.token_manager', 'security.csrf.token_manager');
772+
}
766773
} else {
767774
$container->setParameter('form.type_extension.csrf.enabled', false);
768775
}

src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,18 +12,28 @@
1212
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
1313

1414
use Symfony\Component\Form\Extension\Csrf\Type\FormTypeCsrfExtension;
15+
use Symfony\Component\Security\Csrf\DoubleSubmitCsrfTokenManager;
1516

1617
return static function (ContainerConfigurator $container) {
1718
$container->services()
1819
->set('form.type_extension.csrf', FormTypeCsrfExtension::class)
1920
->args([
20-
service('security.csrf.token_manager'),
21+
service('form.type_extension.csrf.token_manager'),
2122
param('form.type_extension.csrf.enabled'),
2223
param('form.type_extension.csrf.field_name'),
2324
service('translator')->nullOnInvalid(),
2425
param('validator.translation_domain'),
2526
service('form.server_params'),
2627
])
2728
->tag('form.type_extension')
29+
30+
->set('form.type_extension.csrf.token_manager', DoubleSubmitCsrfTokenManager::class)
31+
->args([
32+
service('request_stack'),
33+
service('logger')->nullOnInvalid(),
34+
param('form.type_extension.csrf.header_name'),
35+
param('form.type_extension.csrf.accept_as_fallback'),
36+
])
37+
->tag('monolog.logger', ['channel' => 'request'])
2838
;
2939
};

src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ public function finishView(FormView $view, FormInterface $form, array $options):
7373
$csrfForm = $factory->createNamed($options['csrf_field_name'], HiddenType::class, $data, [
7474
'block_prefix' => 'csrf_token',
7575
'mapped' => false,
76+
'attr' => ['data--csrf-protection' => true],
7677
]);
7778

7879
$view->children[$options['csrf_field_name']] = $csrfForm->createView($view);

src/Symfony/Component/Security/Csrf/CHANGELOG.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,11 @@
11
CHANGELOG
22
=========
33

4+
7.2
5+
---
6+
7+
* Add `DoubleSubmitCsrfTokenManager`
8+
49
6.0
510
---
611

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
<?php
2+
3+
/*
4+
* This file is part of the Symfony package.
5+
*
6+
* (c) Fabien Potencier <fabien@symfony.com>
7+
*
8+
* For the full copyright and license information, please view the LICENSE
9+
* file that was distributed with this source code.
10+
*/
11+
12+
namespace Symfony\Component\Security\Csrf;
13+
14+
use Psr\Log\LoggerInterface;
15+
use Symfony\Component\HttpFoundation\Request;
16+
use Symfony\Component\HttpFoundation\RequestStack;
17+
use Symfony\Component\HttpFoundation\Response;
18+
use Symfony\Component\HttpFoundation\Session\Session;
19+
use Symfony\Component\HttpKernel\Event\ResponseEvent;
20+
21+
/**
22+
* This CSRF token manager uses a combination of cookie and headers to validate non-persistent tokens.
23+
*
24+
* Double-Submit Validation: A JavaScript snippet on the client side is responsible for performing the
25+
* double-submission. If the double-submit information is missing, we fall back to using the Origin or
26+
* Referer headers.
27+
*
28+
* Fallback Scenarios: If neither double-submit nor Origin/Referer headers are available, it typically
29+
* indicates that JavaScript is disabled on the client side (unless the JavaScript snippet was not
30+
* properly implemented), or that the Origin header was not sent.
31+
*
32+
* By default, requests lacking both double-submit and origin information are deemed insecure.
33+
*
34+
* Security Consistency: When a session is found, a behavioral check is added to ensure that the
35+
* validation method does not downgrade from double-submit to origin checks, or from origin checks to
36+
* the accept fallback. This prevents attackers from exploiting potentially less secure validation
37+
* methods once a more secure method has been confirmed as functional.
38+
*
39+
* @author Nicolas Grekas <p@tchwork.com>
40+
*/
41+
final class DoubleSubmitCsrfTokenManager implements CsrfTokenManagerInterface
42+
{
43+
public const HEADER_NAME = 'x-csrf-token';
44+
45+
public function __construct(
46+
private RequestStack $requestStack,
47+
private ?LoggerInterface $logger = null,
48+
private string $headerName = self::HEADER_NAME,
49+
private bool $acceptAsFallback = false,
50+
) {
51+
}
52+
53+
public function getToken(string $tokenId): CsrfToken
54+
{
55+
return new CsrfToken($tokenId, $this->headerName);
56+
}
57+
58+
public function refreshToken(string $tokenId): CsrfToken
59+
{
60+
return new CsrfToken($tokenId, $this->headerName);
61+
}
62+
63+
public function removeToken(string $tokenId): ?string
64+
{
65+
return null;
66+
}
67+
68+
public function isTokenValid(CsrfToken $token): bool
69+
{
70+
// This token is not for us
71+
if ($token->getValue() !== $this->headerName) {
72+
$this->logger?->debug('CSRF validation failed: Unknown CSRF token.');
73+
74+
return false;
75+
}
76+
77+
if (!$request = $this->requestStack->getCurrentRequest()) {
78+
$this->logger?->debug('CSRF validation failed: No request found.');
79+
80+
return false;
81+
}
82+
83+
if (false === $isValidOrigin = $this->isValidOrigin($request)) {
84+
$this->logger?->debug('CSRF validation failed: Origin doesn\'t match.');
85+
86+
return false;
87+
}
88+
89+
if ($this->isValidDoubleSubmit($request)) {
90+
// Mark the request as validated using double-submit info
91+
$request->attributes->set($this->headerName, 'double-submit');
92+
$this->logger?->debug('CSRF validation accepted using double-submit info.');
93+
94+
return true;
95+
}
96+
97+
// Opportunistically lookup at the session for a previous CSRF validation strategy
98+
$session = $request->hasPreviousSession() ? $request->getSession() : null;
99+
$usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0;
100+
$usageIndexReference = \PHP_INT_MIN;
101+
$csrfProtection = $session?->get($this->headerName);
102+
$usageIndexReference = $usageIndexValue;
103+
104+
// If a previous request was validated using double-submit info, stick to it
105+
if ('double-submit' === $csrfProtection) {
106+
$this->logger?->debug('CSRF validation failed: double-submit info was used in a previous request but didn\'t pass this time.');
107+
108+
return false;
109+
}
110+
111+
// If a previous request was validated using origin info, stick to it
112+
if ('origin' === $csrfProtection && null === $isValidOrigin) {
113+
$this->logger?->debug('CSRF validation failed: origin info was used in a previous request but didn\'t pass this time.');
114+
115+
return false;
116+
}
117+
118+
if (true === $isValidOrigin) {
119+
// Mark the request as validated using origin info
120+
$request->attributes->set($this->headerName, 'origin');
121+
$this->logger?->debug('CSRF validation accepted using origin info.');
122+
123+
return true;
124+
}
125+
126+
if ($this->acceptAsFallback) {
127+
$this->logger?->debug('CSRF validation accepted despite the absence of double-submit and origin info.');
128+
129+
return true;
130+
}
131+
132+
$this->logger?->debug('CSRF validation failed: double-submit and origin info not found.');
133+
134+
return false;
135+
}
136+
137+
public function clearCookie(Request $request, Response $response): void
138+
{
139+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->headerName;
140+
141+
if (!$request->cookies->has($cookieName)) {
142+
$response->headers->clearCookie($cookieName, '/', null, $request->isSecure(), false, 'strict');
143+
}
144+
}
145+
146+
public function persistStrategy(Request $request): void
147+
{
148+
if ($request->hasSession(true) && $request->attributes->has($this->headerName)) {
149+
$request->getSession()->set($this->headerName, $request->attributes->get($this->headerName));
150+
}
151+
}
152+
153+
public function onKernelResponse(ResponseEvent $event): void
154+
{
155+
if (!$event->isMainRequest()) {
156+
return;
157+
}
158+
159+
$this->clearCookie($event->getRequest(), $event->getResponse());
160+
$this->persistStrategy($event->getRequest());
161+
}
162+
163+
/**
164+
* @return bool|null Whether the origin is valid, null if missing
165+
*/
166+
private function isValidOrigin(Request $request): ?bool
167+
{
168+
$source = $request->headers->get('Origin') ?? $request->headers->get('Referer') ?? 'null';
169+
170+
return 'null' === $source ? null : str_starts_with($source.'/', $request->getScheme().'://'.$request->getHttpHost().'/');
171+
}
172+
173+
private function isValidDoubleSubmit(Request $request): bool
174+
{
175+
$token = $request->headers->get($this->headerName);
176+
177+
if (!\is_string($token) || \strlen($token) < 32) {
178+
return false;
179+
}
180+
181+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->headerName;
182+
183+
return $request->cookies->get($cookieName) === $token;
184+
}
185+
}

0 commit comments

Comments
 (0)