8000 feature #58095 [Security] Implement stateless headers/cookies-based C… · symfony/symfony@f57a6de · GitHub
[go: up one dir, main page]

Skip to content

Commit f57a6de

Browse files
feature #58095 [Security] Implement stateless headers/cookies-based CSRF protection (nicolas-grekas)
This PR was merged into the 7.2 branch. Discussion ---------- [Security] Implement stateless headers/cookies-based CSRF protection | Q | A | ------------- | --- | Branch? | 7.2 | Bug fix? | no | New feature? | yes | Deprecations? | no | Issues | #13464 | License | MIT #54705 made me think about our CSRF protection and I wrote the attached CSRF token manager to implement stateless headers/cookies-based validation. By defaults, the existing stateful manager is used. In order to leverage this new stateless manager, one needs to list the token ids that should be managed this way: ```yaml framework: csrf_protection: stateless_token_ids: [my_stateless_token_id] ``` * This CSRF token manager uses a combination of cookie and headers to validate non-persistent tokens. * * This manager is designed to be stateless and compatible with HTTP-caching. * * First, we validate the source of the request using the Origin/Referer headers. This relies * on the app being able to know its own target origin. Don't miss configuring your reverse proxy to * send the X-Forwarded-* / Forwarded headers if you're behind one. * * Then, we validate the request using a cookie and a CsrfToken. If the cookie is found, it should * contain the same value as the CsrfToken. A JavaScript snippet on the client side is responsible * for performing this double-submission. The token value should be regenerated on every request * using a cryptographically secure random generator. * * If either double-submit or Origin/Referer headers are missing, it typically indicates that * JavaScript is disabled on the client side, or that the JavaScript snippet was not properly * implemented, or that the Origin/Referer headers were filtered out. * * Requests lacking both double-submit and origin information are deemed insecure. * * When a session is found, a behavioral check is added to ensure that the validation method does not * downgrade from double-submit to origin checks. This prevents attackers from exploiting potentially * less secure validation methods once a more secure method has been confirmed as functional. * * On HTTPS connections, the cookie is prefixed with "__Host-" to prevent it from being forged on an * HTTP channel. On the JS side, the cookie should be set with samesite=strict to strengthen the CSRF * protection. The cookie is always cleared on the response to prevent any further use of the token. * * The $checkHeader argument allows the token to be checked in a header instead of or in addition to a * cookie. This makes it harder for an attacker to forge a request, though it may also pose challenges * when setting the header depending on the client-side framework in use. * * When a fallback CSRF token manager is provided, only tokens listed in the $tokenIds argument will be * manag 8000 ed by this manager. All other tokens will be delegated to the fallback manager. ``` Since it's stateless, end users won't loose their content if they take time to submit a form: even if the session is destroyed while they populate their form, remember-me will reconnect them and the form will be accepted. Recipe update at symfony/recipes#1337 Commits ------- 27d8a31 [Security] Implement stateless headers/cookies-based CSRF protection
2 parents b61db2f + 27d8a31 commit f57a6de

File tree

13 files changed

+608
-10
lines changed

13 files changed

+608
-10
lines changed

src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,8 @@ CHANGELOG
1414
* Deprecate making `cache.app` adapter taggable, use the `cache.app.taggable` adapter instead
1515
* Enable `json_decode_detailed_errors` in the default serializer context in debug mode by default when `seld/jsonlint` is installed
1616
* Register `Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter` as a service named `serializer.name_converter.snake_case_to_camel_case` if available
17+
* Add `framework.csrf_protection.stateless_token_ids`, `.cookie_name`, and `.check_header` options to use stateless headers/cookies-based CSRF protection
18+
* Add `framework.form.csrf_protection.field_attr` option
1719
* Deprecate `session.sid_length` and `session.sid_bits_per_character` config options
1820
* Add the ability to use an existing service as a lock/semaphore resource
1921
* Add support for configuring multiple serializer instances via the configuration

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

Lines changed: 22 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -209,9 +209,22 @@ private function addCsrfSection(ArrayNodeDefinition $rootNode): void
209209
->treatTrueLike(['enabled' => true])
210210
->treatNullLike(['enabled' => true])
211211
->addDefaultsIfNotSet()
212+
->fixXmlConfig('stateless_token_id')
212213
->children()
213-
// defaults to framework.session.enabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class)
214-
->booleanNode('enabled')->defaultNull()->end()
214+
// defaults to framework.csrf_protection.stateless_token_ids || framework.session.enabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class)
215+
->scalarNode('enabled')->defaultNull()->end()
216+
->arrayNode('stateless_token_ids')
217+
->scalarPrototype()->end()
218+
->info('Enable headers/cookies-based CSRF validation for the listed token ids.')
219+
->end()
220+
->scalarNode('check_header')
221+
->defaultFalse()
222+
->info('Whether to check the CSRF token in a header in addition to a cookie when using stateless protection.')
223+
->end()
224+
->scalarNode('cookie_name')
225+
->defaultValue('csrf-token')
226+
->info('The name of the cookie to use when using stateless protection.')
227+
->end()
215228
->end()
216229
->end()
217230
->end()
@@ -232,8 +245,14 @@ private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableI
232245
->treatNullLike(['enabled' => true])
233246
->addDefaultsIfNotSet()
234247
->children()
235-
->booleanNode('enabled')->defaultNull()->end() // defaults to framework.csrf_protection.enabled
248+
->scalarNode('enabled')->defaultNull()->end() // defaults to framework.csrf_protection.enabled
249+
->scalarNode('token_id')->defaultNull()->end()
236250
->scalarNode('field_name')->defaultValue('_token')->end()
251+
->arrayNode('field_attr')
252+
->performNoDeepMerging()
253+
->scalarPrototype()->end()
254+
->defaultValue(['data-controller' => 'csrf-protection'])
255+
->end()
237256
->end()
238257
->end()
239258
->end()

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

Lines changed: 24 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ public function load(array $configs, ContainerBuilder $container): void
464464

465465
// csrf depends on session being registered
466466
if (null === $config['csrf_protection']['enabled']) {
467-
$this->writeConfigEnabled('csrf_protection', $this->readConfigEnabled('session', $container, $config['session']) && !class_exists(FullStack::class) && ContainerBuilder::willBeAvailable('symfony/security-csrf', CsrfTokenManagerInterface::class, ['symfony/framework-bundle']), $config['csrf_protection']);
467+
$this->writeConfigEnabled('csrf_protection', $config['csrf_protection']['stateless_token_ids'] || $this->readConfigEnabled('session', $container, $config['session']) && !class_exists(FullStack::class) && ContainerBuilder::willBeAvailable('symfony/security-csrf', CsrfTokenManagerInterface::class, ['symfony/framework-bundle']), $config['csrf_protection']);
468468
}
469469
$this->registerSecurityCsrfConfiguration($config['csrf_protection'], $container, $loader);
470470

@@ -765,6 +765,10 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont
765765

766766
$container->setParameter('form.type_extension.csrf.enabled', true);
767767
$container->setParameter('form.type_extension.csrf.field_name', $config['form']['csrf_protection']['field_name']);
768+
$container->setParameter('form.type_extension.csrf.field_attr', $config['form']['csrf_protection']['field_attr']);
769+
770+
$container->getDefinition('form.type_extension.csrf')
771+
->replaceArgument(7, $config['form']['csrf_protection']['token_id']);
768772
} else {
769773
$container->setParameter('form.type_extension.csrf.enabled', false);
770774
}
@@ -1815,8 +1819,7 @@ private function registerSecurityCsrfConfiguration(array $config, ContainerBuild
18151819
if (!class_exists(\Symfony\Component\Security\Csrf\CsrfToken::class)) {
18161820
throw new LogicException('CSRF support cannot be enabled as the Security CSRF component is not installed. Try running "composer require symfony/security-csrf".');
18171821
}
1818-
1819-
if (!$this->isInitializedConfigEnabled('session')) {
1822+
if (!$config['stateless_token_ids'] && !$this->isInitializedConfigEnabled('session')) {
18201823
throw new \LogicException('CSRF protection needs sessions to be enabled.');
18211824
}
18221825

@@ -1826,6 +1829,24 @@ private function registerSecurityCsrfConfiguration(array $config, ContainerBuild
18261829
if (!class_exists(CsrfExtension::class)) {
18271830
$container->removeDefinition('twig.extension.security_csrf');
18281831
}
1832+
1833+
if (!$config['stateless_token_ids']) {
1834+
$container->removeDefinition('security.csrf.same_origin_token_manager');
1835+
1836+
return;
1837+
}
1838+
1839+
$container->getDefinition('security.csrf.same_origin_token_manager')
1840+
->replaceArgument(3, $config['stateless_token_ids'])
1841+
->replaceArgument(4, $config['check_header'])
1842+
->replaceArgument(5, $config['cookie_name']);
1843+
1844+
if (!$this->isInitializedConfigEnabled('session')) {
1845+
$container->setAlias('security.csrf.token_manager', 'security.csrf.same_origin_token_manager');
1846+
$container->getDefinition('security.csrf.same_origin_token_manager')
1847+
->setDecoratedService(null)
1848+
->replaceArgument(2, null);
1849+
}
18291850
}
18301851

18311852
private function registerSerializerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void

src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,8 @@
2323
service('translator')->nullOnInvalid(),
2424
param('validator.translation_domain'),
2525
service('form.server_params'),
26+
param('form.type_extension.csrf.field_attr'),
27+
abstract_arg('framework.form.csrf_protection.token_id'),
2628
])
2729
->tag('form.type_extension')
2830
;

src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,12 +71,25 @@
7171
</xsd:complexType>
7272

7373
<xsd:complexType name="form_csrf_protection">
74+
<xsd:sequence>
75+
<xsd:element name="field-attr" type="field_attr" minOccurs="0" maxOccurs="unbounded" />
76+
</xsd:sequence>
7477
<xsd:attribute name="enabled" type="xsd:boolean" />
78+
<xsd:attribute name="token-id" type="xsd:string" />
7579
<xsd:attribute name="field-name" type="xsd:string" />
7680
</xsd:complexType>
7781

82+
<xsd:complexType name="field_attr">
83+
<xsd:attribute name="name" type="xsd:string" use="required"/>
84+
</xsd:complexType>
85+
7886
<xsd:complexType name="csrf_protection">
87+
<xsd:sequence>
88+
<xsd:element name="stateless-token-id" type="xsd:string" minOccurs="0" maxOccurs="unbounded" />
89+
</xsd:sequence>
7990
<xsd:attribute name="enabled" type="xsd:boolean" />
91+
<xsd:attribute name="check-header" type="xsd:string" />
92+
<xsd:attribute name="cookie-name" type="xsd:string" />
8093
</xsd:complexType>
8194

8295
<xsd:complexType name="esi">

src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
use Symfony\Bridge\Twig\Extension\CsrfRuntime;
1616
use Symfony\Component\Security\Csrf\CsrfTokenManager;
1717
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
18+
use Symfony\Component\Security\Csrf\SameOriginCsrfTokenManager;
1819
use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface;
1920
use Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator;
2021
use Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage;
@@ -46,5 +47,18 @@
4647

4748
->set('twig.extension.security_csrf', CsrfExtension::class)
4849
->tag('twig.extension')
50+
51+
->set('security.csrf.same_origin_token_manager', SameOriginCsrfTokenManager::class)
52+
->decorate('security.csrf.token_manager')
53+
->args([
54+
service('request_stack'),
55+
service('logger')->nullOnInvalid(),
56+
service('.inner'),
57+
abstract_arg('framework.csrf_protection.stateless_token_ids'),
58+
abstract_arg('framework.csrf_protection.check_header'),
59+ F438
abstract_arg('framework.csrf_protection.cookie_name'),
60+
])
61+
->tag('monolog.logger', ['channel' => 'request'])
62+
->tag('kernel.event_listener', ['event' => 'kernel.response', 'method' => 'onKernelResponse'])
4963
;
5064
};

src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -715,13 +715,18 @@ protected static function getBundleDefaultConfig()
715715
'trusted_proxies' => ['%env(default::SYMFONY_TRUSTED_PROXIES)%'],
716716
'trusted_headers' => ['%env(default::SYMFONY_TRUSTED_HEADERS)%'],
717717
'csrf_protection' => [
718-
'enabled' => false,
718+
'enabled' => null,
719+
'cookie_name' => 'csrf-token',
720+
'check_header' => false,
721+
'stateless_token_ids' => [],
719722
],
720723
'form' => [
721724
'enabled' => !class_exists(FullStack::class),
722725
'csrf_protection' => [
723726
'enabled' => null, // defaults to csrf_protection.enabled
724727
'field_name' => '_token',
728+
'field_attr' => ['data-controller' => 'csrf-protection'],
729+
'token_id' => null,
725730
],
726731
],
727732
'esi' => ['enabled' => false],

src/Symfony/Bundle/FrameworkBundle/composer.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -96,7 +96,7 @@
9696
"symfony/runtime": "<6.4.13|>=7.0,<7.1.6",
9797
"symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4",
9898
"symfony/serializer": "<6.4",
99-
"symfony/security-csrf": "<6.4",
99+
"symfony/security-csrf": "<7.2",
100100
"symfony/security-core": "<6.4",
101101
"symfony/stopwatch": "<6.4",
102102
"symfony/translation": "<6.4",

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

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@
1919
use Symfony\Component\Form\FormInterface;
2020
use Symfony\Component\Form\FormView;
2121
use Symfony\Component\Form\Util\ServerParams;
22+
use Symfony\Component\OptionsResolver\Options;
2223
use Symfony\Component\OptionsResolver\OptionsResolver;
2324
use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface;
2425
use Symfony\Contracts\Translation\TranslatorInterface;
@@ -35,6 +36,8 @@ public function __construct(
3536
private ?TranslatorInterface $translator = null,
3637
private ?string $translationDomain = null,
3738
private ?ServerParams $serverParams = null,
39+
private array $fieldAttr = [],
40+
private ?string $defaultTokenId = null,
3841
) {
3942
}
4043

@@ -73,6 +76,7 @@ public function finishView(FormView $view, FormInterface $form, array $options):
7376
$csrfForm = $factory->createNamed($options['csrf_field_name'], HiddenType::class, $data, [
7477
'block_prefix' => 'csrf_token',
7578
'mapped' => false,
79+
'attr' => $this->fieldAttr + ['autocomplete' => 'off'],
7680
]);
7781

7882
$view->children[$options['csrf_field_name']] = $csrfForm->createView($view);
@@ -81,13 +85,24 @@ public function finishView(FormView $view, FormInterface $form, array $options):
8185

8286
public function configureOptions(OptionsResolver $resolver): void
8387
{
88+
if ($defaultTokenId = $this->defaultTokenId) {
89+
$defaultTokenManager = $this->defaultTokenManager;
90+
$defaultTokenId = static fn (Options $options) => $options['csrf_token_manager'] === $defaultTokenManager ? $defaultTokenId : null;
91+
}
92+
8493
$resolver->setDefaults([
8594
'csrf_protection' => $this->defaultEnabled,
8695
'csrf_field_name' => $this->defaultFieldName,
8796
'csrf_message' => 'The CSRF token is invalid. Please try to resubmit the form.',
8897
'csrf_token_manager' => $this->defaultTokenManager,
89-
'csrf_token_id' => null,
98+
'csrf_token_id' => $defaultTokenId,
9099
]);
100+
101+
$resolver->setAllowedTypes('csrf_protection', 'bool');
102+
$resolver->setAllowedTypes('csrf_field_name', 'string');
103+
$resolver->setAllowedTypes('csrf_message', 'string');
104+
$resolver->setAllowedTypes('csrf_token_manager', CsrfTokenManagerInterface::class);
105+
$resolver->setAllowedTypes('csrf_token_id', ['null', 'string']);
91106
}
92107

93108
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 `SameOriginCsrfTokenManager`
8+
49
6.0
510
---
611

0 commit comments

Comments
 (0)
0