8000 [Security] Implement double-submit CSRF protection · symfony/symfony@09d6d78 · GitHub
[go: up one dir, main page]

Skip to content

Commit 09d6d78

Browse files
[Security] Implement double-submit CSRF protection
1 parent f654df3 commit 09d6d78

File tree

6 files changed

+224
-2
lines changed

6 files changed

+224
-2
lines changed

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -237,6 +237,7 @@ 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()
240241
->end()
241242
->end()
242243
->end()

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

Lines changed: 6 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,11 @@ 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+
769+
if (!$config['form']['csrf_protection']['header_name'] || !class_exists(DoubleSubmitCsrfTokenManager::class)) {
770+
$container->setAlias('form.type_extension.csrf.token_manager', 'security.csrf.token_manager');
771+
}
766772
} else {
767773
$container->setParameter('form.type_extension.csrf.enabled', false);
768774
}

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+
])
36+
->tag('monolog.logger', ['channel' => 'request'])
37+
->tag('kernel.event_listener', ['event' => 'kernel.response', 'method' => 'onKernelResponse'])
2838
;
2939
};

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,9 @@ 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-
]);
76+
] + (
77+
$options['csrf_data_attr'] ? ['attr' => ['data-'.$options['csrf_data_attr'] => '']] : []
78+
));
7779

7880
$view->children[$options['csrf_field_name']] = $csrfForm->createView($view);
7981
}
@@ -84,10 +86,20 @@ public function configureOptions(OptionsResolver $resolver): void
8486
$resolver->setDefaults([
8587
'csrf_protection' => $this->defaultEnabled,
8688
'csrf_field_name' => $this->defaultFieldName,
89+
8790
'csrf_message' => 'The CSRF token is invalid. Please try to resubmit the form.',
8891
'csrf_token_manager' => $this->defaultTokenManager,
8992
'csrf_token_id' => null,
93+
94+
'csrf_data_attr' => 'csrf-protection',
9095
]);
96+
97+
$resolver->setAllowedTypes('csrf_protection', 'bool');
98+
$resolver->setAllowedTypes('csrf_field_name', 'string');
99+
$resolver->setAllowedTypes('csrf_message', 'string');
100+
$resolver->setAllowedTypes('csrf_token_manager', CsrfTokenManagerInterface::class);
101+
$resolver->setAllowedTypes('csrf_token_id', ['null', 'string']);
102+
$resolver->setAllowedTypes('csrf_data_attr', ['null', 'string']);
91103
}
92104

93105
public static function getExtendedTypes(): iterable

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: 188 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,188 @@
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+
* This manager is designed to be stateless and compatible with HTTP-caching.
25+
26+
* First, we validate the source of the request using the Origin/Referer headers. This relies
27+
* on the app being able to know its own target origin. Don't miss configuring your reverse proxy to
28+
* send the X-Forwarded-* / Forwarded headers if you're behind one.
29+
*
30+
* Then, we validate the request using a cookie and a custom header. If present, both should contain
31+
* the same token value. A JavaScript snippet on the client side is responsible performing this
32+
* double-submission. The token value should be regenerated on every request using a cryptographically
33+
* secure random generator.
34+
*
35+
* If either double-submit or Origin/Referer headers are missing, it typically indicates that
36+
* JavaScript is disabled on the client side, or that the JavaScript snippet was not properly
37+
* implemented, or that the Origin/Referer headers were filtered out.
38+
*
39+
* Requests lacking both double-submit and origin information are deemed insecure.
40+
*
41+
* When a session is found, a behavioral check is added to ensure that the validation method does not
42+
* downgrade from double-submit to origin checks. This prevents attackers from exploiting potentially
43+
* less secure validation methods once a more secure method has been confirmed as functional.
44+
*
45+
* On HTTPS connections, the cookie is prefixed with "__Host-" to prevent it from being forged on an
46+
* HTTP channel. On the JS side, the cookie should be set with samesite=strict to strenghen the CSRF
47+
* protection. The cookie is always cleared on the response to prevent any further use of the token.
48+
*
49+
* @author Nicolas Grekas <p@tchwork.com>
50+
*/
51+
final class DoubleSubmitCsrfTokenManager implements CsrfTokenManagerInterface
52+
{
53+
public const TOKEN_MIN_LENGTH = 32;
54+
55+
public function __construct(
56+
private RequestStack $requestStack,
57< 2851 /td>+
private ?LoggerInterface $logger = null,
58+
private string $headerName = 'x-csrf-token',
59+
) {
60+
}
61+
62+
public function getToken(string $tokenId): CsrfToken
63+
{
64+
return new CsrfToken($tokenId, $this->headerName);
65+
}
66+
67+
public function refreshToken(string $tokenId): CsrfToken
68+
{
69+
return new CsrfToken($tokenId, $this->headerName);
70+
}
71+
72+
public function removeToken(string $tokenId): ?string
73+
{
74+
return null;
75+
}
76+
77+
public function isTokenValid(CsrfToken $token): bool
78+
{
79+
// This token is not for us
80+
if ($token->getValue() !== $this->headerName) {
81+
$this->logger?->debug('CSRF validation failed: Unknown CSRF token.');
82+
83+
return false;
84+
}
85+
86+
if (!$request = $this->requestStack->getCurrentRequest()) {
87+
$this->logger?->debug('CSRF validation failed: No request found.');
88+
89+
return false;
90+
}
91+
92+
if (false === $isValidOrigin = $this->isValidOrigin($request)) {
93+
$this->logger?->debug('CSRF validation failed: Origin doesn\'t match.');
94+
95+
return false;
96+
}
97+
98+
if ($this->isValidDoubleSubmit($request)) {
99+
// Mark the request as validated using double-submit info
100+
$request->attributes->set($this->headerName, 'double-submit');
101+
$this->logger?->debug('CSRF validation accepted using double-submit info.');
102+
103+
return true;
104+
}
105+
106+
// Opportunistically lookup at the session for a previous CSRF validation strategy
107+
$session = $request->hasPreviousSession() ? $request->getSession() : null;
108+
$usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0;
109+
$usageIndexReference = \PHP_INT_MIN;
110+
$csrfProtection = $session?->get($this->headerName);
111+
$usageIndexReference = $usageIndexValue;
112+
113+
// If a previous request was validated using double-submit info, stick to it
114+
if ('double-submit' === $csrfProtection) {
115+
$this->logger?->debug('CSRF validation failed: double-submit info was used in a previous request but didn\'t pass this time.');
116+
117+
return false;
118+
}
119+
120+
// If a previous request was validated using origin info, stick to it
121+
if ('origin' === $csrfProtection && null === $isValidOrigin) {
122+
$this->logger?->debug('CSRF validation failed: origin info was used in a previous request but didn\'t pass this time.');
123+
124+
return false;
125+
}
126+
127+
if (true === $isValidOrigin) {
128+
// Mark the request as validated using origin info
129+
$request->attributes->set($this->headerName, 'origin');
130+
$this->logger?->debug('CSRF validation accepted using origin info.');
131+
132+
return true;
133+
}
134+
135+
$this->logger?->debug('CSRF validation failed: double-submit and origin info not found.');
136+
137+
return false;
138+
}
139+
140+
public function clearCookie(Request $request, Response $response): void
141+
{
142+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->headerName;
143+
144+
if ($request->cookies->has($cookieName)) {
145+
$response->headers->clearCookie($cookieName, '/', null, $request->isSecure(), false, 'strict');
146+
}
147+
}
148+
149+
public function persistStrategy(Request $request): void
150+
{
151+
if ($request->hasSession(true) && $request->attributes->has($this->headerName)) {
152+
$request->getSession()->set($this->headerName, $request->attributes->get($this->headerName));
153+
}
154+
}
155+
156+
public function onKernelResponse(ResponseEvent $event): void
157+
{
158+
if (!$event->isMainRequest()) {
159+
return;
160+
}
161+
162+
$this->clearCookie($event->getRequest(), $event->getResponse());
163+
$this->persistStrategy($event->getRequest());
164+
}
165+
166+
/**
167+
* @return bool|null Whether the origin is valid, null if missing
168+
*/
169+
private function isValidOrigin(Request $request): ?bool
170+
{
171+
$source = $request->headers->get('Origin') ?? $request->headers->get('Referer') ?? 'null';
172+
173+
return 'null' === $source ? null : str_starts_with($source.'/', $request->getScheme().'://'.$request->getHttpHost().'/');
174+
}
175+
176+
private function isValidDoubleSubmit(Request $request): bool
177+
{
178+
$token = $request->headers->get($this->headerName);
179+
180+
if (!\is_string($token) || \strlen($token) < self::TOKEN_MIN_LENGTH) {
181+
return false;
182+
}
183+
184+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->headerName;
185+
186+
return $request->cookies->get($cookieName) === $token;
187+
}
188+
}

0 commit comments

Comments
 (0)
0