From c81cca9e419e7aaed551b9b9a8d012ba7bffb287 Mon Sep 17 00:00:00 2001 From: Oliver Hader Date: Tue, 22 Jan 2019 09:41:00 +0100 Subject: [PATCH] [SECURITY] Avoid disclosing loaded extensions Inline JavaScript settings for RequireJS and ajaxUrls disclose the existence of specific extensions in a TYPO3 installation. In case no backend user is logged in RequireJS settings are fetched using an according endpoint, ajaxUrls (for backend AJAX routes) are limited to those that are accessible without having a user session. Resolves: #83855 Releases: master, 9.5, 8.7 Security-Commit: af76c928bbe6fe05611db0839da879fce132daff Security-Bulletin: TYPO3-CORE-SA-2019-001 Change-Id: I90dddd2fd3fd81834cd40c8638fa487fa106b07c Reviewed-on: https://review.typo3.org/59520 Reviewed-by: Oliver Hader Tested-by: Oliver Hader --- .../Classes/Http/AjaxRequestHandler.php | 3 +- .../Controller/RequireJsController.php | 109 ++++++++++ .../sysext/core/Classes/Page/PageRenderer.php | 205 ++++++++++++++++-- .../core/Configuration/Backend/AjaxRoutes.php | 14 ++ .../Public/JavaScript/requirejs-loader.js | 134 ++++++++++++ typo3/sysext/core/ext_localconf.php | 1 + 6 files changed, 442 insertions(+), 24 deletions(-) create mode 100644 typo3/sysext/core/Classes/Controller/RequireJsController.php create mode 100644 typo3/sysext/core/Configuration/Backend/AjaxRoutes.php create mode 100644 typo3/sysext/core/Resources/Public/JavaScript/requirejs-loader.js diff --git a/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php b/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php index 370c8cd04232..cf6a42b27134 100644 --- a/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php +++ b/typo3/sysext/backend/Classes/Http/AjaxRequestHandler.php @@ -52,7 +52,8 @@ class AjaxRequestHandler implements RequestHandlerInterface '/ajax/logout', '/ajax/login/refresh', '/ajax/login/timedout', - '/ajax/rsa/publickey' + '/ajax/rsa/publickey', + '/ajax/core/requirejs' ]; /** diff --git a/typo3/sysext/core/Classes/Controller/RequireJsController.php b/typo3/sysext/core/Classes/Controller/RequireJsController.php new file mode 100644 index 000000000000..23b88cf80e4a --- /dev/null +++ b/typo3/sysext/core/Classes/Controller/RequireJsController.php @@ -0,0 +1,109 @@ +pageRenderer = GeneralUtility::makeInstance(PageRenderer::class); + } + + /** + * Retrieves additional requirejs configuration for a given module name or module path. + * + * The JSON result e.g. could look like: + * { + * "shim": { + * "vendor/module": ["exports" => "TheModule"] + * }, + * "paths": { + * "vendor/module": "/public/web/path/" + * }, + * "packages": { + * [ + * "name": "module", + * ... + * ] + * } + * } + * + * Parameter name either could be the module name ("vendor/module") or a + * module path ("vendor/module/component") belonging to a module. + * + * @param ServerRequestInterface $request + * @return ResponseInterface + */ + public function retrieveConfiguration(ServerRequestInterface $request): ResponseInterface + { + $name = $request->getQueryParams()['name'] ?? null; + if (empty($name) || !is_string($name)) { + return new JsonResponse(null, 404); + } + $configuration = $this->findConfiguration($name); + return new JsonResponse($configuration, !empty($configuration) ? 200 : 404); + } + + /** + * @param string $name + * @return array + */ + protected function findConfiguration(string $name): array + { + $relevantConfiguration = []; + $this->pageRenderer->loadRequireJs(); + $configuration = $this->pageRenderer->getRequireJsConfig(PageRenderer::REQUIREJS_SCOPE_RESOLVE); + + $shim = $configuration['shim'] ?? []; + foreach ($shim as $baseModuleName => $baseModuleConfiguration) { + if (strpos($name . '/', $baseModuleName . '/') === 0) { + $relevantConfiguration['shim'][$baseModuleName] = $baseModuleConfiguration; + } + } + + $paths = $configuration['paths'] ?? []; + foreach ($paths as $baseModuleName => $baseModulePath) { + if (strpos($name . '/', $baseModuleName . '/') === 0) { + $relevantConfiguration['paths'][$baseModuleName] = $baseModulePath; + } + } + + $packages = $configuration['packages'] ?? []; + foreach ($packages as $package) { + if (!empty($package['name']) + && strpos($name . '/', $package['name'] . '/') === 0 + ) { + $relevantConfiguration['packages'][] = $package; + } + } + + return $relevantConfiguration; + } +} diff --git a/typo3/sysext/core/Classes/Page/PageRenderer.php b/typo3/sysext/core/Classes/Page/PageRenderer.php index 99b5f15824f3..1885a22b693d 100644 --- a/typo3/sysext/core/Classes/Page/PageRenderer.php +++ b/typo3/sysext/core/Classes/Page/PageRenderer.php @@ -43,6 +43,9 @@ class PageRenderer implements \TYPO3\CMS\Core\SingletonInterface const JQUERY_NAMESPACE_DEFAULT = 'jQuery'; const JQUERY_NAMESPACE_DEFAULT_NOCONFLICT = 'defaultNoConflict'; + const REQUIREJS_SCOPE_CONFIG = 'config'; + const REQUIREJS_SCOPE_RESOLVE = 'resolve'; + /** * @var bool */ @@ -334,11 +337,23 @@ class PageRenderer implements \TYPO3\CMS\Core\SingletonInterface protected $addRequireJs = false; /** - * inline configuration for requireJS + * Inline configuration for requireJS (internal) * @var array */ protected $requireJsConfig = []; + /** + * Module names of internal requireJS 'paths' + * @var array + */ + protected $internalRequireJsPathModuleNames = []; + + /** + * Inline configuration for requireJS (public) + * @var array + */ + protected $publicRequireJsConfig = []; + /** * @var bool */ @@ -615,6 +630,34 @@ public function setExtJsPath($path) $this->extJsPath = $path; } + /** + * @param string $scope + * @return array + */ + public function getRequireJsConfig(string $scope = null): array + { + // return basic RequireJS configuration without shim, paths and packages + if ($scope === static::REQUIREJS_SCOPE_CONFIG) { + return array_replace_recursive( + $this->publicRequireJsConfig, + $this->filterArrayKeys( + $this->requireJsConfig, + ['shim', 'paths', 'packages'], + false + ) + ); + } + // return RequireJS configuration for resolving only shim, paths and packages + if ($scope === static::REQUIREJS_SCOPE_RESOLVE) { + return $this->filterArrayKeys( + $this->requireJsConfig, + ['shim', 'paths', 'packages'], + true + ); + } + return []; + } + /*****************************************************/ /* */ /* Public Enablers / Disablers */ @@ -1462,7 +1505,7 @@ public function loadJquery($version = null, $source = null, $namespace = self::J public function loadRequireJs() { $this->addRequireJs = true; - if (!empty($this->requireJsConfig)) { + if (!empty($this->requireJsConfig) && !empty($this->publicRequireJsConfig)) { return; } @@ -1471,13 +1514,17 @@ public function loadRequireJs() $cacheIdentifier = 'requireJS_' . md5(implode(',', $loadedExtensions) . ($isDevelopment ? ':dev' : '') . GeneralUtility::getIndpEnv('TYPO3_REQUEST_SCRIPT')); /** @var VariableFrontend $cache */ $cache = GeneralUtility::makeInstance(CacheManager::class)->getCache('assets'); - $this->requireJsConfig = $cache->get($cacheIdentifier); + $requireJsConfig = $cache->get($cacheIdentifier); // if we did not get a configuration from the cache, compute and store it in the cache - if (empty($this->requireJsConfig)) { - $this->requireJsConfig = $this->computeRequireJsConfig($isDevelopment, $loadedExtensions); - $cache->set($cacheIdentifier, $this->requireJsConfig); + if (!isset($requireJsConfig['internal']) || !isset($requireJsConfig['public']) || true) { + $requireJsConfig = $this->computeRequireJsConfig($isDevelopment, $loadedExtensions); + $cache->set($cacheIdentifier, $requireJsConfig); } + + $this->requireJsConfig = $requireJsConfig['internal']; + $this->publicRequireJsConfig = $requireJsConfig['public']; + $this->internalRequireJsPathModuleNames = $requireJsConfig['internalNames']; } /** @@ -1491,18 +1538,22 @@ public function loadRequireJs() protected function computeRequireJsConfig($isDevelopment, array $loadedExtensions) { // load all paths to map to package names / namespaces - $requireJsConfig = []; + $requireJsConfig = [ + 'public' => [], + 'internal' => [], + 'internalNames' => [], + ]; // In order to avoid browser caching of JS files, adding a GET parameter to the files loaded via requireJS if ($isDevelopment) { - $requireJsConfig['urlArgs'] = 'bust=' . $GLOBALS['EXEC_TIME']; + $requireJsConfig['public']['urlArgs'] = 'bust=' . $GLOBALS['EXEC_TIME']; } else { - $requireJsConfig['urlArgs'] = 'bust=' . GeneralUtility::hmac(TYPO3_version . PATH_site); + $requireJsConfig['public']['urlArgs'] = 'bust=' . GeneralUtility::hmac(TYPO3_version . PATH_site); } $corePath = ExtensionManagementUtility::extPath('core', 'Resources/Public/JavaScript/Contrib/'); $corePath = PathUtility::getAbsoluteWebPath($corePath); // first, load all paths for the namespaces, and configure contrib libs. - $requireJsConfig['paths'] = [ + $requireJsConfig['public']['paths'] = [ 'jquery-ui' => $corePath . 'jquery-ui', 'datatables' => $corePath . 'jquery.dataTables', 'matchheight' => $corePath . 'jquery.matchHeight-min', @@ -1518,16 +1569,31 @@ protected function computeRequireJsConfig($isDevelopment, array $loadedExtension 'jquery/autocomplete' => $corePath . 'jquery.autocomplete', 'd3' => $corePath . 'd3/d3' ]; - + $requireJsConfig['public']['typo3BaseUrl'] = false; + $publicPackageNames = ['core', 'frontend', 'backend']; foreach ($loadedExtensions as $packageName) { - $fullJsPath = 'EXT:' . $packageName . '/Resources/Public/JavaScript/'; - $fullJsPath = GeneralUtility::getFileAbsFileName($fullJsPath); - $fullJsPath = PathUtility::getAbsoluteWebPath($fullJsPath); + $jsPath = 'EXT:' . $packageName . '/Resources/Public/JavaScript/'; + $absoluteJsPath = GeneralUtility::getFileAbsFileName($jsPath); + $fullJsPath = PathUtility::getAbsoluteWebPath($absoluteJsPath); $fullJsPath = rtrim($fullJsPath, '/'); - if ($fullJsPath) { - $requireJsConfig['paths']['TYPO3/CMS/' . GeneralUtility::underscoredToUpperCamelCase($packageName)] = $fullJsPath; + if (!empty($fullJsPath) && file_exists($absoluteJsPath)) { + $type = in_array($packageName, $publicPackageNames, true) ? 'public' : 'internal'; + $requireJsConfig[$type]['paths']['TYPO3/CMS/' . GeneralUtility::underscoredToUpperCamelCase($packageName)] = $fullJsPath; } } + // sanitize module names in internal 'paths' + $internalPathModuleNames = array_keys($requireJsConfig['internal']['paths'] ?? []); + $sanitizedInternalPathModuleNames = array_map( + function ($moduleName) { + // trim spaces and slashes & add ending slash + return trim($moduleName, ' /') . '/'; + }, + $internalPathModuleNames + ); + $requireJsConfig['internalNames'] = array_combine( + $sanitizedInternalPathModuleNames, + $internalPathModuleNames + ); // check if additional AMD modules need to be loaded if a single AMD module is initialized if (is_array($GLOBALS['TYPO3_CONF_VARS']['SC_OPTIONS']['RequireJS']['postInitializationModules'])) { @@ -1561,6 +1627,72 @@ public function addRequireJsConfiguration(array $configuration) \TYPO3\CMS\Core\Utility\ArrayUtility::mergeRecursiveWithOverrule($this->requireJsConfig, $configuration); } + /** + * Generates RequireJS loader HTML markup. + * + * @return string + * @throws \TYPO3\CMS\Backend\Routing\Exception\RouteNotFoundException + */ + protected function getRequireJsLoader(): string + { + $html = ''; + $backendRequest = TYPO3_REQUESTTYPE & TYPO3_REQUESTTYPE_BE; + $backendUserLoggedIn = !empty($GLOBALS['BE_USER']->user['uid']); + + // no backend request - basically frontend + if (!$backendRequest) { + $requireJsConfig = $this->getRequireJsConfig(static::REQUIREJS_SCOPE_CONFIG); + $requireJsConfig['typo3BaseUrl'] = GeneralUtility::getIndpEnv('TYPO3_SITE_PATH') . '?eID=requirejs'; + // backend request, but no backend user logged in + } elseif (!$backendUserLoggedIn) { + $uriBuilder = GeneralUtility::makeInstance(UriBuilder::class); + $requireJsConfig = $this->getRequireJsConfig(static::REQUIREJS_SCOPE_CONFIG); + $requireJsConfig['typo3BaseUrl'] = (string)$uriBuilder->buildUriFromRoute('ajax_core_requirejs'); + // backend request, having backend user logged in + } else { + $requireJsConfig = array_replace_recursive( + $this->publicRequireJsConfig, + $this->requireJsConfig + ); + } + + // add (probably filtered) RequireJS configuration + $html .= GeneralUtility::wrapJS('var require = ' . json_encode($requireJsConfig)) . LF; + // directly after that, include the require.js file + $html .= '' . LF; + + if (!empty($requireJsConfig['typo3BaseUrl'])) { + $html .= '' . LF; + } + + return $html; + } + + /** + * @param array $array + * @param string[] $keys + * @param bool $keep + * @return array + */ + protected function filterArrayKeys(array $array, array $keys, bool $keep = true): array + { + return array_filter( + $array, + function (string $key) use ($keys, $keep) { + return in_array($key, $keys, true) === $keep; + }, + ARRAY_FILTER_USE_KEY + ); + } + /** * includes an AMD-compatible JS file by resolving the ModuleName, and then requires the file via a requireJS request, * additionally allowing to execute JavaScript code afterwards @@ -1582,7 +1714,13 @@ public function loadRequireJsModule($mainModuleName, $callBackFunction = null) $inlineCodeKey = $mainModuleName; // make sure requireJS is initialized $this->loadRequireJs(); - + // move internal module path definition to public module definition + // (since loading a module ends up disclosing the existence anyway) + $baseModuleName = $this->findRequireJsBaseModuleName($mainModuleName); + if ($baseModuleName !== null && isset($this->requireJsConfig['paths'][$baseModuleName])) { + $this->publicRequireJsConfig['paths'][$baseModuleName] = $this->requireJsConfig['paths'][$baseModuleName]; + unset($this->requireJsConfig['paths'][$baseModuleName]); + } // execute the main module, and load a possible callback function $javaScriptCode = 'require(["' . $mainModuleName . '"]'; if ($callBackFunction !== null) { @@ -1614,6 +1752,24 @@ public function enableExtJsDebug() $this->enableExtJsDebug = true; } + /** + * Determines requireJS base module name (if defined). + * + * @param string $moduleName + * @return string|null + */ + protected function findRequireJsBaseModuleName(string $moduleName) + { + // trim spaces and slashes & add ending slash + $sanitizedModuleName = trim($moduleName, ' /') . '/'; + foreach ($this->internalRequireJsPathModuleNames as $sanitizedBaseModuleName => $baseModuleName) { + if (strpos($sanitizedModuleName, $sanitizedBaseModuleName) === 0) { + return $baseModuleName; + } + } + return null; + } + /** * Adds Javascript Inline Label. This will occur in TYPO3.lang - object * The label can be used in scripts with TYPO3.lang. @@ -1984,10 +2140,7 @@ protected function renderMainJavaScriptLibraries() // Include RequireJS if ($this->addRequireJs) { - // load the paths of the requireJS configuration - $out .= GeneralUtility::wrapJS('var require = ' . json_encode($this->requireJsConfig)) . LF; - // directly after that, include the require.js file - $out .= '' . LF; + $out .= $this->getRequireJsLoader(); } // Include jQuery Core for each namespace, depending on the version and source @@ -2023,7 +2176,8 @@ protected function renderMainJavaScriptLibraries() } $this->loadJavaScriptLanguageStrings(); if (TYPO3_MODE === 'BE') { - $this->addAjaxUrlsToInlineSettings(); + $noBackendUserLoggedIn = empty($GLOBALS['BE_USER']->user['uid']); + $this->addAjaxUrlsToInlineSettings($noBackendUserLoggedIn); } $inlineSettings = $this->inlineLanguageLabels ? 'TYPO3.lang = ' . json_encode($this->inlineLanguageLabels) . ';' : ''; $inlineSettings .= $this->inlineSettings ? 'TYPO3.settings = ' . json_encode($this->inlineSettings) . ';' : ''; @@ -2116,8 +2270,10 @@ protected function convertCharsetRecursivelyToUtf8(&$data, string $fromCharset) /** * Make URLs to all backend ajax handlers available as inline setting. + * + * @param bool $publicRoutesOnly */ - protected function addAjaxUrlsToInlineSettings() + protected function addAjaxUrlsToInlineSettings(bool $publicRoutesOnly = false) { $ajaxUrls = []; // Note: this method of adding Ajax URLs is @deprecated as of TYPO3 v8, and will be removed in TYPO3 v9 @@ -2132,6 +2288,9 @@ protected function addAjaxUrlsToInlineSettings() $router = GeneralUtility::makeInstance(Router::class); $routes = $router->getRoutes(); foreach ($routes as $routeIdentifier => $route) { + if ($publicRoutesOnly && $route->getOption('access') !== 'public') { + continue; + } if ($route->getOption('ajax')) { $uri = (string)$uriBuilder->buildUriFromRoute($routeIdentifier); // use the shortened value in order to use this in JavaScript diff --git a/typo3/sysext/core/Configuration/Backend/AjaxRoutes.php b/typo3/sysext/core/Configuration/Backend/AjaxRoutes.php new file mode 100644 index 000000000000..7a8faaecea16 --- /dev/null +++ b/typo3/sysext/core/Configuration/Backend/AjaxRoutes.php @@ -0,0 +1,14 @@ + [ + 'path' => '/core/requirejs', + 'access' => 'public', + 'target' => RequireJsController::class . '::retrieveConfiguration', + ], +]; diff --git a/typo3/sysext/core/Resources/Public/JavaScript/requirejs-loader.js b/typo3/sysext/core/Resources/Public/JavaScript/requirejs-loader.js new file mode 100644 index 000000000000..e0a000af30ac --- /dev/null +++ b/typo3/sysext/core/Resources/Public/JavaScript/requirejs-loader.js @@ -0,0 +1,134 @@ +(function(req) { + /** + * Determines whether moduleName is configured in requirejs paths + * (this code was taken from RequireJS context.nameToUrl). + * + * @see context.nameToUrl + * @see https://github.com/requirejs/requirejs/blob/2.3.3/require.js#L1650-L1670 + * + * @param {Object} config the require context to find state. + * @param {String} moduleName the name of the module. + * @return {boolean} + */ + var inPath = function(config, moduleName) { + var i, parentModule, parentPath; + var paths = config.paths; + var syms = moduleName.split('/'); + //For each module name segment, see if there is a path + //registered for it. Start with most specific name + //and work up from it. + for (i = syms.length; i > 0; i -= 1) { + parentModule = syms.slice(0, i).join('/'); + parentPath = paths[parentModule]; + if (parentPath) { + return true; + } + } + return false; + }; + + /** + * @return {XMLHttpRequest} + */ + var createXhr = function() { + if (typeof XMLHttpRequest !== 'undefined') { + return new XMLHttpRequest(); + } else { + return new ActiveXObject('Microsoft.XMLHTTP'); + } + }; + + /** + * Fetches RequireJS configuration from server via XHR call. + * + * @param {object} config + * @param {string} name + * @param {function} success + * @param {function} error + */ + var fetchConfiguration = function(config, name, success, error) { + // cannot use jQuery here which would be loaded via RequireJS... + var xhr = createXhr(); + xhr.onreadystatechange = function() { + if (this.readyState !== 4) { + return; + } + try { + if (this.status === 200) { + success(JSON.parse(xhr.responseText)); + } else { + error(this.status, xhr.statusText); + } + } catch (error) { + error(this.status, error); + } + }; + xhr.open('GET', config.typo3BaseUrl + '&name=' + encodeURIComponent(name)); + xhr.send(); + }; + + /** + * Adds aspects to RequireJS configuration keys paths and packages. + * + * @param {object} config + * @param {string} data + * @param {object} context + */ + var addToConfiguration = function(config, data, context) { + if (data.shim && data.shim instanceof Object) { + if (typeof config.shim === 'undefined') { + config.shim = {}; + } + Object.keys(data.shim).forEach(function(moduleName) { + config.shim[moduleName] = data.shim[moduleName]; + }); + } + if (data.paths && data.paths instanceof Object) { + if (typeof config.paths === 'undefined') { + config.paths = {}; + } + Object.keys(data.paths).forEach(function(moduleName) { + config.paths[moduleName] = data.paths[moduleName]; + }); + } + if (data.packages && data.packages instanceof Array) { + if (typeof config.packages === 'undefined') { + config.packages = []; + } + data.packages.forEach(function (packageName) { + config.packages.push(packageName); + }); + } + context.configure(config); + }; + + // keep reference to RequireJS default loader + var originalLoad = req.load; + + /** + * Does the request to load a module for the browser case. + * Make this a separate function to allow other environments + * to override it. + * + * @param {Object} context the require context to find state. + * @param {String} name the name of the module. + * @param {Object} url the URL to the module. + */ + req.load = function(context, name, url) { + if (inPath(context.config, name)) { + return originalLoad.call(req, context, name, url); + } + + fetchConfiguration( + context.config, + name, + function(data) { + addToConfiguration(context.config, data, context); + url = context.nameToUrl(name); + // result cannot be returned since nested in two asynchronous calls + originalLoad.call(req, context, name, url); + }, + function() {} + ); + }; +})(requirejs); diff --git a/typo3/sysext/core/ext_localconf.php b/typo3/sysext/core/ext_localconf.php index 26d5749b986e..41cc3b579a0e 100644 --- a/typo3/sysext/core/ext_localconf.php +++ b/typo3/sysext/core/ext_localconf.php @@ -87,6 +87,7 @@ unset($signalSlotDispatcher); $GLOBALS['TYPO3_CONF_VARS']['FE']['eID_include']['dumpFile'] = \TYPO3\CMS\Core\Controller\FileDumpController::class . '::dumpAction'; +$GLOBALS['TYPO3_CONF_VARS']['FE']['eID_include']['requirejs'] = \TYPO3\CMS\Core\Controller\RequireJsController::class . '::retrievePath'; /** @var \TYPO3\CMS\Core\Resource\Rendering\RendererRegistry $rendererRegistry */ $rendererRegistry = \TYPO3\CMS\Core\Resource\Rendering\RendererRegistry::getInstance();