CVE-2023-47127: Improper Authentication (CWE-287)
value: TYPO3 is an open source PHP based web content management system released under the GNU GPL. In typo3 installations there are always at least two different sites. Eg. first.example.org and second.example.com. In affected versions a session cookie generated for the first site can be reused on the second site without requiring additional authentication. This vulnerability has been addressed in versions 8.7.55, 9.5.44, 10.4.41, 11.5.33, and 12.4.8. Users are advised to upgrade. There are no known workarounds for this vulnerability.
CWE description: When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
CVSS v3 base score: 5.4
Published: 2023-11-14T20:15Z
Repository: https://github.com/TYPO3/typo3
Language: PHP
Files changed: typo3/sysext/core/Classes/Http/CookieScope.php, typo3/sysext/core/Classes/Http/CookieScopeTrait.php, typo3/sysext/core/Classes/Http/SetCookieService.php, typo3/sysext/core/Classes/Session/UserSession.php, typo3/sysext/core/Classes/Session/UserSessionManager.php, typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php, typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php, typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php, typo3/sysext/frontend/Tests/Functional/Authentication/FrontendUserAuthenticationTest.php
Commit date: 2023-11-14 09:58:06 +0100
Commit message: [SECURITY] Limit user session to cookie domain

Given that there are two sites `site-a.com` and `site-b.com` in
the same TYPO3 installation, it was possible to reuse a session
cookie that was generated for `site-a.com` in `site-b.com`.

Since there are scenarios, where this is the expected behavior
– when sharing sessions across sub domains, so that an explicit
cookieDomain needs to be configured – user sessions signatures
are now salted with the desired cookie domain, so that a cookie
can only be used on the domain that the cookie was created for.

Testing framework will need to be adapted in a subsequent patch,
but for the time being – and for compatiblity with possible 3rd
party authenticators – legacy tokens will be accepted, but not
created by TYPO3 core.

Resolves: #100885
Releases: main, 12.4, 11.5
Change-Id: I0d1c314c6e206ac12604ba6f859af78b958651dd
Security-Bulletin: TYPO3-CORE-SA-2023-006
Security-References: CVE-2023-47127
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/81729
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>

Diff: diff --git a/typo3/sysext/core/Classes/Http/CookieScope.php b/typo3/sysext/core/Classes/Http/CookieScope.php
new file mode 100644
index 000000000000..c0aa341e5068
--- /dev/null
+++ b/typo3/sysext/core/Classes/Http/CookieScope.php
@@ -0,0 +1,27 @@
+<?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;
+
+final class CookieScope
+{
+    public function __construct(
+        public readonly string $domain,
+        public readonly bool $hostOnly,
+        public readonly string $path,
+    ) {}
+}
diff --git a/typo3/sysext/core/Classes/Http/CookieScopeTrait.php b/typo3/sysext/core/Classes/Http/CookieScopeTrait.php
new file mode 100644
index 000000000000..fb2ae6eafa22
--- /dev/null
+++ b/typo3/sysext/core/Classes/Http/CookieScopeTrait.php
@@ -0,0 +1,72 @@
+<?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.
+     */
+    private function getCookieScope(NormalizedParams $normalizedParams): CookieScope
+    {
+        $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 new CookieScope(
+                domain: $normalizedParams->getRequestHostOnly(),
+                hostOnly: true,
+                // 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 new CookieScope(
+                    domain: $normalizedParams->getRequestHostOnly(),
+                    hostOnly: true,
+                    // If no cookie domain could be matched, use the base path
+                    path: $normalizedParams->getSitePath(),
+                );
+            }
+            $cookieDomain = $match[0];
+        }
+
+        return new CookieScope(
+            // Normalize cookie domain by removing leading and trailing dots,
+            // see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3
+            // > Note that a leading %x2E ("."), if present, is ignored even though that character is not permitted,
+            // > but a trailing %x2E ("."), if present, will cause the user agent to ignore the attribute.
+            domain: trim($cookieDomain, '.'),
+            hostOnly: false,
+            path: '/',
+        );
+    }
+}
diff --git a/typo3/sysext/core/Classes/Http/SetCookieService.php b/typo3/sysext/core/Classes/Http/SetCookieService.php
index 8238c884ad7c..a706bffc1ec4 100644
--- a/typo3/sysext/core/Classes/Http/SetCookieService.php
+++ b/typo3/sysext/core/Classes/Http/SetCookieService.php
@@ -30,10 +30,11 @@ use TYPO3\CMS\Core\Utility\GeneralUtility;
  * in order to use the same session across multiple HTTP requests.
  */
 class SetCookieService
 {
     use CookieHeaderTrait;
+    use CookieScopeTrait;
 
     protected readonly LoggerInterface $logger;
 
     public static function create(string $name, string $type): self
     {
@@ -63,77 +64,48 @@ class SetCookieService
     {
         $setCookie = null;
         $isRefreshTimeBasedCookie = $this->isRefreshTimeBasedCookie($userSession);
         if ($this->isSetSessionCookie($userSession) || $isRefreshTimeBasedCookie) {
             // Get the domain to be used for the cookie (if any):
-            $cookieDomain = $this->getCookieDomain($normalizedParams);
-            // If no cookie domain is set, use the base path:
-            $cookiePath = $cookieDomain ? '/' : $normalizedParams->getSitePath();
+            $cookieScope = $this->getCookieScope($normalizedParams);
             // If the cookie lifetime is set, use it:
             $cookieExpire = $isRefreshTimeBasedCookie ? $GLOBALS['EXEC_TIME'] + $this->lifetime : 0;
             // Valid options are "strict", "lax" or "none", whereas "none" only works in HTTPS requests (default & fallback is "strict")
             $cookieSameSite = $this->sanitizeSameSiteCookieValue(
                 strtolower($GLOBALS['TYPO3_CONF_VARS'][$this->loginType]['cookieSameSite'] ?? Cookie::SAMESITE_STRICT)
             );
             // Use the secure option when the current request is served by a secure connection:
             // SameSite "none" needs the secure option (only allowed on HTTPS)
             $isSecure = $cookieSameSite === Cookie::SAMESITE_NONE || $normalizedParams->isHttps();
             $sessionId = $userSession->getIdentifier();
-            $cookieValue = $userSession->getJwt();
+            $cookieValue = $userSession->getJwt($cookieScope);
             $setCookie = new Cookie(
                 $this->name,
                 $cookieValue,
                 $cookieExpire,
-                $cookiePath,
-                $cookieDomain,
+                $cookieScope->path,
+                // Host-Only cookies need to be provided without an explicit domain,
+                // see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3
+                // and https://datatracker.ietf.org/doc/html/rfc6265#section-5.3
+                // | * If the value of the Domain attribute is "example.com", the user agent will include the cookie
+                // |   in the Cookie header when making HTTP requests to example.com, www.example.com, and www.corp.example.com
+                // | * If the server omits the Domain attribute, the user agent will return the cookie only to the origin server.
+                $cookieScope->hostOnly ? null : $cookieScope->domain,
                 $isSecure,
                 true,
                 false,
                 $cookieSameSite
             );
             $message = $isRefreshTimeBasedCookie ? 'Updated Cookie: {session}, {domain}' : 'Set Cookie: {session}, {domain}';
             $this->logger->debug($message, [
                 'session' => sha1($sessionId),
-                'domain' => $cookieDomain,
+                'domain' => $cookieScope->domain,
             ]);
         }
         return $setCookie;
     }
 
-    /**
-     * Gets the domain to be used on setting cookies.
-     * The information is taken from the value in $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'].
-     *
-     * @return string The domain to be used on setting cookies
-     */
-    protected function getCookieDomain(NormalizedParams $normalizedParams): string
-    {
-        $result = '';
-        $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) {
-            if ($cookieDomain[0] === '/') {
-                $match = [];
-                $matchCnt = @preg_match($cookieDomain, $normalizedParams->getRequestHostOnly(), $match);
-                if ($matchCnt === false) {
-                    $this->logger->critical(
-                        'The regular expression for the cookie domain ({domain}) contains errors. The session is not shared across sub-domains.',
-                        ['domain' => $cookieDomain]
-                    );
-                } elseif ($matchCnt) {
-                    $result = $match[0];
-                }
-            } else {
-                $result = $cookieDomain;
-            }
-        }
-        return $result;
-    }
-
     /**
      * Determine whether a session cookie needs to be set (lifetime=0)
      */
     public function isSetSessionCookie(UserSession $userSession, bool $forceSetCookie = false): bool
     {
@@ -178,17 +150,15 @@ class SetCookieService
     /**
      * Empty / unset the cookie
      */
     public function removeCookie(NormalizedParams $normalizedParams): Cookie
     {
-        $cookieDomain = $this->getCookieDomain($normalizedParams);
-        // If no cookie domain is set, use the base path
-        $cookiePath = $cookieDomain ? '/' : $normalizedParams->getSitePath();
+        $scope = $this->getCookieScope($normalizedParams);
         return new Cookie(
             $this->name,
             '',
             -1,
-            $cookiePath,
-            $cookieDomain
+            $scope->path,
+            $scope->domain
         );
     }
 }
diff --git a/typo3/sysext/core/Classes/Session/UserSession.php b/typo3/sysext/core/Classes/Session/UserSession.php
index 4cf2cdb80758..62665daa30d5 100644
--- a/typo3/sysext/core/Classes/Session/UserSession.php
+++ b/typo3/sysext/core/Classes/Session/UserSession.php
@@ -15,11 +15,14 @@ declare(strict_types=1);
  * The TYPO3 project - inspiring people to share!
  */
 
 namespace TYPO3\CMS\Core\Session;
 
+use TYPO3\CMS\Core\Http\CookieScope;
+use TYPO3\CMS\Core\Log\LogManager;
 use TYPO3\CMS\Core\Security\JwtTrait;
+use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
  * Represents all information about a user's session.
  * A user session can be bound to a frontend / backend user, or an anonymous session based on session data stored
  * in the session backend.
@@ -193,21 +196,23 @@ class UserSession
         return $GLOBALS['EXEC_TIME'] > ($this->lastUpdated + self::SESSION_UPDATE_GRACE_PERIOD);
     }
 
     /**
      * Gets session ID wrapped in JWT to be used for emitting a new cookie.
-     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature>)>`
+     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>`
      *
+     * @param ?CookieScope $scope
      * @return string the session ID wrapped in JWT to be used for emitting a new cookie
      */
-    public function getJwt(): string
+    public function getJwt(?CookieScope $scope = null): string
     {
         // @todo payload could be organized in a new `SessionToken` object
         return self::encodeHashSignedJwt(
             [
                 'identifier' => $this->identifier,
                 'time' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339),
+                'scope' => $scope,
             ],
             self::createSigningKeyFromEncryptionKey(UserSession::class)
         );
     }
 
@@ -244,24 +249,44 @@ class UserSession
         return $userSession;
     }
 
     /**
      * Verifies and resolves the session ID from a submitted cookie value:
-     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature>)>`
+     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>`
      *
      * @param string $cookieValue submitted cookie value
+     * @param CookieScope $scope
      * @return non-empty-string|null session ID, null in case verification failed
      * @throws \Exception
      * @see getJwt()
      */
-    public static function resolveIdentifierFromJwt(string $cookieValue): ?string
+    public static function resolveIdentifierFromJwt(string $cookieValue, CookieScope $scope): ?string
     {
         if ($cookieValue === '') {
             return null;
         }
+
         $payload = self::decodeJwt($cookieValue, self::createSigningKeyFromEncryptionKey(UserSession::class));
-        return !empty($payload->identifier) && is_string($payload->identifier) ? $payload->identifier : null;
+
+        $identifier = !empty($payload->identifier) && is_string($payload->identifier) ? $payload->identifier : null;
+        if ($identifier === null) {
+            return null;
+        }
+
+        $domainScope = (string)($payload->scope->domain ?? '');
+        $pathScope = (string)($payload->scope->path ?? '');
+        if ($domainScope === '' || $pathScope === '') {
+            $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(self::class);
+            $logger->notice('A session cookie with out a domain scope has been used', ['cookieHash' => substr(sha1($cookieValue), 0, 12)]);
+            return $identifier;
+        }
+        if ($domainScope !== $scope->domain || $pathScope !== $scope->path) {
+            // invalid scope, the cookie jwt has been used on a wrong path or domain
+            return null;
+        }
+
+        return $identifier;
     }
 
     /**
      * @internal Used internally to store data in the backend
      * @return array The session record as array
diff --git a/typo3/sysext/core/Classes/Session/UserSessionManager.php b/typo3/sysext/core/Classes/Session/UserSessionManager.php
index 2333bda00615..65209e97ba83 100644
--- a/typo3/sysext/core/Classes/Session/UserSessionManager.php
+++ b/typo3/sysext/core/Classes/Session/UserSessionManager.php
@@ -20,10 +20,11 @@ namespace TYPO3\CMS\Core\Session;
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Log\LoggerAwareInterface;
 use Psr\Log\LoggerAwareTrait;
 use TYPO3\CMS\Core\Authentication\IpLocker;
 use TYPO3\CMS\Core\Crypto\Random;
+use TYPO3\CMS\Core\Http\CookieScopeTrait;
 use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
 use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
 use TYPO3\CMS\Core\Utility\GeneralUtility;
 
 /**
@@ -41,10 +42,11 @@ 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;
     protected const LIFETIME_OF_ANONYMOUS_SESSION_DATA = 86400;
 
@@ -57,21 +59,23 @@ class UserSessionManager implements LoggerAwareInterface
     protected int $sessionLifetime;
 
     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"
      *
      * @internal it is recommended to use the factory method "create"
      */
-    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
     {
         if ($garbageCollectionForAnonymousSessions > 0) {
@@ -89,11 +93,12 @@ class UserSessionManager implements LoggerAwareInterface
      */
     public function createFromRequestOrAnonymous(ServerRequestInterface $request, string $cookieName): UserSession
     {
         try {
             $cookieValue = (string)($request->getCookieParams()[$cookieName] ?? '');
-            $sessionId = UserSession::resolveIdentifierFromJwt($cookieValue);
+            $scope = $this->getCookieScope($request->getAttribute('normalizedParams'));
+            $sessionId = UserSession::resolveIdentifierFromJwt($cookieValue, $scope);
         } catch (\Exception $exception) {
             $this->logger->debug('Could not resolve session identifier from JWT', ['exception' => $exception]);
         }
         return $this->getSessionFromSessionId($sessionId ?? '') ?? $this->createAnonymousSession();
     }
@@ -352,11 +357,12 @@ class UserSessionManager implements LoggerAwareInterface
         }
         $object = GeneralUtility::makeInstance(
             self::class,
             $sessionManager->getSessionBackend($loginType),
             $sessionLifetime,
-            $ipLocker
+            $ipLocker,
+            $loginType
         );
         if ($loginType === 'FE') {
             $object->setGarbageCollectionTimeoutForAnonymousSessions((int)($GLOBALS['TYPO3_CONF_VARS']['FE']['sessionDataLifetime'] ?? 0));
         }
         return $object;
diff --git a/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php b/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php
index 68f20e460bc5..e3c716be93db 100644
--- a/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php
+++ b/typo3/sysext/core/Tests/Unit/Authentication/BackendUserAuthenticationTest.php
@@ -78,11 +78,12 @@ final class BackendUserAuthenticationTest extends UnitTestCase
         $sessionBackendMock = $this->createMock(SessionBackendInterface::class);
         $sessionBackendMock->method('remove')->with(self::anything())->willReturn(true);
         $userSessionManager = new UserSessionManager(
             $sessionBackendMock,
             86400,
-            new IpLocker(0, 0)
+            new IpLocker(0, 0),
+            'BE'
         );
 
         $GLOBALS['BE_USER'] = $this->getMockBuilder(BackendUserAuthentication::class)->getMock();
         $GLOBALS['BE_USER']->user = [
             'uid' => 4711,
diff --git a/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php b/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php
index 416f7637fe4d..8650c9a0743a 100644
--- a/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php
+++ b/typo3/sysext/core/Tests/Unit/Session/UserSessionManagerTest.php
@@ -18,10 +18,11 @@ declare(strict_types=1);
 namespace TYPO3\CMS\Core\Tests\Unit\Session;
 
 use Psr\Http\Message\ServerRequestInterface;
 use Psr\Log\NullLogger;
 use TYPO3\CMS\Core\Authentication\IpLocker;
+use TYPO3\CMS\Core\Http\NormalizedParams;
 use TYPO3\CMS\Core\Security\JwtTrait;
 use TYPO3\CMS\Core\Session\Backend\Exception\SessionNotFoundException;
 use TYPO3\CMS\Core\Session\Backend\SessionBackendInterface;
 use TYPO3\CMS\Core\Session\UserSession;
 use TYPO3\CMS\Core\Session\UserSessionManager;
@@ -60,11 +61,12 @@ final class UserSessionManagerTest extends UnitTestCase
     {
         $sessionBackendMock = $this->createMock(SessionBackendInterface::class);
         $subject = new UserSessionManager(
             $sessionBackendMock,
             $sessionLifetime,
-            new IpLocker(0, 0)
+            new IpLocker(0, 0),
+            'FE'
         );
         $session = $subject->createAnonymousSession();
         self::assertEquals($expectedResult, $subject->willExpire($session, $gracePeriod));
     }
 
@@ -73,11 +75,12 @@ final class UserSessionManagerTest extends UnitTestCase
         $GLOBALS['EXEC_TIME'] = time();
         $sessionBackendMock = $this->createMock(SessionBackendInterface::class);
         $subject = new UserSessionManager(
             $sessionBackendMock,
             60,
-            new IpLocker(0, 0)
+            new IpLocker(0, 0),
+            'FE'
         );
         $expiredSession = UserSession::createFromRecord('random-string', ['ses_tstamp' => time() - 500]);
         self::assertTrue($subject->hasExpired($expiredSession));
         $newSession = UserSession::createFromRecord('random-string', ['ses_tstamp' => time()]);
         self::assertFalse($subject->hasExpired($newSession));
@@ -98,21 +101,35 @@ final class UserSessionManagerTest extends UnitTestCase
             'ses_iplock' => '[DISABLED]',
         ]);
         $subject = new UserSessionManager(
             $sessionBackendMock,
             50,
-            new IpLocker(0, 0)
+            new IpLocker(0, 0),
+            'FE'
         );
         $subject->setLogger(new NullLogger());
+        $cookieDomain = 'example.org';
         $validSessionJwt = self::encodeHashSignedJwt(
             [
                 'identifier' => 'valid-session',
                 'time' => (new \DateTimeImmutable())->format(\DateTimeImmutable::RFC3339),
+                'scope' => [
+                    'domain' => $cookieDomain,
+                    'path' => '/',
+                ],
             ],
             self::createSigningKeyFromEncryptionKey(UserSession::class)
         );
+
+        $normalizedParams = $this->createMock(NormalizedParams::class);
+        $normalizedParams->method('getRequestHostOnly')->willReturn($cookieDomain);
+        $normalizedParams->method('getSitePath')->willReturn('/');
         $request = $this->createMock(ServerRequestInterface::class);
+        $request->method('getAttribute')->willReturnCallback(static fn(string $name): mixed => match ($name) {
+            'normalizedParams' => $normalizedParams,
+            default => null,
+        });
         $request->method('getCookieParams')->willReturn(['bar' => $validSessionJwt]);
         $persistedSession = $subject->createFromRequestOrAnonymous($request, 'bar');
         self::assertEquals(13, $persistedSession->getUserId());
         self::assertFalse($persistedSession->isAnonymous());
         self::assertFalse($persistedSession->isNew());
@@ -132,15 +149,23 @@ final class UserSessionManagerTest extends UnitTestCase
             new SessionNotFoundException('Session not found', 1669358326)
         );
         $subject = new UserSessionManager(
             $sessionBackendMock,
             50,
-            new IpLocker(0, 0)
+            new IpLocker(0, 0),
+            'FE'
         );
         $subject->setLogger(new NullLogger());
 
+        $cookieDomain = 'example.org';
+        $normalizedParams = $this->createMock(NormalizedParams::class);
+        $normalizedParams->method('getRequestHostOnly')->willReturn($cookieDomain);
         $request = $this->createMock(ServerRequestInterface::class);
+        $request->method('getAttribute')->willReturnCallback(static fn(string $name): mixed => match ($name) {
+            'normalizedParams' => $normalizedParams,
+            default => null,
+        });
         $request->method('getCookieParams')->willReturnOnConsecutiveCalls([], ['foo' => 'invalid-session']);
         $anonymousSession = $subject->createFromRequestOrAnonymous($request, 'foo');
         self::assertTrue($anonymousSession->isNew());
         self::assertTrue($anonymousSession->isAnonymous());
 
@@ -163,11 +188,12 @@ final class UserSessionManagerTest extends UnitTestCase
             'ses_iplock' => '[DISABLED]',
         ]);
         $subject = new UserSessionManager(
             $sessionBackendMock,
             60,
-            new IpLocker(0, 0)
+            new IpLocker(0, 0),
+            'FE'
         );
         $session = UserSession::createFromRecord('random-string', ['ses_tstamp' => time() - 500]);
         $session = $subject->updateSession($session);
         self::assertSame(7654321, $session->getLastUpdated());
     }
@@ -186,11 +212,12 @@ final class UserSessionManagerTest extends UnitTestCase
             'ses_iplock' => IpLocker::DISABLED_LOCK_VALUE,
         ]);
         $subject = new UserSessionManager(
             $sessionBackendMock,
             60,
-            new IpLocker(0, 0)
+            new IpLocker(0, 0),
+            'FE'
         );
         $session = UserSession::createFromRecord('random-string', ['ses_tstamp' => time() - 500]);
         $session = $subject->fixateAnonymousSession($session);
         self::assertSame(IpLocker::DISABLED_LOCK_VALUE, $session->getIpLock());
         self::assertNull($session->getUserId());
diff --git a/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php b/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php
index 82223c7be744..ca178ca835e2 100644
--- a/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php
+++ b/typo3/sysext/core/Tests/Unit/Session/UserSessionTest.php
@@ -15,10 +15,11 @@ declare(strict_types=1);
  * The TYPO3 project - inspiring people to share!
  */
 
 namespace TYPO3\CMS\Core\Tests\Unit\Session;
 
+use TYPO3\CMS\Core\Http\CookieScope;
 use TYPO3\CMS\Core\Security\JwtTrait;
 use TYPO3\CMS\Core\Session\UserSession;
 use TYPO3\TestingFramework\Core\Unit\UnitTestCase;
 
 final class UserSessionTest extends UnitTestCase
@@ -36,10 +37,11 @@ final class UserSessionTest extends UnitTestCase
             'ses_userid' => 0,
             'ses_iplock' => '[DISABLED]',
             'ses_tstamp' => 1607041477,
             'ses_permanent' => 1,
         ];
+        $scope = new CookieScope(domain: 'example.com', hostOnly: true, path: '/');
 
         $session = UserSession::createFromRecord($record['ses_id'], $record, true);
 
         self::assertEquals($record['ses_id'], $session->getIdentifier());
         self::assertNull($session->getUserId());
@@ -59,11 +61,11 @@ final class UserSessionTest extends UnitTestCase
 
         $session->overrideData(['override' => 'data']);
 
         self::assertTrue($session->dataWasUpdated());
         self::assertEquals(['override' => 'data'], $session->getData());
-        self::assertSame($record['ses_id'], UserSession::resolveIdentifierFromJwt($session->getJwt()));
+        self::assertSame($record['ses_id'], UserSession::resolveIdentifierFromJwt($session->getJwt($scope), $scope) ?? '');
     }
 
     /**
      * @test
      */
diff --git a/typo3/sysext/frontend/Tests/Functional/Authentication/FrontendUserAuthenticationTest.php b/typo3/sysext/frontend/Tests/Functional/Authentication/FrontendUserAuthenticationTest.php
index 3476290cdc85..5fafed8a58e8 100644
--- a/typo3/sysext/frontend/Tests/Functional/Authentication/FrontendUserAuthenticationTest.php
+++ b/typo3/sysext/frontend/Tests/Functional/Authentication/FrontendUserAuthenticationTest.php
@@ -17,10 +17,13 @@ declare(strict_types=1);
 
 namespace TYPO3\CMS\Frontend\Tests\Functional\Authentication;
 
 use GuzzleHttp\Cookie\SetCookie;
 use Psr\Log\NullLogger;
+use TYPO3\CMS\Core\Core\Environment;
+use TYPO3\CMS\Core\Http\NormalizedParams;
+use TYPO3\CMS\Core\Http\ServerRequest;
 use TYPO3\CMS\Core\Security\Nonce;
 use TYPO3\CMS\Core\Security\RequestToken;
 use TYPO3\CMS\Core\Tests\Functional\SiteHandling\SiteBasedTestTrait;
 use TYPO3\CMS\Frontend\Authentication\FrontendUserAuthentication;
 use TYPO3\TestingFramework\Core\Functional\Framework\Frontend\InternalRequest;
@@ -62,10 +65,23 @@ final class FrontendUserAuthenticationTest extends FunctionalTestCase
      */
     public function canCreateNewAndExistingSessionWithValidRequestToken(): void
     {
         $this->importCSVDataSet(__DIR__ . '/Fixtures/fe_users.csv');
 
+        $normalizedParams = new NormalizedParams(
+            [
+                'REQUEST_URI' => '/',
+                'HTTP_HOST' => 'localhost',
+                'DOCUMENT_ROOT' => Environment::getPublicPath(),
+                'SCRIPT_FILENAME' => Environment::getPublicPath() . '/index.php',
+                'SCRIPT_NAME' => '/index.php',
+            ],
+            $GLOBALS['TYPO3_CONF_VARS']['SYS'],
+            Environment::getPublicPath() . '/index.php',
+            Environment::getPublicPath()
+        );
+
         $nonce = Nonce::create();
         $requestToken = RequestToken::create('core/user-auth/fe')->toHashSignedJwt($nonce);
         $request = (new InternalRequest())
             ->withPageId(self::ROOT_PAGE_ID)
             ->withMethod('POST')
@@ -75,21 +91,22 @@ final class FrontendUserAuthenticationTest extends FunctionalTestCase
                     'pass' => 'test',
                     'logintype' => 'login',
                     '__RequestToken' => $requestToken,
                 ]
             )
+            ->withAttribute('normalizedParams', $normalizedParams)
             ->withCookieParams([123 => 'bogus', 'typo3nonce_' . $nonce->getSigningIdentifier()->name => $nonce->toHashSignedJwt()]);
 
         $response = $this->executeFrontendSubRequest($request);
 
         self::assertStringContainsString('fe_typo_user', $response->getHeaderLine('Set-Cookie'));
         $this->assertCSVDataSet(__DIR__ . '/Fixtures/fe_sessions_filled.csv');
 
         // Now check whether the existing session is retrieved by providing the retrieved JWT token in the cookie params.
         $cookie = SetCookie::fromString($response->getHeaderLine('Set-Cookie'));
-        $request = (new InternalRequest())
-            ->withPageId(self::ROOT_PAGE_ID)
+        $request = (new ServerRequest('http://localhost/'))
+            ->withAttribute('normalizedParams', $normalizedParams)
             ->withCookieParams([$cookie->getName() => $cookie->getValue()]);
 
         $frontendUserAuthentication = new FrontendUserAuthentication();
         $frontendUserAuthentication->setLogger(new NullLogger());
         $frontendUserAuthentication->start($request);

Vulnerable code:             $cookieDomain = $this->getCookieDomain($normalizedParams);
            // If no cookie domain is set, use the base path:
            $cookiePath = $cookieDomain ? '/' : $normalizedParams->getSitePath();
            $cookieValue = $userSession->getJwt();
                $cookiePath,
                $cookieDomain,
                'domain' => $cookieDomain,
    /**
     * Gets the domain to be used on setting cookies.
     * The information is taken from the value in $GLOBALS['TYPO3_CONF_VARS']['SYS']['cookieDomain'].
     *
     * @return string The domain to be used on setting cookies
     */
    protected function getCookieDomain(NormalizedParams $normalizedParams): string
    {
        $result = '';
        $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) {
            if ($cookieDomain[0] === '/') {
                $match = [];
                $matchCnt = @preg_match($cookieDomain, $normalizedParams->getRequestHostOnly(), $match);
                if ($matchCnt === false) {
                    $this->logger->critical(
                        'The regular expression for the cookie domain ({domain}) contains errors. The session is not shared across sub-domains.',
                        ['domain' => $cookieDomain]
                    );
                } elseif ($matchCnt) {
                    $result = $match[0];
                }
            } else {
                $result = $cookieDomain;
            }
        }
        return $result;
    }

        $cookieDomain = $this->getCookieDomain($normalizedParams);
        // If no cookie domain is set, use the base path
        $cookiePath = $cookieDomain ? '/' : $normalizedParams->getSitePath();
            $cookiePath,
            $cookieDomain
     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature>)>`
    public function getJwt(): string
     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature>)>`
    public static function resolveIdentifierFromJwt(string $cookieValue): ?string
        return !empty($payload->identifier) && is_string($payload->identifier) ? $payload->identifier : null;
    public function __construct(SessionBackendInterface $sessionBackend, int $sessionLifetime, IpLocker $ipLocker)
            $sessionId = UserSession::resolveIdentifierFromJwt($cookieValue);
            $ipLocker
            new IpLocker(0, 0)
            new IpLocker(0, 0)
            new IpLocker(0, 0)
            new IpLocker(0, 0)
            new IpLocker(0, 0)
            new IpLocker(0, 0)
            new IpLocker(0, 0)
        self::assertSame($record['ses_id'], UserSession::resolveIdentifierFromJwt($session->getJwt()));
        $request = (new InternalRequest())
            ->withPageId(self::ROOT_PAGE_ID)
Fixed code: <?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;

final class CookieScope
{
    public function __construct(
        public readonly string $domain,
        public readonly bool $hostOnly,
        public readonly string $path,
    ) {}
}
<?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.
     */
    private function getCookieScope(NormalizedParams $normalizedParams): CookieScope
    {
        $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 new CookieScope(
                domain: $normalizedParams->getRequestHostOnly(),
                hostOnly: true,
                // 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 new CookieScope(
                    domain: $normalizedParams->getRequestHostOnly(),
                    hostOnly: true,
                    // If no cookie domain could be matched, use the base path
                    path: $normalizedParams->getSitePath(),
                );
            }
            $cookieDomain = $match[0];
        }

        return new CookieScope(
            // Normalize cookie domain by removing leading and trailing dots,
            // see https://www.rfc-editor.org/rfc/rfc6265#section-4.1.2.3
            // > Note that a leading %x2E ("."), if present, is ignored even though that character is not permitted,
            // > but a trailing %x2E ("."), if present, will cause the user agent to ignore the attribute.
            domain: trim($cookieDomain, '.'),
            hostOnly: false,
            path: '/',
        );
    }
}
    use CookieScopeTrait;
            $cookieScope = $this->getCookieScope($normalizedParams);
            $cookieValue = $userSession->getJwt($cookieScope);
                $cookieScope->path,
                // Host-Only cookies need to be provided without an explicit domain,
                // see https://datatracker.ietf.org/doc/html/rfc6265#section-4.1.2.3
                // and https://datatracker.ietf.org/doc/html/rfc6265#section-5.3
                // | * If the value of the Domain attribute is "example.com", the user agent will include the cookie
                // |   in the Cookie header when making HTTP requests to example.com, www.example.com, and www.corp.example.com
                // | * If the server omits the Domain attribute, the user agent will return the cookie only to the origin server.
                $cookieScope->hostOnly ? null : $cookieScope->domain,
                'domain' => $cookieScope->domain,
        $scope = $this->getCookieScope($normalizedParams);
            $scope->path,
            $scope->domain
use TYPO3\CMS\Core\Http\CookieScope;
use TYPO3\CMS\Core\Log\LogManager;
use TYPO3\CMS\Core\Utility\GeneralUtility;
     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>`
     * @param ?CookieScope $scope
    public function getJwt(?CookieScope $scope = null): string
                'scope' => $scope,
     * `Cookie: <JWT(HS256, [identifier => <session-id>], <signature(encryption-key, cookie-domain)>)>`
     * @param CookieScope $scope
    public static function resolveIdentifierFromJwt(string $cookieValue, CookieScope $scope): ?string


        $identifier = !empty($payload->identifier) && is_string($payload->identifier) ? $payload->identifier : null;
        if ($identifier === null) {
            return null;
        }

        $domainScope = (string)($payload->scope->domain ?? '');
        $pathScope = (string)($payload->scope->path ?? '');
        if ($domainScope === '' || $pathScope === '') {
            $logger = GeneralUtility::makeInstance(LogManager::class)->getLogger(self::class);
            $logger->notice('A session cookie with out a domain scope has been used', ['cookieHash' => substr(sha1($cookieValue), 0, 12)]);
            return $identifier;
        }
        if ($domainScope !== $scope->domain || $pathScope !== $scope->path) {
            // invalid scope, the cookie jwt has been used on a wrong path or domain
            return null;
        }

        return $identifier;
use TYPO3\CMS\Core\Http\CookieScopeTrait;
    use CookieScopeTrait;
    protected string $loginType;
    public function __construct(SessionBackendInterface $sessionBackend, int $sessionLifetime, IpLocker $ipLocker, string $loginType)
        $this->loginType = $loginType;
            $scope = $this->getCookieScope($request->getAttribute('normalizedParams'));
            $sessionId = UserSession::resolveIdentifierFromJwt($cookieValue, $scope);
            $ipLocker,
            $loginType
            new IpLocker(0, 0),
            'BE'
use TYPO3\CMS\Core\Http\NormalizedParams;
            new IpLocker(0, 0),
            'FE'
            new IpLocker(0, 0),
            'FE'
            new IpLocker(0, 0),
            'FE'
        $cookieDomain = 'example.org';
                'scope' => [
                    'domain' => $cookieDomain,
                    'path' => '/',
                ],

        $normalizedParams = $this->createMock(NormalizedParams::class);
        $normalizedParams->method('getRequestHostOnly')->willReturn($cookieDomain);
        $normalizedParams->method('getSitePath')->willReturn('/');
        $request->method('getAttribute')->willReturnCallback(static fn(string $name): mixed => match ($name) {
            'normalizedParams' => $normalizedParams,
            default => null,
        });
            new IpLocker(0, 0),
            'FE'
        $cookieDomain = 'example.org';
        $normalizedParams = $this->createMock(NormalizedParams::class);
        $normalizedParams->method('getRequestHostOnly')->willReturn($cookieDomain);
        $request->method('getAttribute')->willReturnCallback(static fn(string $name): mixed => match ($name) {
            'normalizedParams' => $normalizedParams,
            default => null,
        });
            new IpLocker(0, 0),
            'FE'
            new IpLocker(0, 0),
            'FE'
use TYPO3\CMS\Core\Http\CookieScope;
        $scope = new CookieScope(domain: 'example.com', hostOnly: true, path: '/');
        self::assertSame($record['ses_id'], UserSession::resolveIdentifierFromJwt($session->getJwt($scope), $scope) ?? '');
use TYPO3\CMS\Core\Core\Environment;
use TYPO3\CMS\Core\Http\NormalizedParams;
use TYPO3\CMS\Core\Http\ServerRequest;
        $normalizedParams = new NormalizedParams(
            [
                'REQUEST_URI' => '/',
                'HTTP_HOST' => 'localhost',
                'DOCUMENT_ROOT' => Environment::getPublicPath(),
                'SCRIPT_FILENAME' => Environment::getPublicPath() . '/index.php',
                'SCRIPT_NAME' => '/index.php',
            ],
            $GLOBALS['TYPO3_CONF_VARS']['SYS'],
            Environment::getPublicPath() . '/index.php',
            Environment::getPublicPath()
        );

            ->withAttribute('normalizedParams', $normalizedParams)
        $request = (new ServerRequest('http://localhost/'))
            ->withAttribute('normalizedParams', $normalizedParams)