From 27d8a31d105ed53a043e4e21321279fb4085a2f1 Mon Sep 17 00:00:00 2001 From: Nicolas Grekas Date: Mon, 26 Aug 2024 16:50:35 +0200 Subject: [PATCH] [Security] Implement stateless headers/cookies-based CSRF protection --- .../Bundle/FrameworkBundle/CHANGELOG.md | 2 + .../DependencyInjection/Configuration.php | 25 +- .../FrameworkExtension.php | 27 +- .../Resources/config/form_csrf.php | 2 + .../Resources/config/schema/symfony-1.0.xsd | 13 + .../Resources/config/security_csrf.php | 14 + .../DependencyInjection/ConfigurationTest.php | 7 +- .../Bundle/FrameworkBundle/composer.json | 2 +- .../Csrf/Type/FormTypeCsrfExtension.php | 17 +- .../Component/Security/Csrf/CHANGELOG.md | 5 + .../Csrf/SameOriginCsrfTokenManager.php | 268 ++++++++++++++++++ .../Tests/SameOriginCsrfTokenManagerTest.php | 232 +++++++++++++++ .../Component/Security/Csrf/composer.json | 4 +- 13 files changed, 608 insertions(+), 10 deletions(-) create mode 100644 src/Symfony/Component/Security/Csrf/SameOriginCsrfTokenManager.php create mode 100644 src/Symfony/Component/Security/Csrf/Tests/SameOriginCsrfTokenManagerTest.php diff --git a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md index 19ce70b6be0ef..3d6acb26a1368 100644 --- a/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md +++ b/src/Symfony/Bundle/FrameworkBundle/CHANGELOG.md @@ -14,6 +14,8 @@ CHANGELOG * Deprecate making `cache.app` adapter taggable, use the `cache.app.taggable` adapter instead * Enable `json_decode_detailed_errors` in the default serializer context in debug mode by default when `seld/jsonlint` is installed * Register `Symfony\Component\Serializer\NameConverter\SnakeCaseToCamelCaseNameConverter` as a service named `serializer.name_converter.snake_case_to_camel_case` if available + * Add `framework.csrf_protection.stateless_token_ids`, `.cookie_name`, and `.check_header` options to use stateless headers/cookies-based CSRF protection + * Add `framework.form.csrf_protection.field_attr` option * Deprecate `session.sid_length` and `session.sid_bits_per_character` config options * Add the ability to use an existing service as a lock/semaphore resource * Add support for configuring multiple serializer instances via the configuration diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php index 7a3983101ae79..9abd10e73b565 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/Configuration.php @@ -209,9 +209,22 @@ private function addCsrfSection(ArrayNodeDefinition $rootNode): void ->treatTrueLike(['enabled' => true]) ->treatNullLike(['enabled' => true]) ->addDefaultsIfNotSet() + ->fixXmlConfig('stateless_token_id') ->children() - // defaults to framework.session.enabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class) - ->booleanNode('enabled')->defaultNull()->end() + // defaults to framework.csrf_protection.stateless_token_ids || framework.session.enabled && !class_exists(FullStack::class) && interface_exists(CsrfTokenManagerInterface::class) + ->scalarNode('enabled')->defaultNull()->end() + ->arrayNode('stateless_token_ids') + ->scalarPrototype()->end() + ->info('Enable headers/cookies-based CSRF validation for the listed token ids.') + ->end() + ->scalarNode('check_header') + ->defaultFalse() + ->info('Whether to check the CSRF token in a header in addition to a cookie when using stateless protection.') + ->end() + ->scalarNode('cookie_name') + ->defaultValue('csrf-token') + ->info('The name of the cookie to use when using stateless protection.') + ->end() ->end() ->end() ->end() @@ -232,8 +245,14 @@ private function addFormSection(ArrayNodeDefinition $rootNode, callable $enableI ->treatNullLike(['enabled' => true]) ->addDefaultsIfNotSet() ->children() - ->booleanNode('enabled')->defaultNull()->end() // defaults to framework.csrf_protection.enabled + ->scalarNode('enabled')->defaultNull()->end() // defaults to framework.csrf_protection.enabled + ->scalarNode('token_id')->defaultNull()->end() ->scalarNode('field_name')->defaultValue('_token')->end() + ->arrayNode('field_attr') + ->performNoDeepMerging() + ->scalarPrototype()->end() + ->defaultValue(['data-controller' => 'csrf-protection']) + ->end() ->end() ->end() ->end() diff --git a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php index 826e8fb0f31f2..1393797711883 100644 --- a/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php +++ b/src/Symfony/Bundle/FrameworkBundle/DependencyInjection/FrameworkExtension.php @@ -464,7 +464,7 @@ public function load(array $configs, ContainerBuilder $container): void // csrf depends on session being registered if (null === $config['csrf_protection']['enabled']) { - $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']); + $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']); } $this->registerSecurityCsrfConfiguration($config['csrf_protection'], $container, $loader); @@ -765,6 +765,10 @@ private function registerFormConfiguration(array $config, ContainerBuilder $cont $container->setParameter('form.type_extension.csrf.enabled', true); $container->setParameter('form.type_extension.csrf.field_name', $config['form']['csrf_protection']['field_name']); + $container->setParameter('form.type_extension.csrf.field_attr', $config['form']['csrf_protection']['field_attr']); + + $container->getDefinition('form.type_extension.csrf') + ->replaceArgument(7, $config['form']['csrf_protection']['token_id']); } else { $container->setParameter('form.type_extension.csrf.enabled', false); } @@ -1815,8 +1819,7 @@ private function registerSecurityCsrfConfiguration(array $config, ContainerBuild if (!class_exists(\Symfony\Component\Security\Csrf\CsrfToken::class)) { throw new LogicException('CSRF support cannot be enabled as the Security CSRF component is not installed. Try running "composer require symfony/security-csrf".'); } - - if (!$this->isInitializedConfigEnabled('session')) { + if (!$config['stateless_token_ids'] && !$this->isInitializedConfigEnabled('session')) { throw new \LogicException('CSRF protection needs sessions to be enabled.'); } @@ -1826,6 +1829,24 @@ private function registerSecurityCsrfConfiguration(array $config, ContainerBuild if (!class_exists(CsrfExtension::class)) { $container->removeDefinition('twig.extension.security_csrf'); } + + if (!$config['stateless_token_ids']) { + $container->removeDefinition('security.csrf.same_origin_token_manager'); + + return; + } + + $container->getDefinition('security.csrf.same_origin_token_manager') + ->replaceArgument(3, $config['stateless_token_ids']) + ->replaceArgument(4, $config['check_header']) + ->replaceArgument(5, $config['cookie_name']); + + if (!$this->isInitializedConfigEnabled('session')) { + $container->setAlias('security.csrf.token_manager', 'security.csrf.same_origin_token_manager'); + $container->getDefinition('security.csrf.same_origin_token_manager') + ->setDecoratedService(null) + ->replaceArgument(2, null); + } } private function registerSerializerConfiguration(array $config, ContainerBuilder $container, PhpFileLoader $loader): void diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php index c8e5e973e40f9..c63d087c864db 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/form_csrf.php @@ -23,6 +23,8 @@ service('translator')->nullOnInvalid(), param('validator.translation_domain'), service('form.server_params'), + param('form.type_extension.csrf.field_attr'), + abstract_arg('framework.form.csrf_protection.token_id'), ]) ->tag('form.type_extension') ; diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd index 64e9c76cbd765..ed7cc744f0464 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/schema/symfony-1.0.xsd @@ -71,12 +71,25 @@ + + + + + + + + + + + + + diff --git a/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php b/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php index bad2284bfb124..ca5d69be32837 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php +++ b/src/Symfony/Bundle/FrameworkBundle/Resources/config/security_csrf.php @@ -15,6 +15,7 @@ use Symfony\Bridge\Twig\Extension\CsrfRuntime; use Symfony\Component\Security\Csrf\CsrfTokenManager; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; +use Symfony\Component\Security\Csrf\SameOriginCsrfTokenManager; use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface; use Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator; use Symfony\Component\Security\Csrf\TokenStorage\SessionTokenStorage; @@ -46,5 +47,18 @@ ->set('twig.extension.security_csrf', CsrfExtension::class) ->tag('twig.extension') + + ->set('security.csrf.same_origin_token_manager', SameOriginCsrfTokenManager::class) + ->decorate('security.csrf.token_manager') + ->args([ + service('request_stack'), + service('logger')->nullOnInvalid(), + service('.inner'), + abstract_arg('framework.csrf_protection.stateless_token_ids'), + abstract_arg('framework.csrf_protection.check_header'), + abstract_arg('framework.csrf_protection.cookie_name'), + ]) + ->tag('monolog.logger', ['channel' => 'request']) + ->tag('kernel.event_listener', ['event' => 'kernel.response', 'method' => 'onKernelResponse']) ; }; diff --git a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php index c569a852d93b3..53706d2e05e32 100644 --- a/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php +++ b/src/Symfony/Bundle/FrameworkBundle/Tests/DependencyInjection/ConfigurationTest.php @@ -715,13 +715,18 @@ protected static function getBundleDefaultConfig() 'trusted_proxies' => ['%env(default::SYMFONY_TRUSTED_PROXIES)%'], 'trusted_headers' => ['%env(default::SYMFONY_TRUSTED_HEADERS)%'], 'csrf_protection' => [ - 'enabled' => false, + 'enabled' => null, + 'cookie_name' => 'csrf-token', + 'check_header' => false, + 'stateless_token_ids' => [], ], 'form' => [ 'enabled' => !class_exists(FullStack::class), 'csrf_protection' => [ 'enabled' => null, // defaults to csrf_protection.enabled 'field_name' => '_token', + 'field_attr' => ['data-controller' => 'csrf-protection'], + 'token_id' => null, ], ], 'esi' => ['enabled' => false], diff --git a/src/Symfony/Bundle/FrameworkBundle/composer.json b/src/Symfony/Bundle/FrameworkBundle/composer.json index 3613ba8ebfbcc..af83a9a13f403 100644 --- a/src/Symfony/Bundle/FrameworkBundle/composer.json +++ b/src/Symfony/Bundle/FrameworkBundle/composer.json @@ -96,7 +96,7 @@ "symfony/runtime": "<6.4.13|>=7.0,<7.1.6", "symfony/scheduler": "<6.4.4|>=7.0.0,<7.0.4", "symfony/serializer": "<6.4", - "symfony/security-csrf": "<6.4", + "symfony/security-csrf": "<7.2", "symfony/security-core": "<6.4", "symfony/stopwatch": "<6.4", "symfony/translation": "<6.4", diff --git a/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php b/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php index 0ad4daeb3c108..10367ae5ffe65 100644 --- a/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php +++ b/src/Symfony/Component/Form/Extension/Csrf/Type/FormTypeCsrfExtension.php @@ -19,6 +19,7 @@ use Symfony\Component\Form\FormInterface; use Symfony\Component\Form\FormView; use Symfony\Component\Form\Util\ServerParams; +use Symfony\Component\OptionsResolver\Options; use Symfony\Component\OptionsResolver\OptionsResolver; use Symfony\Component\Security\Csrf\CsrfTokenManagerInterface; use Symfony\Contracts\Translation\TranslatorInterface; @@ -35,6 +36,8 @@ public function __construct( private ?TranslatorInterface $translator = null, private ?string $translationDomain = null, private ?ServerParams $serverParams = null, + private array $fieldAttr = [], + private ?string $defaultTokenId = null, ) { } @@ -73,6 +76,7 @@ public function finishView(FormView $view, FormInterface $form, array $options): $csrfForm = $factory->createNamed($options['csrf_field_name'], HiddenType::class, $data, [ 'block_prefix' => 'csrf_token', 'mapped' => false, + 'attr' => $this->fieldAttr + ['autocomplete' => 'off'], ]); $view->children[$options['csrf_field_name']] = $csrfForm->createView($view); @@ -81,13 +85,24 @@ public function finishView(FormView $view, FormInterface $form, array $options): public function configureOptions(OptionsResolver $resolver): void { + if ($defaultTokenId = $this->defaultTokenId) { + $defaultTokenManager = $this->defaultTokenManager; + $defaultTokenId = static fn (Options $options) => $options['csrf_token_manager'] === $defaultTokenManager ? $defaultTokenId : null; + } + $resolver->setDefaults([ 'csrf_protection' => $this->defaultEnabled, 'csrf_field_name' => $this->defaultFieldName, 'csrf_message' => 'The CSRF token is invalid. Please try to resubmit the form.', 'csrf_token_manager' => $this->defaultTokenManager, - 'csrf_token_id' => null, + 'csrf_token_id' => $defaultTokenId, ]); + + $resolver->setAllowedTypes('csrf_protection', 'bool'); + $resolver->setAllowedTypes('csrf_field_name', 'string'); + $resolver->setAllowedTypes('csrf_message', 'string'); + $resolver->setAllowedTypes('csrf_token_manager', CsrfTokenManagerInterface::class); + $resolver->setAllowedTypes('csrf_token_id', ['null', 'string']); } public static function getExtendedTypes(): iterable diff --git a/src/Symfony/Component/Security/Csrf/CHANGELOG.md b/src/Symfony/Component/Security/Csrf/CHANGELOG.md index 1476c99b76499..a347990667941 100644 --- a/src/Symfony/Component/Security/Csrf/CHANGELOG.md +++ b/src/Symfony/Component/Security/Csrf/CHANGELOG.md @@ -1,6 +1,11 @@ CHANGELOG ========= +7.2 +--- + + * Add `SameOriginCsrfTokenManager` + 6.0 --- diff --git a/src/Symfony/Component/Security/Csrf/SameOriginCsrfTokenManager.php b/src/Symfony/Component/Security/Csrf/SameOriginCsrfTokenManager.php new file mode 100644 index 0000000000000..9ef61964bfe1e --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/SameOriginCsrfTokenManager.php @@ -0,0 +1,268 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf; + +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpKernel\Event\ResponseEvent; + +/** + * 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 + * managed by this manager. All other tokens will be delegated to the fallback manager. + * + * @author Nicolas Grekas + */ +final class SameOriginCsrfTokenManager implements CsrfTokenManagerInterface +{ + public const TOKEN_MIN_LENGTH = 24; + + public const CHECK_NO_HEADER = 0; + public const CHECK_HEADER = 1; + public const CHECK_ONLY_HEADER = 2; + + /** + * @param self::CHECK_* $checkHeader + * @param string[] $tokenIds + */ + public function __construct( + private RequestStack $requestStack, + private ?LoggerInterface $logger = null, + private ?CsrfTokenManagerInterface $fallbackCsrfTokenManager = null, + private array $tokenIds = [], + private int $checkHeader = self::CHECK_NO_HEADER, + private string $cookieName = 'csrf-token', + ) { + if (!$cookieName) { + throw new \InvalidArgumentException('The cookie name cannot be empty.'); + } + + if (!preg_match('/^[-a-zA-Z0-9_]+$/D', $cookieName)) { + throw new \InvalidArgumentException('The cookie name contains invalid characters.'); + } + + $this->tokenIds = array_flip($tokenIds); + } + + public function getToken(string $tokenId): CsrfToken + { + if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) { + return $this->fallbackCsrfTokenManager->getToken($tokenId); + } + + return new CsrfToken($tokenId, $this->cookieName); + } + + public function refreshToken(string $tokenId): CsrfToken + { + if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) { + return $this->fallbackCsrfTokenManager->refreshToken($tokenId); + } + + return new CsrfToken($tokenId, $this->cookieName); + } + + public function removeToken(string $tokenId): ?string + { + if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) { + return $this->fallbackCsrfTokenManager->removeToken($tokenId); + } + + return null; + } + + public function isTokenValid(CsrfToken $token): bool + { + if (!isset($this->tokenIds[$token->getId()]) && $this->fallbackCsrfTokenManager) { + return $this->fallbackCsrfTokenManager->isTokenValid($token); + } + + if (!$request = $this->requestStack->getCurrentRequest()) { + $this->logger?->error('CSRF validation failed: No request found.'); + + return false; + } + + if (\strlen($token->getValue()) < self::TOKEN_MIN_LENGTH && $token->getValue() !== $this->cookieName) { + $this->logger?->warning('Invalid double-submit CSRF token.'); + + return false; + } + + if (false === $isValidOrigin = $this->isValidOrigin($request)) { + $this->logger?->warning('CSRF validation failed: origin info doesn\'t match.'); + + return false; + } + + if (false === $isValidDoubleSubmit = $this->isValidDoubleSubmit($request, $token->getValue())) { + return false; + } + + if (null === $isValidOrigin && null === $isValidDoubleSubmit) { + $this->logger?->warning('CSRF validation failed: double-submit and origin info not found.'); + + return false; + } + + // Opportunistically lookup at the session for a previous CSRF validation strategy + $session = $request->hasPreviousSession() ? $request->getSession() : null; + $usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0; + $usageIndexReference = \PHP_INT_MIN; + $previousCsrfProtection = (int) $session?->get($this->cookieName); + $usageIndexReference = $usageIndexValue; + $shift = $request->isMethodSafe() ? 8 : 0; + + if ($previousCsrfProtection) { + if (!$isValidOrigin && (1 & ($previousCsrfProtection >> $shift))) { + $this->logger?->warning('CSRF validation failed: origin info was used in a previous request but is now missing.'); + + return false; + } + + if (!$isValidDoubleSubmit && (2 & ($previousCsrfProtection >> $shift))) { + $this->logger?->warning('CSRF validation failed: double-submit info was used in a previous request but is now missing.'); + + return false; + } + } + + if ($isValidOrigin && $isValidDoubleSubmit) { + $csrfProtection = 3; + $this->logger?->debug('CSRF validation accepted using both origin and double-submit info.'); + } elseif ($isValidOrigin) { + $csrfProtection = 1; + $this->logger?->debug('CSRF validation accepted using origin info.'); + } else { + $csrfProtection = 2; + $this->logger?->debug('CSRF validation accepted using double-submit info.'); + } + + if (1 & $csrfProtection) { + // Persist valid origin for both safe and non-safe requests + $previousCsrfProtection |= 1 & (1 << 8); + } + + $request->attributes->set($this->cookieName, ($csrfProtection << $shift) | $previousCsrfProtection); + + return true; + } + + public function clearCookies(Request $request, Response $response): void + { + if (!$request->attributes->has($this->cookieName)) { + return; + } + + $cookieName = ($request->isSecure() ? '__Host-' : '').$this->cookieName; + + foreach ($request->cookies->all() as $name => $value) { + if ($this->cookieName === $value && str_starts_with($name, $cookieName.'_')) { + $response->headers->clearCookie($name, '/', null, $request->isSecure(), false, 'strict'); + } + } + } + + public function persistStrategy(Request $request): void + { + if ($request->hasSession(true) && $request->attributes->has($this->cookieName)) { + $request->getSession()->set($this->cookieName, $request->attributes->get($this->cookieName)); + } + } + + public function onKernelResponse(ResponseEvent $event): void + { + if (!$event->isMainRequest()) { + return; + } + + $this->clearCookies($event->getRequest(), $event->getResponse()); + $this->persistStrategy($event->getRequest()); + } + + /** + * @return bool|null Whether the origin is valid, null if missing + */ + private function isValidOrigin(Request $request): ?bool + { + $source = $request->headers->get('Origin') ?? $request->headers->get('Referer') ?? 'null'; + + return 'null' === $source ? null : str_starts_with($source.'/', $request->getSchemeAndHttpHost().'/'); + } + + /** + * @return bool|null Whether the double-submit is valid, null if missing + */ + private function isValidDoubleSubmit(Request $request, string $token): ?bool + { + if ($this->cookieName === $token) { + return null; + } + + if ($this->checkHeader && $request->headers->get($this->cookieName, $token) !== $token) { + $this->logger?->warning('CSRF validation failed: wrong token found in header info.'); + + return false; + } + + $cookieName = ($request->isSecure() ? '__Host-' : '').$this->cookieName; + + if (self::CHECK_ONLY_HEADER === $this->checkHeader) { + if (!$request->headers->has($this->cookieName)) { + return null; + } + + $request->cookies->set($cookieName.'_'.$token, $this->cookieName); // Ensure clearCookie() can remove any cookie filtered by a reverse-proxy + + return true; + } + + if (($request->cookies->all()[$cookieName.'_'.$token] ?? null) !== $this->cookieName && !($this->checkHeader && $request->headers->has($this->cookieName))) { + return null; + } + + return true; + } +} diff --git a/src/Symfony/Component/Security/Csrf/Tests/SameOriginCsrfTokenManagerTest.php b/src/Symfony/Component/Security/Csrf/Tests/SameOriginCsrfTokenManagerTest.php new file mode 100644 index 0000000000000..1ad17b80e0549 --- /dev/null +++ b/src/Symfony/Component/Security/Csrf/Tests/SameOriginCsrfTokenManagerTest.php @@ -0,0 +1,232 @@ + + * + * For the full copyright and license information, please view the LICENSE + * file that was distributed with this source code. + */ + +namespace Symfony\Component\Security\Csrf\Tests; + +use PHPUnit\Framework\TestCase; +use Psr\Log\LoggerInterface; +use Symfony\Component\HttpFoundation\Request; +use Symfony\Component\HttpFoundation\RequestStack; +use Symfony\Component\HttpFoundation\Response; +use Symfony\Component\HttpFoundation\Session\Session; +use Symfony\Component\HttpKernel\Event\ResponseEvent; +use Symfony\Component\HttpKernel\HttpKernelInterface; +use Symfony\Component\Security\Csrf\CsrfToken; +use Symfony\Component\Security\Csrf\SameOriginCsrfTokenManager; + +class SameOriginCsrfTokenManagerTest extends TestCase +{ + private $requestStack; + private $logger; + private $csrfTokenManager; + + protected function setUp(): void + { + $this->requestStack = new RequestStack(); + $this->logger = $this->createMock(LoggerInterface::class); + $this->csrfTokenManager = new SameOriginCsrfTokenManager($this->requestStack, $this->logger); + } + + public function testInvalidCookieName() + { + $this->expectException(\InvalidArgumentException::class); + new SameOriginCsrfTokenManager($this->requestStack, $this->logger, null, [], SameOriginCsrfTokenManager::CHECK_NO_HEADER, ''); + } + + public function testInvalidCookieNameCharacters() + { + $this->expectException(\InvalidArgumentException::class); + new SameOriginCsrfTokenManager($this->requestStack, $this->logger, null, [], SameOriginCsrfTokenManager::CHECK_NO_HEADER, 'invalid name!'); + } + + public function testGetToken() + { + $tokenId = 'test_token'; + $token = $this->csrfTokenManager->getToken($tokenId); + + $this->assertInstanceOf(CsrfToken::class, $token); + $this->assertSame($tokenId, $token->getId()); + } + + public function testNoRequest() + { + $token = new CsrfToken('test_token', 'test_value'); + + $this->logger->expects($this->once())->method('error')->with('CSRF validation failed: No request found.'); + $this->assertFalse($this->csrfTokenManager->isTokenValid($token)); + } + + public function testInvalidTokenLength() + { + $request = new Request(); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', ''); + + $this->logger->expects($this->once())->method('warning')->with('Invalid double-submit CSRF token.'); + $this->assertFalse($this->csrfTokenManager->isTokenValid($token)); + } + + public function testInvalidOrigin() + { + $request = new Request(); + $request->headers->set('Origin', 'http://malicious.com'); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', str_repeat('a', 24)); + + $this->logger->expects($this->once())->method('warning')->with('CSRF validation failed: origin info doesn\'t match.'); + $this->assertFalse($this->csrfTokenManager->isTokenValid($token)); + } + + public function testValidOrigin() + { + $request = new Request(); + $request->headers->set('Origin', $request->getSchemeAndHttpHost()); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', str_repeat('a', 24)); + + $this->logger->expects($this->once())->method('debug')->with('CSRF validation accepted using origin info.'); + $this->assertTrue($this->csrfTokenManager->isTokenValid($token)); + $this->assertSame(1 << 8, $request->attributes->get('csrf-token')); + } + + public function testValidOriginAfterDoubleSubmit() + { + $session = $this->createMock(Session::class); + $request = new Request(); + $request->setSession($session); + $request->headers->set('Origin', $request->getSchemeAndHttpHost()); + $request->cookies->set('sess', 'id'); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', str_repeat('a', 24)); + + $session->expects($this->once())->method('getName')->willReturn('sess'); + $session->expects($this->once())->method('get')->with('csrf-token')->willReturn(2 << 8); + $this->logger->expects($this->once())->method('warning')->with('CSRF validation failed: double-submit info was used in a previous request but is now missing.'); + $this->assertFalse($this->csrfTokenManager->isTokenValid($token)); + } + + public function testMissingPreviousOrigin() + { + $session = $this->createMock(Session::class); + $request = new Request(); + $request->cookies->set('csrf-token_'.str_repeat('a', 24), 'csrf-token'); + $request->setSession($session); + $request->cookies->set('sess', 'id'); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', str_repeat('a', 24)); + + $session->expects($this->once())->method('getName')->willReturn('sess'); + $session->expects($this->once())->method('get')->with('csrf-token')->willReturn(1 << 8); + $this->logger->expects($this->once())->method('warning')->with('CSRF validation failed: origin info was used in a previous request but is now missing.'); + $this->assertFalse($this->csrfTokenManager->isTokenValid($token)); + } + + public function testValidDoubleSubmit() + { + $request = new Request(); + $request->cookies->set('csrf-token_'.str_repeat('a', 24), 'csrf-token'); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', str_repeat('a', 24)); + + $this->logger->expects($this->once())->method('debug')->with('CSRF validation accepted using double-submit info.'); + $this->assertTrue($this->csrfTokenManager->isTokenValid($token)); + $this->assertSame(2 << 8, $request->attributes->get('csrf-token')); + } + + public function testCheckOnlyHeader() + { + $csrfTokenManager = new SameOriginCsrfTokenManager($this->requestStack, $this->logger, null, [], SameOriginCsrfTokenManager::CHECK_ONLY_HEADER); + + $request = new Request(); + $tokenValue = str_repeat('a', 24); + $request->headers->set('csrf-token', $tokenValue); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', $tokenValue); + + $this->logger->expects($this->once())->method('debug')->with('CSRF validation accepted using double-submit info.'); + $this->assertTrue($csrfTokenManager->isTokenValid($token)); + $this->assertSame('csrf-token', $request->cookies->get('csrf-token_'.$tokenValue)); + + $this->logger->expects($this->once())->method('warning')->with('CSRF validation failed: wrong token found in header info.'); + $this->assertFalse($csrfTokenManager->isTokenValid(new CsrfToken('test_token', str_repeat('b', 24)))); + } + + /** + * @testWith [0] + * [1] + * [2] + */ + public function testValidOriginMissingDoubleSubmit(int $checkHeader) + { + $csrfTokenManager = new SameOriginCsrfTokenManager($this->requestStack, $this->logger, null, [], $checkHeader); + + $request = new Request(); + $tokenValue = str_repeat('a', 24); + $request->headers->set('Origin', $request->getSchemeAndHttpHost()); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', $tokenValue); + + $this->logger->expects($this->once())->method('debug')->with('CSRF validation accepted using origin info.'); + $this->assertTrue($csrfTokenManager->isTokenValid($token)); + } + + public function testMissingEverything() + { + $request = new Request(); + $this->requestStack->push($request); + + $token = new CsrfToken('test_token', str_repeat('a', 24)); + + $this->logger->expects($this->once())->method('warning')->with('CSRF validation failed: double-submit and origin info not found.'); + $this->assertFalse($this->csrfTokenManager->isTokenValid($token)); + } + + public function testClearCookies() + { + $request = new Request([], [], ['csrf-token' => 2], ['csrf-token_test' => 'csrf-token']); + $response = new Response(); + + $this->csrfTokenManager->clearCookies($request, $response); + + $this->assertTrue($response->headers->has('Set-Cookie')); + } + + public function testPersistStrategyWithSession() + { + $session = $this->createMock(Session::class); + $request = new Request(); + $request->setSession($session); + $request->attributes->set('csrf-token', 2 << 8); + + $session->expects($this->once())->method('set')->with('csrf-token', 2 << 8); + + $this->csrfTokenManager->persistStrategy($request); + } + + public function testOnKernelResponse() + { + $request = new Request([], [], ['csrf-token' => 2], ['csrf-token_test' => 'csrf-token']); + $response = new Response(); + $event = new ResponseEvent($this->createMock(HttpKernelInterface::class), $request, HttpKernelInterface::MAIN_REQUEST, $response); + + $this->csrfTokenManager->onKernelResponse($event); + + $this->assertTrue($response->headers->has('Set-Cookie')); + } +} diff --git a/src/Symfony/Component/Security/Csrf/composer.json b/src/Symfony/Component/Security/Csrf/composer.json index e93fc478802a4..c2bfed1de3d7e 100644 --- a/src/Symfony/Component/Security/Csrf/composer.json +++ b/src/Symfony/Component/Security/Csrf/composer.json @@ -20,7 +20,9 @@ "symfony/security-core": "^6.4|^7.0" }, "require-dev": { - "symfony/http-foundation": "^6.4|^7.0" + "psr/log": "^1|^2|^3", + "symfony/http-foundation": "^6.4|^7.0", + "symfony/http-kernel": "^6.4|^7.0" }, "conflict": { "symfony/http-foundation": "<6.4"