CVE-2023-24814 (PHP, published 2023-02-07T19:15Z)
Description: value: TYPO3 is a free and open source Content Management Framework released under the GNU General Public License. In affected versions the TYPO3 core component `GeneralUtility::getIndpEnv()` uses the unfiltered server environment variable `PATH_INFO`, which allows attackers to inject malicious content. In combination with the TypoScript setting `config.absRefPrefix=auto`, attackers can inject malicious HTML code to pages that have not been rendered and cached, yet. As a result, injected values would be cached and delivered to other website visitors (persisted cross-site scripting). Individual code which relies on the resolved value of `GeneralUtility::getIndpEnv('SCRIPT_NAME')` and corresponding usages (as shown below) are vulnerable as well. Additional investigations confirmed that at least Apache web server deployments using CGI (FPM, FCGI/FastCGI, and similar) are affected. However, there still might be the risk that other scenarios like nginx, IIS, or Apache/mod_php are vulnerable. The usage of server environment variable `PATH_INFO` has been removed from corresponding processings in `GeneralUtility::getIndpEnv()`. Besides that, the public property `TypoScriptFrontendController::$absRefPrefix` is encoded for both being used as a URI component and for being used as a prefix in an HTML context. This mitigates the cross-site scripting vulnerability. Users are advised to update to TYPO3 versions 8.7.51 ELTS, 9.5.40 ELTS, 10.4.35 LTS, 11.5.23 LTS and 12.2.0 which fix this problem. For users who are unable to patch in a timely manner the TypoScript setting `config.absRefPrefix` should at least be set to a static path value, instead of using auto - e.g. `config.absRefPrefix=/`. This workaround **does not fix all aspects of the vulnerability**, and is just considered to be an intermediate mitigation to the most prominent manifestation.
Severity: nan (CVSS v3 base score: 6.1)
Weakness: CWE-79 \u2014 Improper Neutralization of Input During Web Page Generation ('Cross-site Scripting')
The product does not neutralize or incorrectly neutralizes user-controllable input before it is placed in output that is used as a web page that is served to other users.
Repository: https://github.com/TYPO3/typo3
Files changed:
typo3/sysext/core/Classes/Core/SystemEnvironmentBuilder.php
typo3/sysext/core/Classes/Http/NormalizedParams.php
typo3/sysext/core/Classes/Utility/GeneralUtility.php
typo3/sysext/core/Tests/Acceptance/Support/Extension/ApplicationEnvironment.php
typo3/sysext/core/Tests/Unit/Http/NormalizedParamsTest.php
typo3/sysext/core/Tests/Unit/Log/Processor/WebProcessorTest.php
typo3/sysext/felogin/Tests/Unit/Validation/RedirectUrlValidatorTest.php
typo3/sysext/frontend/Classes/Controller/TypoScriptFrontendController.php
Commit message: [SECURITY] Prevent XSS due to wrong PATH_INFO evaluation

As already started in #88304 (but only for NormalizedParams)
and later reverted in #89312 (because of cgi-bin problems),
PATH_INFO is no longer considered as a preferable SCRIPT_NAME
alternative. All known server configurations set SCRIPT_NAME
these days to a proper value when cgi.fix_pathinfo is set.

The fallback to PATH_INFO has been introduced with
the initial revision of TYPO3 and isn't needed at all nowadays,
it's actually wrong, as a REQUEST_URI like /index.php/foo/bar
would incorrectly be interpreted as $scriptName == "/foo/bar",
which let's all calculations on $scriptName fail and
even leads to XSS where values derived from $scriptName are
printed without being escaped.

Also any ORIG_SCRIPT_NAME evaluation is dropped, as this variable
contains the SCRIPT_NAME that was set by the webserver configuration
before PHP applied cgi.fix_pathinfo. Using ORIG_SCRIPT_NAME
effectively meant bypassing PHP's pathinfo fix. It usually contains
the cgi-wrapper paths, which is why PATH_INFO was used to overrule
wrong ORIG_SCRIPT_NAME values.

GeneralUtility::getIndpEnv('PATH_INFO') is adapted to trust the
servers PATH_INFO information, now that we no longer allow
servers to send SCRIPT_NAME as PATH_INFO (we enforce
cgi.fix_pathinfo=1 for CGI installations).

The normalized SCRIPT_NAME is now adapted to be encoded as a URL
path by default, as all TYPO3 usages expect this to be an URL path.
Note that $_SERVER['SCRIPT_NAME'] refers to the servers file
system path, not the URL encoded value.

This SCRIPT_NAME sanitization actually enables:

a) TYPO3 to be run in a subfolder that contains characters
that need URL encoding
e.g. `/test:site/` – url encoded that'd be `/test3Asite/`.

b) prevention of XSS in case third party extensions
missed to escape any URL that is derived from SCRIPT_NAME
(while making sure that properly escaped
output is not double escaped)

Resolves: #99651
Related: #88304
Related: #89312
Releases: main, 11.5, 10.4
Change-Id: Ief95253d764665db5182a15ce8ffd02ea02ee61e
Security-Bulletin: TYPO3-CORE-SA-2023-001
Security-References: CVE-2023-24814
Reviewed-on: https://review.typo3.org/c/Packages/TYPO3.CMS/+/77739
Tested-by: Oliver Hader <oliver.hader@typo3.org>
Reviewed-by: Oliver Hader <oliver.hader@typo3.org>


Vulnerable code:
     * Calculate path to entry script if not in cli mode.
     *
     * Depending on the environment, the script path is found in different $_SERVER variables.
        $isCgi = Environment::isRunningOnCgiServer();
        if ($isCgi && Environment::usesCgiFixPathInfo()) {
            return $_SERVER['SCRIPT_FILENAME'];
        }
        $cgiPath = $_SERVER['ORIG_PATH_TRANSLATED'] ?? $_SERVER['PATH_TRANSLATED'] ?? '';
        if ($cgiPath && $isCgi) {
            return $cgiPath;
        return $_SERVER['ORIG_SCRIPT_FILENAME'] ?? $_SERVER['SCRIPT_FILENAME'];
        $scriptName = $this->scriptName = self::determineScriptName(
        $this->documentRoot = self::determineDocumentRoot($scriptName, $scriptFilename);
        // see https://forge.typo3.org/issues/89312
        // When using a CGI wrapper to dispatch the PHP process `ORIG_SCRIPT_NAME`
        // contains the name of the wrapper script (which is most probably outside
        // the TYPO3's project root) and leads to invalid prefixes, e.g. resolving
        // the `siteUrl` incorrectly as `http://ip10.local/fcgi/` instead of
        // actual `http://ip10.local/`
        $possiblePathInfo = ($serverParams['ORIG_PATH_INFO'] ?? '') ?: ($serverParams['PATH_INFO'] ?? '');
        $possibleScriptName = ($serverParams['ORIG_SCRIPT_NAME'] ?? '') ?: ($serverParams['SCRIPT_NAME'] ?? '');
        $scriptName = Environment::isRunningOnCgiServer() && $possiblePathInfo
            ? $possiblePathInfo
            : $possibleScriptName;
     * @param string $scriptName Entry script path of URI, without domain and without query parameters, with leading /
    protected static function determineDocumentRoot(string $scriptName, string $scriptFilename): string
        $scriptNameArray = explode('/', strrev($scriptName));
                $retVal = Environment::isRunningOnCgiServer()
                    && (($_SERVER['ORIG_PATH_INFO'] ?? false) ?: ($_SERVER['PATH_INFO'] ?? false))
                        ? (($_SERVER['ORIG_PATH_INFO'] ?? '') ?: ($_SERVER['PATH_INFO'] ?? ''))
                        : (($_SERVER['ORIG_SCRIPT_NAME'] ?? '') ?: ($_SERVER['SCRIPT_NAME'] ?? ''));
                // $_SERVER['PATH_INFO'] != $_SERVER['SCRIPT_NAME'] is necessary because some servers (Windows/CGI)
                // are seen to set PATH_INFO equal to script_name
                // Further, there must be at least one '/' in the path - else the PATH_INFO value does not make sense.
                // IF 'PATH_INFO' never works for our purpose in TYPO3 with CGI-servers,
                // then 'PHP_SAPI=='cgi'' might be a better check.
                // Right now strcmp($_SERVER['PATH_INFO'], GeneralUtility::getIndpEnv('SCRIPT_NAME')) will always
                // return FALSE for CGI-versions, but that is only as long as SCRIPT_NAME is set equal to PATH_INFO
                // because of PHP_SAPI=='cgi' (see above)
                if (!Environment::isRunningOnCgiServer()) {
                    $retVal = $_SERVER['PATH_INFO'] ?? '';
                }
                $SN_A = explode('/', strrev(self::getIndpEnv('SCRIPT_NAME')));
            'PATH_TRANSLATED' => $docRoot . '/index.php',
            'use ORIG_SCRIPT_NAME if ORIG_PATH_INFO is set but empty' => [
                [
                    'ORIG_PATH_INFO' => '',
                    'PATH_INFO' => '',
                    'ORIG_SCRIPT_NAME' => '/orig/script/name.php',
                    'SCRIPT_NAME' => '/script/name.php',
                ],
                [],
                '/orig/script/name.php',
            ],
            'use ORIG_SCRIPT_NAME if PATH_INFO is set but empty' => [
                [
                    'PATH_INFO' => '',
                    'ORIG_SCRIPT_NAME' => '/orig/script/name.php',
                    'SCRIPT_NAME' => '/script/name.php',
                ],
                [],
                '/orig/script/name.php',
            ],
            'use SCRIPT_NAME if ORIG_PATH_INFO is set but empty' => [
                [
                    'ORIG_PATH_INFO' => '',
                    'PATH_INFO' => '',
                    'ORIG_SCRIPT_NAME' => '',
                    'SCRIPT_NAME' => '/script/name.php',
                ],
                [],
                '/script/name.php',
            ],
            'use SCRIPT_NAME if PATH_INFO is set but empty' => [
                [
                    'PATH_INFO' => '',
                    'ORIG_SCRIPT_NAME' => '',
                    'SCRIPT_NAME' => '/script/name.php',
                ],
                [],
                '/script/name.php',
            ],
            'use SCRIPT_NAME if ORIG_PATH_INFO is set' => [
                [
                    'ORIG_PATH_INFO' => '/foo/bar',
                    'PATH_INFO' => '',
                    'ORIG_SCRIPT_NAME' => '',
                    'SCRIPT_NAME' => '/script/name.php',
                ],
                [],
                '/script/name.php',
            ],
            'use SCRIPT_NAME if PATH_INFO is set' => [
                    'PATH_INFO' => '/foo/bar',
                    'ORIG_SCRIPT_NAME' => '',
            'use ORIG_SCRIPT_NAME' => [
                [
                    'ORIG_SCRIPT_NAME' => '/orig/script/name.php',
                    'SCRIPT_NAME' => '/script/name.php',
                ],
                [],
                '/orig/script/name.php',
            ],
            'use SCRIPT_NAME' => [
                    'SCRIPT_NAME' => '/script/name.php',
                '/script/name.php',
            'PATH_INFO' => '/typo3/index.php',
                    'SCRIPT_NAME' => '/typo3/index.php?id=42&foo=bar',
                    'SCRIPT_NAME' => '/some/sub/dir/typo3/index.php?id=42&foo=bar',
            'PATH_INFO' => '/typo3/index.php',
        $expected = '/typo3/index.php';
        $_SERVER['PATH_INFO'] = '';
        $_SERVER['ORIG_SCRIPT_NAME'] = '';
        $_SERVER['ORIG_PATH_INFO'] = $_SERVER['PATH_INFO'] = $_SERVER['ORIG_SCRIPT_NAME'] = $_SERVER['SCRIPT_NAME'] = $this->testSitePath . 'index.php';
            '"' . $this->absRefPrefix . '_assets/',
            '"' . $this->absRefPrefix . 'typo3temp/',
            '"' . $this->absRefPrefix . PathUtility::stripPathSitePrefix(Environment::getExtensionsPath()) . '/',
            '"' . $this->absRefPrefix . PathUtility::stripPathSitePrefix(Environment::getFrameworkBasePath()) . '/',
            $replace[] = '"' . $this->absRefPrefix . $directory;

Fixed code:
     * Return path to entry script if not in cli mode.
        if (Environment::isRunningOnCgiServer() && !Environment::usesCgiFixPathInfo()) {
            throw new \Exception('TYPO3 does only support being used with cgi.fix_pathinfo=1 on CGI server APIs.', 1675108421);

        return $_SERVER['SCRIPT_FILENAME'];
        $scriptNameOnFileSystem = self::determineScriptName(
        $scriptName = $this->scriptName = self::encodeFileSystemPathComponentForUrlPath($scriptNameOnFileSystem);
        $this->documentRoot = self::determineDocumentRoot($scriptNameOnFileSystem, $scriptFilename);
    private static function encodeFileSystemPathComponentForUrlPath(string $path): string
    {
        return implode('/', array_map('rawurlencode', explode('/', $path)));
    }

        $scriptName = $serverParams['SCRIPT_NAME'] ?? '';
     * @param string $scriptNameOnFileSystem Entry script path of URI on file system, without domain and without query parameters, with leading /
    protected static function determineDocumentRoot(string $scriptNameOnFileSystem, string $scriptFilename): string
        $scriptNameArray = explode('/', strrev($scriptNameOnFileSystem));
                $retVal = $_SERVER['SCRIPT_NAME'] ?? '';
                $retVal = self::encodeFileSystemPathComponentForUrlPath($retVal);
                $retVal = $_SERVER['PATH_INFO'] ?? '';
                // Use rawurldecode to reverse the result of self::encodeFileSystemPathComponentForUrlPath()
                // which has been applied to getIndpEnv(SCRIPT_NAME) for web URI usage.
                // We compare with a file system path (SCRIPT_FILENAME) in here and therefore need to undo the encoding.
                $SN_A = array_map('rawurldecode', explode('/', strrev(self::getIndpEnv('SCRIPT_NAME'))));
    protected static function encodeFileSystemPathComponentForUrlPath(string $path): string
    {
        return implode('/', array_map('rawurlencode', explode('/', $path)));
    }

            'use SCRIPT_NAME' => [
            'apply URL encoding to SCRIPT_NAME' => [
                    'SCRIPT_NAME' => '/test:site/script/name.php',
                '/test%3Asite/script/name.php',
            'use query string and script name in special subdirectory if REQUEST_URI is not set' => [
                [
                    'QUERY_STRING' => 'parameter=foo/bar&id=42',
                    'SCRIPT_NAME' => '/sub:dir/typo3/index.php',
                ],
                [],
                '/sub%3Adir/typo3/index.php?parameter=foo/bar&id=42',
            ],
                    'SCRIPT_NAME' => '/typo3/index.php',
                    'REQUEST_URI' => '/typo3/index.php?id=42&foo=bar',
                    'SCRIPT_NAME' => '/some/sub/dir/typo3/index.php',
                    'REQUEST_URI' => '/some/sub/dir/typo3/index.php?id=42&foo=bar',
            'PATH_INFO' => '/foo/bar',
        $expected = '/foo/bar';
        $_SERVER['SCRIPT_NAME'] = '';
        $_SERVER['SCRIPT_NAME'] = $this->testSitePath . 'index.php';
        $encodedAbsRefPrefix = htmlspecialchars($this->absRefPrefix, ENT_QUOTES | ENT_HTML5);
            '"' . $encodedAbsRefPrefix . '_assets/',
            '"' . $encodedAbsRefPrefix . 'typo3temp/',
            '"' . $encodedAbsRefPrefix . PathUtility::stripPathSitePrefix(Environment::getExtensionsPath()) . '/',
            '"' . $encodedAbsRefPrefix . PathUtility::stripPathSitePrefix(Environment::getFrameworkBasePath()) . '/',
            $replace[] = '"' . $encodedAbsRefPrefix . $directory;