diff --git a/typo3/sysext/core/Classes/Http/CookieScopeTrait.php b/typo3/sysext/core/Classes/Http/CookieScopeTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..e5ddad67bc1e5b22d3db17f8a2eaea9d57bf46e8 --- /dev/null +++ b/typo3/sysext/core/Classes/Http/CookieScopeTrait.php @@ -0,0 +1,67 @@ +<?php + +declare(strict_types=1); + +/* + * This file is part of the TYPO3 CMS project. + * + * It is free software; you can redistribute it and/or modify it under + * the terms of the GNU General Public License, either version 2 + * of the License, or any later version. + * + * For the full copyright and license information, please read the + * LICENSE.txt file that was distributed with this source code. + * + * The TYPO3 project - inspiring people to share! + */ + +namespace TYPO3\CMS\Core\Http; + +trait CookieScopeTrait +{ + /** + * Returns the domain and path to be used for setting cookies. + * The information is taken from the value in $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] if set, + * otherwise the normalized request params are used. + * + * @return array{domain: string, path: string} The domain and path to be used when setting cookies + */ + private function getCookieScope(NormalizedParams $normalizedParams): array + { + $cookieDomain = $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'] ?? ''; + // If a specific cookie domain is defined for a given application type, use that domain + if (!empty($GLOBALS['TYPO3_CONF_VARS'][$this->loginType]['cookieDomain'])) { + $cookieDomain = $GLOBALS['TYPO3_CONF_VARS'][$this->loginType]['cookieDomain']; + } + if (!$cookieDomain) { + return [ + 'domain' => $normalizedParams->getRequestHostOnly(), + // If no cookie domain is set, use the base path + 'path' => $normalizedParams->getSitePath(), + ]; + } + if ($cookieDomain[0] === '/') { + $match = []; + $matchCount = @preg_match($cookieDomain, $normalizedParams->getRequestHostOnly(), $match); + if ($matchCount === false) { + $this->logger->critical( + 'The regular expression for the cookie domain ({domain}) contains errors. The session is not shared across sub-domains.', + ['domain' => $cookieDomain] + ); + } + if ($matchCount === false || $matchCount === 0) { + return [ + 'domain' => $normalizedParams->getRequestHostOnly(), + // If no cookie domain could be matched, use the base path + 'path' => $normalizedParams->getSitePath(), + ]; + } + $cookieDomain = $match[0]; + } + + return [ + 'domain' => trim($cookieDomain, '.'), + 'path' => '/', + ]; + } +} diff --git a/typo3/sysext/core/Classes/Session/UserSessionManager.php b/typo3/sysext/core/Classes/Session/UserSessionManager.php index d4a60410f400e8d931be020261233f98ea5bd17a..cb69de751cf617cc771bdb1eea8c3d3eb07ffba6 100644 --- a/typo3/sysext/core/Classes/Session/UserSessionManager.php +++ b/typo3/sysext/core/Classes/Session/UserSessionManager.php @@ -21,7 +21,10 @@ use Psr\Http\Message\ServerRequestInterface; use Psr\Log\LoggerAwareInterface; use Psr\Log\LoggerAwareTrait; use TYPO3\CMS\Core\Authentication\IpLocker; +use TYPO3\CMS\Core\Core\Environment; use TYPO3\CMS\Core\Crypto\Random; +use TYPO3\CMS\Core\Http\CookieScopeTrait; +use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException; use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; use TYPO3\CMS\Core\Utility\GeneralUtility; @@ -34,6 +37,7 @@ use TYPO3\CMS\Core\Utility\GeneralUtility; class UserSessionManager implements LoggerAwareInterface { use LoggerAwareTrait; + use CookieScopeTrait; protected const SESSION_ID_LENGTH = 32; protected const GARBAGE_COLLECTION_LIFETIME = 86400; @@ -52,6 +56,7 @@ class UserSessionManager implements LoggerAwareInterface protected int $garbageCollectionForAnonymousSessions = self::LIFETIME_OF_ANONYMOUS_SESSION_DATA; protected SessionBackendInterface $sessionBackend; protected IpLocker $ipLocker; + protected string $loginType; /** * Constructor. Marked as internal, as it is recommended to use the factory method "create" @@ -61,11 +66,12 @@ class UserSessionManager implements LoggerAwareInterface * @param IpLocker $ipLocker * @internal */ - public function __construct(SessionBackendInterface $sessionBackend, int $sessionLifetime, IpLocker $ipLocker) + public function __construct(SessionBackendInterface $sessionBackend, int $sessionLifetime, IpLocker $ipLocker, string $loginType) { $this->sessionBackend = $sessionBackend; $this->sessionLifetime = $sessionLifetime; $this->ipLocker = $ipLocker; + $this->loginType = $loginType; } protected function setGarbageCollectionTimeoutForAnonymousSessions(int $garbageCollectionForAnonymousSessions = 0): void @@ -285,13 +291,38 @@ class UserSessionManager implements LoggerAwareInterface } /** - * Creates a new session ID using a random with SESSION_ID_LENGTH as length + * Creates a new session ID using a random with SESSION_ID_LENGTH as length of the random part * * @return string */ protected function createSessionId(): string { - return GeneralUtility::makeInstance(Random::class)->generateRandomHexString(self::SESSION_ID_LENGTH); + $normalizedParams = $this->getNormalizedParams(); + $cookieScope = $this->getCookieScope($normalizedParams); + $key = sha1($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . '/' . UserSession::class . '/' . $cookieScope['domain']); + $random = GeneralUtility::makeInstance(Random::class)->generateRandomHexString(self::SESSION_ID_LENGTH); + $signature = hash_hmac('sha256', $random, $key); + + return $random . '.' . $signature; + } + + /** + * @todo/notes for backports: Same as in typo3/sysext/core/Classes/Hooks/CreateSiteConfiguration.php, + */ + protected function getNormalizedParams(): NormalizedParams + { + $normalizedParams = null; + $serverParams = Environment::isCli() ? ['HTTP_HOST' => 'localhost'] : $_SERVER; + if (isset($GLOBALS['TYPO3_REQUEST'])) { + $normalizedParams = $GLOBALS['TYPO3_REQUEST']->getAttribute('normalizedParams'); + $serverParams = $GLOBALS['TYPO3_REQUEST']->getServerParams(); + } + + if (!$normalizedParams instanceof NormalizedParams) { + $normalizedParams = NormalizedParams::createFromServerParams($serverParams); + } + + return $normalizedParams; } /** @@ -306,6 +337,25 @@ class UserSessionManager implements LoggerAwareInterface if ($id === '') { return null; } + + $sessionsParts = explode('.', $id, 2); + // Verify if session id is signed with cookie domain. + // Note that we allow possibly unsiged session IDs (used for testing framework or 3rd party authenticators) + if (count($sessionsParts) === 2) { + $random = $sessionsParts[0]; + $signature = $sessionsParts[1]; + $normalizedParams = $this->getNormalizedParams(); + $cookieScope = $this->getCookieScope($normalizedParams); + $key = sha1($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . '/' . UserSession::class . '/' . $cookieScope['domain']); + $validHash = hash_hmac('sha256', $random, $key); + if (!hash_equals($validHash, $signature)) { + $this->logger->notice('User Session rejected because of invalid signature', ['session' => substr(sha1($id), 0, 12)]); + return null; + } + } elseif ($this->logger !== null) { + $this->logger->notice('Unsigned session id has been used', ['session' => substr(sha1($id), 0, 12)]); + } + try { $sessionRecord = $this->sessionBackend->get($id); if ($sessionRecord === []) { @@ -357,7 +407,8 @@ class UserSessionManager implements LoggerAwareInterface self::class, $sessionManager->getSessionBackend($loginType), $sessionLifetime, - $ipLocker + $ipLocker, + $loginType ); if ($loginType === 'FE') { $object->setGarbageCollectionTimeoutForAnonymousSessions((int)($GLOBALS['TYPO3_CONF_VARS']['FE']['sessionDataLifetime'] ?? 0)); diff --git a/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php b/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php index 293793a625af58c36499addf4615ea27f5418b86..6bcd200bd45776b187f295d37da2af85676fa856 100644 --- a/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php +++ b/typo3/sysext/core/Tests/Functional/Page/PageRendererTest.php @@ -341,7 +341,8 @@ class PageRendererTest extends FunctionalTestCase $userSessionManager = new UserSessionManager( $sessionBackend->reveal(), 86400, - $this->prophesize(IpLocker::class)->reveal() + $this->prophesize(IpLocker::class)->reveal(), + 'BE' ); $GLOBALS['BE_USER'] = new BackendUserAuthentication(); $GLOBALS['BE_USER']->initializeUserSessionManager($userSessionManager); diff --git a/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php b/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php index 488eb9b2d3c8ca105f1e7cf1e40c03b43df91bd3..83a039e683e38347603cce927147fcca28fb0848 100644 --- a/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php +++ b/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php @@ -101,7 +101,8 @@ class BackendUserAuthenticationTest extends UnitTestCase $userSessionManager = new UserSessionManager( $sessionBackend->reveal(), 86400, - new IpLocker(0, 0) + new IpLocker(0, 0), + 'BE' ); $GLOBALS['BE_USER'] = $this->getMockBuilder(BackendUserAuthentication::class)->getMock(); diff --git a/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php b/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php index 9a66feb644aef46d1cc1f269f224b7fd956146ea..eaa8c6ceac6167e7f806c97ff90b607a925736d0 100644 --- a/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php +++ b/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php @@ -21,6 +21,7 @@ use Prophecy\Argument; use Prophecy\PhpUnit\ProphecyTrait; use Psr\Http\Message\ServerRequestInterface; use TYPO3\CMS\Core\Authentication\IpLocker; +use TYPO3\CMS\Core\Http\NormalizedParams; use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException; use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface; use TYPO3\CMS\Core\Session\UserSession; @@ -62,7 +63,8 @@ class UserSessionManagerTest extends UnitTestCase $subject = new UserSessionManager( $sessionBackendProphecy->reveal(), $sessionLifetime, - new IpLocker(0, 0) + new IpLocker(0, 0), + 'FE' ); $session = $subject->createAnonymousSession(); self::assertEquals($expectedResult, $subject->willExpire($session, $gracePeriod)); @@ -75,7 +77,8 @@ class UserSessionManagerTest extends UnitTestCase $subject = new UserSessionManager( $sessionBackendProphecy->reveal(), 60, - new IpLocker(0, 0) + new IpLocker(0, 0), + 'FE' ); $expiredSession = UserSession::createFromRecord('random-string', ['ses_tstamp' => time() - 500]); self::assertTrue($subject->hasExpired($expiredSession)); @@ -88,9 +91,16 @@ class UserSessionManagerTest extends UnitTestCase */ public function createFromRequestOrAnonymousCreatesProperSessionObjects(): void { + $cookieDomain = 'example.org'; + $normalizedParams = $this->createMock(NormalizedParams::class); + $normalizedParams->method('getRequestHostOnly')->willReturn($cookieDomain); + $key = sha1($GLOBALS['TYPO3_CONF_VARS']['SYS']['encryptionKey'] . '/' . UserSession::class . '/' . $cookieDomain); + $sessionId = 'valid-session'; + $signature = hash_hmac('sha256', $sessionId, $key); + $validSession = $sessionId . '.' . $signature; $sessionBackendProphecy = $this->prophesize(SessionBackendInterface::class); $sessionBackendProphecy->get('invalid-session')->willThrow(SessionNotFoundException::class); - $sessionBackendProphecy->get('valid-session')->willReturn([ + $sessionBackendProphecy->get($validSession)->willReturn([ 'ses_id' => 'valid-session', 'ses_userid' => 13, 'ses_data' => serialize(['propertyA' => 42, 'propertyB' => 'great']), @@ -100,18 +110,24 @@ class UserSessionManagerTest extends UnitTestCase $subject = new UserSessionManager( $sessionBackendProphecy->reveal(), 50, - new IpLocker(0, 0) + new IpLocker(0, 0), + 'FE' ); $request = $this->prophesize(ServerRequestInterface::class); $request->getCookieParams()->willReturn([]); + $request->getServerParams()->willReturn(['HTTP_HOST' => $cookieDomain]); + $request->getAttribute('normalizedParams')->willReturn($normalizedParams); + $GLOBALS['TYPO3_REQUEST'] = $request->reveal(); $anonymousSession = $subject->createFromRequestOrAnonymous($request->reveal(), 'foo'); self::assertTrue($anonymousSession->isNew()); self::assertTrue($anonymousSession->isAnonymous()); - $request->getCookieParams()->willReturn(['foo' => 'invalid-session', 'bar' => 'valid-session']); + + $request->getCookieParams()->willReturn(['foo' => 'invalid-session', 'bar' => $validSession]); $anonymousSessionFromInvalidBackendRequest = $subject->createFromRequestOrAnonymous($request->reveal(), 'foo'); self::assertTrue($anonymousSessionFromInvalidBackendRequest->isNew()); self::assertTrue($anonymousSessionFromInvalidBackendRequest->isAnonymous()); $persistedSession = $subject->createFromRequestOrAnonymous($request->reveal(), 'bar'); + self::assertEquals(13, $persistedSession->getUserId()); self::assertFalse($persistedSession->isAnonymous()); self::assertFalse($persistedSession->isNew()); @@ -136,7 +152,8 @@ class UserSessionManagerTest extends UnitTestCase $subject = new UserSessionManager( $sessionBackendProphecy->reveal(), 60, - new IpLocker(0, 0) + new IpLocker(0, 0), + 'FE' ); $session = UserSession::createFromRecord('random-string', ['ses_tstamp' => time() - 500]); $session = $subject->updateSession($session); @@ -159,7 +176,8 @@ class UserSessionManagerTest extends UnitTestCase $subject = new UserSessionManager( $sessionBackendProphecy->reveal(), 60, - new IpLocker(0, 0) + new IpLocker(0, 0), + 'FE' ); $session = UserSession::createFromRecord('random-string', ['ses_tstamp' => time() - 500]); $session = $subject->fixateAnonymousSession($session); diff --git a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php index 5ab87b28e09a7a428594b5f3eca10f7ac43c1e1d..3e232765bb28a15322f346e1fb8e544b24b68982 100644 --- a/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php +++ b/typo3/sysext/frontend/Tests/Unit/Authentication/FrontendUserAuthenticationTest.php @@ -92,7 +92,8 @@ class FrontendUserAuthenticationTest extends UnitTestCase $userSessionManager = new UserSessionManager( $sessionBackendProphecy->reveal(), 86400, - new IpLocker(0, 0) + new IpLocker(0, 0), + 'FE' ); $subject = new FrontendUserAuthentication(); $subject->setLogger(new NullLogger());