CVE-2023-47127 (PHP, published 2023-11-14T20:15Z)
Description: 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.
Severity: nan (CVSS v3 base score: 5.4)
Weakness: CWE-287 \u2014 Improper Authentication
When an actor claims to have a given identity, the product does not prove or insufficiently proves that the claim is correct.
Repository: https://github.com/TYPO3/typo3
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 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>


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)