+ */ +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"
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: