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

Skip to content

Commit 5b81f2f

Browse files
[Security] Implement double-submit CSRF protection
1 parent f654df3 commit 5b81f2f

File tree

6 files changed

+196
-1
lines changed

6 files changed

+196
-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: 170 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,170 @@
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+
* A CSRF token manager that uses a cookie and a header to validate non-persistent tokens.
23+
*
24+
* @author Nicolas Grekas <p@tchwork.com>
25+
*/
26+
final class DoubleSubmitCsrfTokenManager implements CsrfTokenManagerInterface
27+
{
28+
public const HEADER_NAME = 'x-csrf-token';
29+
30+
public function __construct(
31+
private RequestStack $requestStack,
32+
private ?LoggerInterface $logger = null,
33+
private string $headerName = self::HEADER_NAME,
34+
private bool $acceptAsFallback = false,
35+
) {
36+
}
37+
38+
public function getToken(string $tokenId): CsrfToken
39+
{
40+
return new CsrfToken($tokenId, $this->headerName);
41+
}
42+
43+
public function refreshToken(string $tokenId): CsrfToken
44+
{
45+
return new CsrfToken($tokenId, $this->headerName);
46+
}
47+
48+
public function removeToken(string $tokenId): ?string
49+
{
50+
return null;
51+
}
52+
53+
public function isTokenValid(CsrfToken $token): bool
54+
{
55+
// This token is not for us
56+
if ($token->getValue() !== $this->headerName) {
57+
$this->logger?->debug('CSRF validation failed: Unknown CSRF token.');
58+
59+
return false;
60+
}
61+
62+
if (!$request = $this->requestStack->getCurrentRequest()) {
63+
$this->logger?->debug('CSRF validation failed: No request found.');
64+
65+
return false;
66+
}
67+
68+
if (false === $isValidOrigin = $this->isValidOrigin($request)) {
69+
$this->logger?->debug('CSRF validation failed: Origin doesn\'t match.');
70+
71+
return false;
72+
}
73+
74+
if ($this->isValidDoubleSubmit($request)) {
75+
// Mark the request as validated using double-submit info
76+
$request->attributes->set($this->headerName, 'double-submit');
77+
$this->logger?->debug('CSRF validation accepted using double-submit info.');
78+
79+
return true;
80+
}
81+
82+
// Opportunistically lookup at the session for a previous CSRF validation strategy
83+
$session = $request->hasPreviousSession() ? $request->getSession() : null;
84+
$usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0;
85+
$usageIndexReference = \PHP_INT_MIN;
86+
$csrfProtection = $session?->get($this->headerName);
87+
$usageIndexReference = $usageIndexValue;
88+
89+
// If a previous request was validated using double-submit info, stick to it
90+
if ('double-submit' === $csrfProtection) {
91+
$this->logger?->debug('CSRF validation failed: double-submit info was used in a previous request but didn\'t pass this time.');
92+
93+
return false;
94+
}
95+
96+
// If a previous request was validated using origin info, stick to it
97+
if ('origin' === $csrfProtection && null === $isValidOrigin) {
98+
$this->logger?->debug('CSRF validation failed: origin info was used in a previous request but didn\'t pass this time.');
99+
100+
return false;
101+
}
102+
103+
if (true === $isValidOrigin) {
104+
// Mark the request as validated using origin info
105+
$request->attributes->set($this->headerName, 'origin');
106+
$this->logger?->debug('CSRF validation accepted using origin info.');
107+
108+
return true;
109+
}
110+
111+
if ($this->acceptAsFallback) {
112+
$this->logger?->debug('CSRF validation accepted despite the absence of double-submit and origin info.');
113+
114+
return true;
115+
}
116+
117+
$this->logger?->debug('CSRF validation failed: double-submit and origin info not found.');
118+
119+
return false;
120+
}
121+
122+
public function clearCookie(Request $request, Response $response): void
123+
{
124+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->headerName;
125+
126+
if (!$request->cookies->has($cookieName)) {
127+
$response->headers->clearCookie($cookieName, '/', null, $request->isSecure(), false, 'strict');
128+
}
129+
}
130+
131+
public function persistStrategy(Request $request): void
132+
{
133+
if ($request->hasSession(true) && $request->attributes->has($this->headerName)) {
134+
$request->getSession()->set($this->headerName, $request->attributes->get($this->headerName));
135+
}
136+
}
137+
138+
public function onKernelResponse(ResponseEvent $event): void
139+
{
140+
if (!$event->isMainRequest()) {
141+
return;
142+
}
143+
144+
$this->clearCookie($event->getRequest(), $event->getResponse());
145+
$this->persistStrategy($event->getRequest());
146+
}
147+
148+
/**
149+
* @return bool|null Whether the origin is valid, null if missing
150+
*/
151+
private function isValidOrigin(Request $request): ?bool
152+
{
153+
$source = $request->headers->get('Origin') ?? $request->headers->get('Referer') ?? 'null';
154+
155+
return 'null' === $source ? null : str_starts_with($source.'/', $request->getScheme().'://'.$request->getHttpHost().'/');
156+
}
157+
158+
private function isValidDoubleSubmit(Request $request): bool
159+
{
160+
$token = $request->headers->get($this->headerName);
161+
162+
if (!\is_string($token) || \strlen($token) < 32) {
163+
return $token;
164+
}
165+
166+
$cookieName = ($request->isSecure() ? '__Host-' : '').$this->headerName;
167+
168+
return dump($request->cookies->get($cookieName) === $token);
169+
}
170+
}

0 commit comments

Comments
 (0)
0