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" 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