10000 [Security] Implement double-submit CSRF protection · symfony/symfony@66ae045 · GitHub
[go: up one dir, main page]

Skip to content

Commit 66ae045

Browse files
[Security] Implement double-submit CSRF protection
1 parent f654df3 commit 66ae045

File tree

8 files changed

+237
-6
lines changed

8 files changed

+237
-6
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ CHANGELOG
1111
* [BC BREAK] The `secrets:decrypt-to-local` command terminates with a non-zero exit code when a secret could not be read
1212
* Deprecate making `cache.app` adapter taggable, use the `cache.app.taggable` adapter instead
1313
* Enable `json_decode_detailed_errors` in the default serializer context in debug mode by default when `seld/jsonlint` is installed
14+
* Add `framework.form.csrf_header_name` option that enables double-submit CSRF protection
1415

1516
7.1
1617
---

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

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -214,7 +214,7 @@ private function addCsrfSection(ArrayNodeDefinition $rootNode): void
214214
->addDefaultsIfNotSet()
215215
->children()
216216
// defaults to framework.session.enabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class)
217-
->booleanNode('enabled')->defaultNull()->end()
217+
->scalarNode('enabled')->defaultNull()->end()
218218
->end()
219219
->end()
220220
->end()
@@ -230,13 +230,22 @@ private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableI
230230
->{$enableIfStandalone('symfony/form', Form::class)}()
231231
->children()
232232
->arrayNode('csrf_protection')
233-
->treatFalseLike(['enabled' => false])
234-
->treatTrueLike(['enabled' => true])
235-
->treatNullLike(['enabled' => true])
233+
->beforeNormalization()
234+
->always(static fn ($v) => match (\gettype($v)) {
235+
'string' => ['enabled' => null, 'header_name' => $v],
236+
'boolean' => ['enabled' => $v],
237+
'NULL' => ['enabled' => true],
238+
default => $v,
239+
})
240+
->end()
236241
->addDefaultsIfNotSet()
237242
->children()
238-
->booleanNode('enabled')->defaultNull()->end() // defaults to framework.csrf_protection.enabled
243+
->scalarNode('enabled')->defaultNull()->end() // defaults to framework.csrf_protection.enabled
239244
->scalarNode('field_name')->defaultValue('_token')->end()
245+
->scalarNode('header_name')
246+
->defaultNull()
247+
->info('The name of the HTTP header to use to perform double-submission; defaults to null, which uses stateful CSRF tokens.')
248+
->end()
240249
->end()
241250
->end()
242251
->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/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -725,6 +725,7 @@ protected static function getBundleDefaultConfig()
725725
'csrf_protection' => [
726726
'enabled' => null, // defaults to csrf_protection.enabled
727727
'field_name' => '_token',
728+
'header_name' => null,
728729
],
729730
],
730731
'esi' => ['enabled' => false],

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

Lines changed: 11 additions & 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' => $options['csrf_data_attr'] ? ['data-'.$options['csrf_data_attr'] => ''] : [],
7677
]);
7778

7879
$view->children[$options['csrf_field_name']] = $csrfForm->createView($view);
@@ -84,10 +85,20 @@ public function configureOptions(OptionsResolver $resolver): void
8485
$resolver->setDefaults([
8586
'csrf_protection' => $this->defaultEnabled,
8687
'csrf_field_name' => $this->defaultFieldName,
88+
8789
'csrf_message' => 'The CSRF token is invalid. Please try to resubmit the form.',
8890
'csrf_token_manager' => $this->defaultTokenManager,
8991
'csrf_token_id' => null,
92+
93+
'csrf_data_attr' => 'csrf-protection',
9094
]);
95+
96+
$resolver->setAllowedTypes('csrf_protection', 'bool');
97+
$resolver->setAllowedTypes('csrf_field_name', 'string');
98+
$resolver->setAllowedTypes('csrf_message', 'string');
99+
$resolver->setAllowedTypes('csrf_token_manager', CsrfTokenManagerInterface::class);
100+
$resolver->setAllowedTypes('csrf_token_id', ['null', 'string']);
101+
$resolver->setAllowedTypes('csrf_data_attr', ['null', 'string']);
91102
}
92103

93104
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+
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