├── CHANGELOG.md ├── Exception └── TokenNotFoundException.php ├── TokenGenerator ├── TokenGeneratorInterface.php └── UriSafeTokenGenerator.php ├── TokenStorage ├── ClearableTokenStorageInterface.php ├── TokenStorageInterface.php ├── NativeSessionTokenStorage.php └── SessionTokenStorage.php ├── composer.json ├── README.md ├── LICENSE ├── CsrfToken.php ├── CsrfTokenManagerInterface.php ├── CsrfTokenManager.php └── SameOriginCsrfTokenManager.php /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | CHANGELOG 2 | ========= 3 | 4 | 7.2 5 | --- 6 | 7 | * Add `SameOriginCsrfTokenManager` 8 | 9 | 6.0 10 | --- 11 | 12 | * Remove the `SessionInterface $session` constructor argument of `SessionTokenStorage`, inject a `\Symfony\Component\HttpFoundation\RequestStack $requestStack` instead 13 | * Using `SessionTokenStorage` outside a request context throws a `SessionNotFoundException` 14 | 15 | 5.3 16 | --- 17 | 18 | The CHANGELOG for version 5.3 and earlier can be found at https://github.com/symfony/symfony/blob/5.3/src/Symfony/Component/Security/CHANGELOG.md 19 | -------------------------------------------------------------------------------- /Exception/TokenNotFoundException.php: -------------------------------------------------------------------------------- 1 | 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\Exception; 13 | 14 | use Symfony\Component\Security\Core\Exception\RuntimeException; 15 | 16 | /** 17 | * @author Bernhard Schussek 18 | */ 19 | class TokenNotFoundException extends RuntimeException 20 | { 21 | } 22 | -------------------------------------------------------------------------------- /TokenGenerator/TokenGeneratorInterface.php: -------------------------------------------------------------------------------- 1 | 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\TokenGenerator; 13 | 14 | /** 15 | * Generates CSRF tokens. 16 | * 17 | * @author Bernhard Schussek 18 | */ 19 | interface TokenGeneratorInterface 20 | { 21 | /** 22 | * Generates a CSRF token. 23 | */ 24 | public function generateToken(): string; 25 | } 26 | -------------------------------------------------------------------------------- /TokenStorage/ClearableTokenStorageInterface.php: -------------------------------------------------------------------------------- 1 | 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\TokenStorage; 13 | 14 | /** 15 | * @author Christian Flothmann 16 | */ 17 | interface ClearableTokenStorageInterface extends TokenStorageInterface 18 | { 19 | /** 20 | * Removes all CSRF tokens. 21 | */ 22 | public function clear(): void; 23 | } 24 | -------------------------------------------------------------------------------- /composer.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "symfony/security-csrf", 3 | "type": "library", 4 | "description": "Symfony Security Component - CSRF Library", 5 | "keywords": [], 6 | "homepage": "https://symfony.com", 7 | "license": "MIT", 8 | "authors": [ 9 | { 10 | "name": "Fabien Potencier", 11 | "email": "fabien@symfony.com" 12 | }, 13 | { 14 | "name": "Symfony Community", 15 | "homepage": "https://symfony.com/contributors" 16 | } 17 | ], 18 | "require": { 19 | "php": ">=8.2", 20 | "symfony/security-core": "^6.4|^7.0" 21 | }, 22 | "require-dev": { 23 | "psr/log": "^1|^2|^3", 24 | "symfony/http-foundation": "^6.4|^7.0", 25 | "symfony/http-kernel": "^6.4|^7.0" 26 | }, 27 | "conflict": { 28 | "symfony/http-foundation": "<6.4" 29 | }, 30 | "autoload": { 31 | "psr-4": { "Symfony\\Component\\Security\\Csrf\\": "" }, 32 | "exclude-from-classmap": [ 33 | "/Tests/" 34 | ] 35 | }, 36 | "minimum-stability": "dev" 37 | } 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | Security Component - CSRF 2 | ========================= 3 | 4 | The Security CSRF (cross-site request forgery) component provides a class 5 | `CsrfTokenManager` for generating and validating CSRF tokens. 6 | 7 | Sponsor 8 | ------- 9 | 10 | The Security component for Symfony 7.1 is [backed][1] by [SymfonyCasts][2]. 11 | 12 | Learn Symfony faster by watching real projects being built and actively coding 13 | along with them. SymfonyCasts bridges that learning gap, bringing you video 14 | tutorials and coding challenges. Code on! 15 | 16 | Help Symfony by [sponsoring][3] its development! 17 | 18 | Resources 19 | --------- 20 | 21 | * [Documentation](https://symfony.com/doc/current/components/security.html) 22 | * [Contributing](https://symfony.com/doc/current/contributing/index.html) 23 | * [Report issues](https://github.com/symfony/symfony/issues) and 24 | [send Pull Requests](https://github.com/symfony/symfony/pulls) 25 | in the [main Symfony repository](https://github.com/symfony/symfony) 26 | 27 | [1]: https://symfony.com/backers 28 | [2]: https://symfonycasts.com 29 | [3]: https://symfony.com/sponsor 30 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Copyright (c) 2004-present Fabien Potencier 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice shall be included in all 11 | copies or substantial portions of the Software. 12 | 13 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 14 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 15 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 16 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 17 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 18 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 19 | THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /CsrfToken.php: -------------------------------------------------------------------------------- 1 | 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 | /** 15 | * A CSRF token. 16 | * 17 | * @author Bernhard Schussek 18 | */ 19 | class CsrfToken 20 | { 21 | private string $value; 22 | 23 | public function __construct( 24 | private string $id, 25 | #[\SensitiveParameter] ?string $value, 26 | ) { 27 | $this->value = $value ?? ''; 28 | } 29 | 30 | /** 31 | * Returns the ID of the CSRF token. 32 | */ 33 | public function getId(): string 34 | { 35 | return $this->id; 36 | } 37 | 38 | /** 39 | * Returns the value of the CSRF token. 40 | */ 41 | public function getValue(): string 42 | { 43 | return $this->value; 44 | } 45 | 46 | /** 47 | * Returns the value of the CSRF token. 48 | */ 49 | public function __toString(): string 50 | { 51 | return $this->value; 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /TokenStorage/TokenStorageInterface.php: -------------------------------------------------------------------------------- 1 | 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\TokenStorage; 13 | 14 | /** 15 | * Stores CSRF tokens. 16 | * 17 | * @author Bernhard Schussek 18 | */ 19 | interface TokenStorageInterface 20 | { 21 | /** 22 | * Reads a stored CSRF token. 23 | * 24 | * @throws \Symfony\Component\Security\Csrf\Exception\TokenNotFoundException If the token ID does not exist 25 | */ 26 | public function getToken(string $tokenId): string; 27 | 28 | /** 29 | * Stores a CSRF token. 30 | */ 31 | public function setToken(string $tokenId, #[\SensitiveParameter] string $token): void; 32 | 33 | /** 34 | * Removes a CSRF token. 35 | * 36 | * @return string|null Returns the removed token if one existed, NULL 37 | * otherwise 38 | */ 39 | public function removeToken(string $tokenId): ?string; 40 | 41 | /** 42 | * Checks whether a token with the given token ID exists. 43 | */ 44 | public function hasToken(string $tokenId): bool; 45 | } 46 | -------------------------------------------------------------------------------- /TokenGenerator/UriSafeTokenGenerator.php: -------------------------------------------------------------------------------- 1 | 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\TokenGenerator; 13 | 14 | /** 15 | * Generates CSRF tokens. 16 | * 17 | * @author Bernhard Schussek 18 | */ 19 | class UriSafeTokenGenerator implements TokenGeneratorInterface 20 | { 21 | /** 22 | * Generates URI-safe CSRF tokens. 23 | * 24 | * @param int $entropy The amount of entropy collected for each token (in bits) 25 | */ 26 | public function __construct( 27 | private int $entropy = 256, 28 | ) { 29 | if ($entropy <= 7) { 30 | throw new \InvalidArgumentException('Entropy should be greater than 7.'); 31 | } 32 | } 33 | 34 | public function generateToken(): string 35 | { 36 | // Generate an URI safe base64 encoded string that does not contain "+", 37 | // "/" or "=" which need to be URL encoded and make URLs unnecessarily 38 | // longer. 39 | $bytes = random_bytes(intdiv($this->entropy, 8)); 40 | 41 | return rtrim(strtr(base64_encode($bytes), '+/', '-_'), '='); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /CsrfTokenManagerInterface.php: -------------------------------------------------------------------------------- 1 | 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 | /** 15 | * Manages CSRF tokens. 16 | * 17 | * @author Bernhard Schussek 18 | */ 19 | interface CsrfTokenManagerInterface 20 | { 21 | /** 22 | * Returns a CSRF token for the given ID. 23 | * 24 | * If previously no token existed for the given ID, a new token is 25 | * generated. Otherwise the existing token is returned (with the same value, 26 | * not the same instance). 27 | * 28 | * @param string $tokenId The token ID. You may choose an arbitrary value 29 | * for the ID 30 | */ 31 | public function getToken(string $tokenId): CsrfToken; 32 | 33 | /** 34 | * Generates a new token value for the given ID. 35 | * 36 | * This method will generate a new token for the given token ID, independent 37 | * of whether a token value previously existed or not. It can be used to 38 | * enforce once-only tokens in environments with high security needs. 39 | * 40 | * @param string $tokenId The token ID. You may choose an arbitrary value 41 | * for the ID 42 | */ 43 | public function refreshToken(string $tokenId): CsrfToken; 44 | 45 | /** 46 | * Invalidates the CSRF token with the given ID, if one exists. 47 | * 48 | * @return string|null Returns the removed token value if one existed, NULL 49 | * otherwise 50 | */ 51 | public function removeToken(string $tokenId): ?string; 52 | 53 | /** 54 | * Returns whether the given CSRF token is valid. 55 | */ 56 | public function isTokenValid(CsrfToken $token): bool; 57 | } 58 | -------------------------------------------------------------------------------- /TokenStorage/NativeSessionTokenStorage.php: -------------------------------------------------------------------------------- 1 | 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\TokenStorage; 13 | 14 | use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException; 15 | 16 | /** 17 | * Token storage that uses PHP's native session handling. 18 | * 19 | * @author Bernhard Schussek 20 | */ 21 | class NativeSessionTokenStorage implements ClearableTokenStorageInterface 22 | { 23 | /** 24 | * The namespace used to store values in the session. 25 | */ 26 | public const SESSION_NAMESPACE = '_csrf'; 27 | 28 | private bool $sessionStarted = false; 29 | 30 | /** 31 | * Initializes the storage with a session namespace. 32 | * 33 | * @param string $namespace The namespace under which the token is stored in the session 34 | */ 35 | public function __construct( 36 | private string $namespace = self::SESSION_NAMESPACE, 37 | ) { 38 | } 39 | 40 | public function getToken(string $tokenId): string 41 | { 42 | if (!$this->sessionStarted) { 43 | $this->startSession(); 44 | } 45 | 46 | if (!isset($_SESSION[$this->namespace][$tokenId])) { 47 | throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' does not exist.'); 48 | } 49 | 50 | return (string) $_SESSION[$this->namespace][$tokenId]; 51 | } 52 | 53 | public function setToken(string $tokenId, #[\SensitiveParameter] string $token): void 54 | { 55 | if (!$this->sessionStarted) { 56 | $this->startSession(); 57 | } 58 | 59 | $_SESSION[$this->namespace][$tokenId] = $token; 60 | } 61 | 62 | public function hasToken(string $tokenId): bool 63 | { 64 | if (!$this->sessionStarted) { 65 | $this->startSession(); 66 | } 67 | 68 | return isset($_SESSION[$this->namespace][$tokenId]); 69 | } 70 | 71 | public function removeToken(string $tokenId): ?string 72 | { 73 | if (!$this->sessionStarted) { 74 | $this->startSession(); 75 | } 76 | 77 | if (!isset($_SESSION[$this->namespace][$tokenId])) { 78 | return null; 79 | } 80 | 81 | $token = (string) $_SESSION[$this->namespace][$tokenId]; 82 | 83 | unset($_SESSION[$this->namespace][$tokenId]); 84 | 85 | if (!$_SESSION[$this->namespace]) { 86 | unset($_SESSION[$this->namespace]); 87 | } 88 | 89 | return $token; 90 | } 91 | 92 | public function clear(): void 93 | { 94 | unset($_SESSION[$this->namespace]); 95 | } 96 | 97 | private function startSession(): void 98 | { 99 | if (\PHP_SESSION_NONE === session_status()) { 100 | session_start(); 101 | } 102 | 103 | $this->sessionStarted = true; 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /TokenStorage/SessionTokenStorage.php: -------------------------------------------------------------------------------- 1 | 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\TokenStorage; 13 | 14 | use Symfony\Component\HttpFoundation\Exception\SessionNotFoundException; 15 | use Symfony\Component\HttpFoundation\RequestStack; 16 | use Symfony\Component\HttpFoundation\Session\SessionInterface; 17 | use Symfony\Component\Security\Csrf\Exception\TokenNotFoundException; 18 | 19 | /** 20 | * Token storage that uses a Symfony Session object. 21 | * 22 | * @author Bernhard Schussek 23 | */ 24 | class SessionTokenStorage implements ClearableTokenStorageInterface 25 | { 26 | /** 27 | * The namespace used to store values in the session. 28 | */ 29 | public const SESSION_NAMESPACE = '_csrf'; 30 | 31 | /** 32 | * Initializes the storage with a RequestStack object and a session namespace. 33 | * 34 | * @param string $namespace The namespace under which the token is stored in the requestStack 35 | */ 36 | public function __construct( 37 | private RequestStack $requestStack, 38 | private string $namespace = self::SESSION_NAMESPACE, 39 | ) { 40 | } 41 | 42 | public function getToken(string $tokenId): string 43 | { 44 | $session = $this->getSession(); 45 | if (!$session->isStarted()) { 46 | $session->start(); 47 | } 48 | 49 | if (!$session->has($this->namespace.'/'.$tokenId)) { 50 | throw new TokenNotFoundException('The CSRF token with ID '.$tokenId.' does not exist.'); 51 | } 52 | 53 | return (string) $session->get($this->namespace.'/'.$tokenId); 54 | } 55 | 56 | public function setToken(string $tokenId, #[\SensitiveParameter] string $token): void 57 | { 58 | $session = $this->getSession(); 59 | if (!$session->isStarted()) { 60 | $session->start(); 61 | } 62 | 63 | $session->set($this->namespace.'/'.$tokenId, $token); 64 | } 65 | 66 | public function hasToken(string $tokenId): bool 67 | { 68 | $session = $this->getSession(); 69 | if (!$session->isStarted()) { 70 | $session->start(); 71 | } 72 | 73 | return $session->has($this->namespace.'/'.$tokenId); 74 | } 75 | 76 | public function removeToken(string $tokenId): ?string 77 | { 78 | $session = $this->getSession(); 79 | if (!$session->isStarted()) { 80 | $session->start(); 81 | } 82 | 83 | return $session->remove($this->namespace.'/'.$tokenId); 84 | } 85 | 86 | public function clear(): void 87 | { 88 | $session = $this->getSession(); 89 | foreach (array_keys($session->all()) as $key) { 90 | if (str_starts_with($key, $this->namespace.'/')) { 91 | $session->remove($key); 92 | } 93 | } 94 | } 95 | 96 | /** 97 | * @throws SessionNotFoundException 98 | */ 99 | private function getSession(): SessionInterface 100 | { 101 | return $this->requestStack->getSession(); 102 | } 103 | } 104 | -------------------------------------------------------------------------------- /CsrfTokenManager.php: -------------------------------------------------------------------------------- 1 | 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 Symfony\Component\HttpFoundation\RequestStack; 15 | use Symfony\Component\Security\Core\Exception\InvalidArgumentException; 16 | use Symfony\Component\Security\Csrf\TokenGenerator\TokenGeneratorInterface; 17 | use Symfony\Component\Security\Csrf\TokenGenerator\UriSafeTokenGenerator; 18 | use Symfony\Component\Security\Csrf\TokenStorage\NativeSessionTokenStorage; 19 | use Symfony\Component\Security\Csrf\TokenStorage\TokenStorageInterface; 20 | 21 | /** 22 | * Default implementation of {@link CsrfTokenManagerInterface}. 23 | * 24 | * @author Bernhard Schussek 25 | * @author Kévin Dunglas 26 | */ 27 | class CsrfTokenManager implements CsrfTokenManagerInterface 28 | { 29 | private TokenGeneratorInterface $generator; 30 | private TokenStorageInterface $storage; 31 | private \Closure|string $namespace; 32 | 33 | /** 34 | * @param $namespace 35 | * * null: generates a namespace using $_SERVER['HTTPS'] 36 | * * string: uses the given string 37 | * * RequestStack: generates a namespace using the current main request 38 | * * callable: uses the result of this callable (must return a string) 39 | */ 40 | public function __construct(?TokenGeneratorInterface $generator = null, ?TokenStorageInterface $storage = null, string|RequestStack|callable|null $namespace = null) 41 | { 42 | $this->generator = $generator ?? new UriSafeTokenGenerator(); 43 | $this->storage = $storage ?? new NativeSessionTokenStorage(); 44 | 45 | $superGlobalNamespaceGenerator = fn () => !empty($_SERVER['HTTPS']) && 'off' !== strtolower($_SERVER['HTTPS']) ? 'https-' : ''; 46 | 47 | if (null === $namespace) { 48 | $this->namespace = $superGlobalNamespaceGenerator; 49 | } elseif ($namespace instanceof RequestStack) { 50 | $this->namespace = static function () use ($namespace, $superGlobalNamespaceGenerator) { 51 | if ($request = $namespace->getMainRequest()) { 52 | return $request->isSecure() ? 'https-' : ''; 53 | } 54 | 55 | return $superGlobalNamespaceGenerator(); 56 | }; 57 | } elseif ($namespace instanceof \Closure || \is_string($namespace)) { 58 | $this->namespace = $namespace; 59 | } elseif (\is_callable($namespace)) { 60 | $this->namespace = $namespace(...); 61 | } else { 62 | throw new InvalidArgumentException(\sprintf('$namespace must be a string, a callable returning a string, null or an instance of "RequestStack". "%s" given.', get_debug_type($namespace))); 63 | } 64 | } 65 | 66 | public function getToken(string $tokenId): CsrfToken 67 | { 68 | $namespacedId = $this->getNamespace().$tokenId; 69 | if ($this->storage->hasToken($namespacedId)) { 70 | $value = $this->storage->getToken($namespacedId); 71 | } else { 72 | $value = $this->generator->generateToken(); 73 | 74 | $this->storage->setToken($namespacedId, $value); 75 | } 76 | 77 | return new CsrfToken($tokenId, $this->randomize($value)); 78 | } 79 | 80 | public function refreshToken(string $tokenId): CsrfToken 81 | { 82 | $namespacedId = $this->getNamespace().$tokenId; 83 | $value = $this->generator->generateToken(); 84 | 85 | $this->storage->setToken($namespacedId, $value); 86 | 87 | return new CsrfToken($tokenId, $this->randomize($value)); 88 | } 89 | 90 | public function removeToken(string $tokenId): ?string 91 | { 92 | return $this->storage->removeToken($this->getNamespace().$tokenId); 93 | } 94 | 95 | public function isTokenValid(CsrfToken $token): bool 96 | { 97 | $namespacedId = $this->getNamespace().$token->getId(); 98 | if (!$this->storage->hasToken($namespacedId)) { 99 | return false; 100 | } 101 | 102 | return hash_equals($this->storage->getToken($namespacedId), $this->derandomize($token->getValue())); 103 | } 104 | 105 | private function getNamespace(): string 106 | { 107 | return \is_callable($ns = $this->namespace) ? $ns() : $ns; 108 | } 109 | 110 | private function randomize(string $value): string 111 | { 112 | $key = random_bytes(32); 113 | $value = $this->xor($value, $key); 114 | 115 | return \sprintf('%s.%s.%s', substr(hash('xxh128', $key), 0, 1 + (\ord($key[0]) % 32)), rtrim(strtr(base64_encode($key), '+/', '-_'), '='), rtrim(strtr(base64_encode($value), '+/', '-_'), '=')); 116 | } 117 | 118 | private function derandomize(string $value): string 119 | { 120 | $parts = explode('.', $value); 121 | if (3 !== \count($parts)) { 122 | return $value; 123 | } 124 | $key = base64_decode(strtr($parts[1], '-_', '+/')); 125 | if ('' === $key || false === $key) { 126 | return $value; 127 | } 128 | $value = base64_decode(strtr($parts[2], '-_', '+/')); 129 | 130 | return $this->xor($value, $key); 131 | } 132 | 133 | private function xor(string $value, string $key): string 134 | { 135 | if (\strlen($value) > \strlen($key)) { 136 | $key = str_repeat($key, ceil(\strlen($value) / \strlen($key))); 137 | } 138 | 139 | return $value ^ $key; 140 | } 141 | } 142 | -------------------------------------------------------------------------------- /SameOriginCsrfTokenManager.php: -------------------------------------------------------------------------------- 1 | 7 | * 8 | * For the full copyright and license information, please view the LICENSE 9 | * file that was distributed with this source code. 10 | */ 11 | 12 | namespace Symfony\Component\Security\Csrf; 13 | 14 | use Psr\Log\LoggerInterface; 15 | use Symfony\Component\HttpFoundation\Request; 16 | use Symfony\Component\HttpFoundation\RequestStack; 17 | use Symfony\Component\HttpFoundation\Response; 18 | use Symfony\Component\HttpFoundation\Session\Session; 19 | use Symfony\Component\HttpKernel\Event\ResponseEvent; 20 | 21 | /** 22 | * This CSRF token manager uses a combination of cookie and headers to validate non-persistent tokens. 23 | * 24 | * This manager is designed to be stateless and compatible with HTTP-caching. 25 | * 26 | * First, we validate the source of the request using the Origin/Referer headers. This relies 27 | * on the app being able to know its own target origin. Don't miss configuring your reverse proxy to 28 | * send the X-Forwarded-* / Forwarded headers if you're behind one. 29 | * 30 | * Then, we validate the request using a cookie and a CsrfToken. If the cookie is found, it should 31 | * contain the same value as the CsrfToken. A JavaScript snippet on the client side is responsible 32 | * for performing this double-submission. The token value should be regenerated on every request 33 | * using a cryptographically secure random generator. 34 | * 35 | * If either double-submit or Origin/Referer headers are missing, it typically indicates that 36 | * JavaScript is disabled on the client side, or that the JavaScript snippet was not properly 37 | * implemented, or that the Origin/Referer headers were filtered out. 38 | * 39 | * Requests lacking both double-submit and origin information are deemed insecure. 40 | * 41 | * When a session is found, a behavioral check is added to ensure that the validation method does not 42 | * downgrade from double-submit to origin checks. This prevents attackers from exploiting potentially 43 | * less secure validation methods once a more secure method has been confirmed as functional. 44 | * 45 | * On HTTPS connections, the cookie is prefixed with "__Host-" to prevent it from being forged on an 46 | * HTTP channel. On the JS side, the cookie should be set with samesite=strict to strengthen the CSRF 47 | * protection. The cookie is always cleared on the response to prevent any further use of the token. 48 | * 49 | * The $checkHeader argument allows the token to be checked in a header instead of or in addition to a 50 | * cookie. This makes it harder for an attacker to forge a request, though it may also pose challenges 51 | * when setting the header depending on the client-side framework in use. 52 | * 53 | * When a fallback CSRF token manager is provided, only tokens listed in the $tokenIds argument will be 54 | * managed by this manager. All other tokens will be delegated to the fallback manager. 55 | * 56 | * @author Nicolas Grekas 57 | */ 58 | final class SameOriginCsrfTokenManager implements CsrfTokenManagerInterface 59 | { 60 | public const TOKEN_MIN_LENGTH = 24; 61 | 62 | public const CHECK_NO_HEADER = 0; 63 | public const CHECK_HEADER = 1; 64 | public const CHECK_ONLY_HEADER = 2; 65 | 66 | /** 67 | * @param self::CHECK_* $checkHeader 68 | * @param string[] $tokenIds 69 | */ 70 | public function __construct( 71 | private RequestStack $requestStack, 72 | private ?LoggerInterface $logger = null, 73 | private ?CsrfTokenManagerInterface $fallbackCsrfTokenManager = null, 74 | private array $tokenIds = [], 75 | private int $checkHeader = self::CHECK_NO_HEADER, 76 | private string $cookieName = 'csrf-token', 77 | ) { 78 | if (!$cookieName) { 79 | throw new \InvalidArgumentException('The cookie name cannot be empty.'); 80 | } 81 | 82 | if (!preg_match('/^[-a-zA-Z0-9_]+$/D', $cookieName)) { 83 | throw new \InvalidArgumentException('The cookie name contains invalid characters.'); 84 | } 85 | 86 | $this->tokenIds = array_flip($tokenIds); 87 | } 88 | 89 | public function getToken(string $tokenId): CsrfToken 90 | { 91 | if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) { 92 | return $this->fallbackCsrfTokenManager->getToken($tokenId); 93 | } 94 | 95 | return new CsrfToken($tokenId, $this->cookieName); 96 | } 97 | 98 | public function refreshToken(string $tokenId): CsrfToken 99 | { 100 | if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) { 101 | return $this->fallbackCsrfTokenManager->refreshToken($tokenId); 102 | } 103 | 104 | return new CsrfToken($tokenId, $this->cookieName); 105 | } 106 | 107 | public function removeToken(string $tokenId): ?string 108 | { 109 | if (!isset($this->tokenIds[$tokenId]) && $this->fallbackCsrfTokenManager) { 110 | return $this->fallbackCsrfTokenManager->removeToken($tokenId); 111 | } 112 | 113 | return null; 114 | } 115 | 116 | public function isTokenValid(CsrfToken $token): bool 117 | { 118 | if (!isset($this->tokenIds[$token->getId()]) && $this->fallbackCsrfTokenManager) { 119 | return $this->fallbackCsrfTokenManager->isTokenValid($token); 120 | } 121 | 122 | if (!$request = $this->requestStack->getCurrentRequest()) { 123 | $this->logger?->error('CSRF validation failed: No request found.'); 124 | 125 | return false; 126 | } 127 | 128 | if (\strlen($token->getValue()) < self::TOKEN_MIN_LENGTH && $token->getValue() !== $this->cookieName) { 129 | $this->logger?->warning('Invalid double-submit CSRF token.'); 130 | 131 | return false; 132 | } 133 | 134 | if (false === $isValidOrigin = $this->isValidOrigin($request)) { 135 | $this->logger?->warning('CSRF validation failed: origin info doesn\'t match.'); 136 | 137 | return false; 138 | } 139 | 140 | if (false === $isValidDoubleSubmit = $this->isValidDoubleSubmit($request, $token->getValue())) { 141 | return false; 142 | } 143 | 144 | if (null === $isValidOrigin && null === $isValidDoubleSubmit) { 145 | $this->logger?->warning('CSRF validation failed: double-submit and origin info not found.'); 146 | 147 | return false; 148 | } 149 | 150 | // Opportunistically lookup at the session for a previous CSRF validation strategy 151 | $session = $request->hasPreviousSession() ? $request->getSession() : null; 152 | $usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0; 153 | $usageIndexReference = \PHP_INT_MIN; 154 | $previousCsrfProtection = (int) $session?->get($this->cookieName); 155 | $usageIndexReference = $usageIndexValue; 156 | $shift = $request->isMethodSafe() ? 8 : 0; 157 | 158 | if ($previousCsrfProtection) { 159 | if (!$isValidOrigin && (1 & ($previousCsrfProtection >> $shift))) { 160 | $this->logger?->warning('CSRF validation failed: origin info was used in a previous request but is now missing.'); 161 | 162 | return false; 163 | } 164 | 165 | if (!$isValidDoubleSubmit && (2 & ($previousCsrfProtection >> $shift))) { 166 | $this->logger?->warning('CSRF validation failed: double-submit info was used in a previous request but is now missing.'); 167 | 168 | return false; 169 | } 170 | } 171 | 172 | if ($isValidOrigin && $isValidDoubleSubmit) { 173 | $csrfProtection = 3; 174 | $this->logger?->debug('CSRF validation accepted using both origin and double-submit info.'); 175 | } elseif ($isValidOrigin) { 176 | $csrfProtection = 1; 177 | $this->logger?->debug('CSRF validation accepted using origin info.'); 178 | } else { 179 | $csrfProtection = 2; 180 | $this->logger?->debug('CSRF validation accepted using double-submit info.'); 181 | } 182 | 183 | if (1 & $csrfProtection) { 184 | // Persist valid origin for both safe and non-safe requests 185 | $previousCsrfProtection |= 1 | (1 << 8); 186 | } 187 | 188 | $request->attributes->set($this->cookieName, ($csrfProtection << $shift) | $previousCsrfProtection); 189 | 190 | return true; 191 | } 192 | 193 | public function clearCookies(Request $request, Response $response): void 194 | { 195 | if (!$request->attributes->has($this->cookieName)) { 196 | return; 197 | } 198 | 199 | $cookieName = ($request->isSecure() ? '__Host-' : '').$this->cookieName; 200 | 201 | foreach ($request->cookies->all() as $name => $value) { 202 | if ($this->cookieName === $value && str_starts_with($name, $cookieName.'_')) { 203 | $response->headers->clearCookie($name, '/', null, $request->isSecure(), false, 'strict'); 204 | } 205 | } 206 | } 207 | 208 | public function persistStrategy(Request $request): void 209 | { 210 | if (!$request->attributes->has($this->cookieName) 211 | || !$request->hasSession(true) 212 | || !($session = $request->getSession())->isStarted() 213 | ) { 214 | return; 215 | } 216 | 217 | $usageIndexValue = $session instanceof Session ? $usageIndexReference = &$session->getUsageIndex() : 0; 218 | $usageIndexReference = \PHP_INT_MIN; 219 | $session->set($this->cookieName, $request->attributes->get($this->cookieName)); 220 | $usageIndexReference = $usageIndexValue; 221 | } 222 | 223 | public function onKernelResponse(ResponseEvent $event): void 224 | { 225 | if (!$event->isMainRequest()) { 226 | return; 227 | } 228 | 229 | $this->clearCookies($event->getRequest(), $event->getResponse()); 230 | $this->persistStrategy($event->getRequest()); 231 | } 232 | 233 | /** 234 | * @return bool|null Whether the origin is valid, null if missing 235 | */ 236 | private function isValidOrigin(Request $request): ?bool 237 | { 238 | $target = $request->getSchemeAndHttpHost().'/'; 239 | $source = 'null'; 240 | 241 | foreach (['Origin', 'Referer'] as $header) { 242 | if (!$request->headers->has($header)) { 243 | continue; 244 | } 245 | $source = $request->headers->get($header); 246 | 247 | if (str_starts_with($source.'/', $target)) { 248 | return true; 249 | } 250 | } 251 | 252 | return 'null' === $source ? null : false; 253 | } 254 | 255 | /** 256 | * @return bool|null Whether the double-submit is valid, null if missing 257 | */ 258 | private function isValidDoubleSubmit(Request $request, string $token): ?bool 259 | { 260 | if ($this->cookieName === $token) { 261 | return null; 262 | } 263 | 264 | if ($this->checkHeader && $request->headers->get($this->cookieName, $token) !== $token) { 265 | $this->logger?->warning('CSRF validation failed: wrong token found in header info.'); 266 | 267 | return false; 268 | } 269 | 270 | $cookieName = ($request->isSecure() ? '__Host-' : '').$this->cookieName; 271 | 272 | if (self::CHECK_ONLY_HEADER === $this->checkHeader) { 273 | if (!$request->headers->has($this->cookieName)) { 274 | return null; 275 | } 276 | 277 | $request->cookies->set($cookieName.'_'.$token, $this->cookieName); // Ensure clearCookie() can remove any cookie filtered by a reverse-proxy 278 | 279 | return true; 280 | } 281 | 282 | if (($request->cookies->all()[$cookieName.'_'.$token] ?? null) !== $this->cookieName && !($this->checkHeader && $request->headers->has($this->cookieName))) { 283 | return null; 284 | } 285 | 286 | return true; 287 | } 288 | } 289 | --------------------------------------------------------------------------------