From 889ed77d2905d8b17afd31c723a23240c978823f Mon Sep 17 00:00:00 2001 From: Oliver Hader Date: Tue, 22 Jan 2019 09:41:52 +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: a6275dcec387039a8a40972f3f8a8607d20fe6dd Security-Bulletin: TYPO3-CORE-SA-2019-001 Change-Id: I7be7e93b2bd67ddcb2e10863577090b34182c555 Reviewed-on: https://review.typo3.org/59527 Reviewed-by: Oliver Hader Tested-by: Oliver Hader --- .../Middleware/BackendUserAuthenticator.php | 3 +- .../Controller/RequireJsController.php | 109 +++++++++ .../sysext/core/Classes/Page/PageRenderer.php | 206 ++++++++++++++++-- .../core/Configuration/Backend/AjaxRoutes.php | 14 ++ .../Public/JavaScript/requirejs-loader.js | 134 ++++++++++++ typo3/sysext/core/ext_localconf.php | 1 + 6 files changed, 443 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/Middleware/BackendUserAuthenticator.php b/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php index d868567c4749..685e604e9273 100644 --- a/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php +++ b/typo3/sysext/backend/Classes/Middleware/BackendUserAuthenticator.php @@ -45,7 +45,8 @@ class BackendUserAuthenticator implements MiddlewareInterface '/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 303ac76463ee..5947a717a6aa 100644 --- a/typo3/sysext/core/Classes/Page/PageRenderer.php +++ b/typo3/sysext/core/Classes/Page/PageRenderer.php @@ -45,6 +45,9 @@ class PageRenderer implements \TYPO3\CMS\Core\SingletonInterface // @deprecated will be removed in TYPO3 v10.0. const JQUERY_NAMESPACE_NONE = 'none'; + const REQUIREJS_SCOPE_CONFIG = 'config'; + const REQUIREJS_SCOPE_RESOLVE = 'resolve'; + /** * @var bool */ @@ -332,11 +335,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 */ @@ -599,6 +614,34 @@ public function setRequireJsPath($path) $this->requireJsPath = $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 */ @@ -1399,7 +1442,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; } @@ -1408,13 +1451,17 @@ public function loadRequireJs() $cacheIdentifier = 'requireJS_' . md5(implode(',', $loadedExtensions) . ($isDevelopment ? ':dev' : '') . GeneralUtility::getIndpEnv('TYPO3_REQUEST_SCRIPT')); /** @var FrontendInterface $cache */ $cache = static::$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']; } /** @@ -1428,18 +1475,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 . Environment::getProjectPath()); + $requireJsConfig['public']['urlArgs'] = 'bust=' . GeneralUtility::hmac(TYPO3_version . Environment::getProjectPath()); } $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' => $corePath . '/jquery/jquery', 'jquery-ui' => $corePath . 'jquery-ui', 'datatables' => $corePath . 'jquery.dataTables', @@ -1455,16 +1506,32 @@ protected function computeRequireJsConfig($isDevelopment, array $loadedExtension 'jquery/autocomplete' => $corePath . 'jquery.autocomplete', 'd3' => $corePath . 'd3/d3' ]; - $requireJsConfig['waitSeconds'] = 30; + $requireJsConfig['public']['waitSeconds'] = 30; + $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'] ?? false)) { @@ -1498,6 +1565,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 @@ -1519,7 +1652,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) { @@ -1530,6 +1669,24 @@ public function loadRequireJsModule($mainModuleName, $callBackFunction = null) $this->addJsInlineCode('RequireJS-Module-' . $inlineCodeKey, $javaScriptCode); } + /** + * 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. @@ -1938,10 +2095,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 @@ -1953,7 +2107,8 @@ protected function renderMainJavaScriptLibraries() $this->loadJavaScriptLanguageStrings(); if (TYPO3_MODE === 'BE') { - $this->addAjaxUrlsToInlineSettings(); + $noBackendUserLoggedIn = empty($GLOBALS['BE_USER']->user['uid']); + $this->addAjaxUrlsToInlineSettings($noBackendUserLoggedIn); } $inlineSettings = ''; $languageLabels = $this->parseLanguageLabelsForJavaScript(); @@ -2031,8 +2186,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 = []; // Add the ajax-based routes @@ -2042,6 +2199,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 d441fc77f8de..3227779f1910 100644 --- a/typo3/sysext/core/ext_localconf.php +++ b/typo3/sysext/core/ext_localconf.php @@ -89,6 +89,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();