Skip to content

Navigation Menu

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Appearance settings

Commit 42233a3

Browse filesBrowse files
[Security] Implement double-submit CSRF protection
1 parent f654df3 commit 42233a3
Copy full SHA for 42233a3

File tree

6 files changed

+211
-1
lines changed
Filter options

6 files changed

+211
-1
lines changed

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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php
+2Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php
+7Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php
+11-1Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php
+1Lines 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

Copy file name to clipboardExpand all lines: src/Symfony/Component/Security/Csrf/CHANGELOG.md
+5Lines 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

+185Lines 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 (0)
Morty Proxy This is a proxified and sanitized view of the page, visit original site.