Skip to content

Commit 0e8e0da

Browse files
[Security] Implement double-submit CSRF protection
1 parent f654df3 commit 0e8e0da

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, this will fall back to
26+
* using the Origin or 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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy