Loading core/lib/Drupal/Component/Utility/UrlHelper.php +43 −0 Original line number Diff line number Diff line Loading @@ -62,6 +62,49 @@ public static function buildQuery(array $query, $parent = '') { return implode('&', $params); } /** * Compresses a string for use in a query parameter. * * While RFC 1738 doesn't specify a maximum length for query strings, * browsers or server configurations may restrict URLs and/or query strings to * a certain length, often 1000 or 2000 characters. This method can be used to * compress a string into a URL-safe query parameter which will be shorter * than if it was used directly. * * @see \Drupal\Component\Utility\UrlHelper::uncompressQueryParameter() * * @param string $data * The data to compress. * * @return string * The data compressed into a URL-safe string. */ public static function compressQueryParameter(string $data): string { // Use 'base64url' encoding. Note that the '=' sign is only used for padding // on the right of the string, and is otherwise not part of the data. // @see https://datatracker.ietf.org/doc/html/rfc4648#section-5 // @see https://www.php.net/manual/en/function.base64-encode.php#123098 return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(gzcompress($data))); } /** * Takes a compressed parameter and converts it back to the original. * * @see \Drupal\Component\Utility\UrlHelper::compressQueryParameter() * * @param string $compressed * A string as compressed by * \Drupal\Component\Utility\UrlHelper::compressQueryParameter(). * * @return string|bool * The uncompressed data or FALSE on failure. */ public static function uncompressQueryParameter(string $compressed): string|bool { // Because this comes from user data, suppress the PHP warning that // gzcompress() throws if the base64-encoded string is invalid. return @gzuncompress(base64_decode(str_replace(['-', '_'], ['+', '/'], $compressed))); } /** * Filters a URL query parameter array to remove unwanted elements. * Loading core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php +10 −6 Original line number Diff line number Diff line Loading @@ -102,18 +102,22 @@ public function optimize(array $css_assets, array $libraries) { $css_assets[$order]['data'] = $uri; } } // Generate a URL for each group of assets, but do not process them inline, // this is done using optimizeGroup() when the asset path is requested. $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; // All asset group URLs will have exactly the same query arguments, except // for the delta, so prepare them in advance. $query_args = [ 'language' => $this->languageManager->getCurrentLanguage()->getId(), 'theme' => $this->themeManager->getActiveTheme()->getName(), 'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)), 'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))), ]; $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; if ($already_loaded) { $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)); $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded))); } // Generate a URL for each group of assets, but do not process them inline, // this is done using optimizeGroup() when the asset path is requested. foreach ($css_assets as $order => $css_asset) { if (!empty($css_asset['preprocessed'])) { $query = ['delta' => "$order"] + $query_args; Loading core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php +10 −7 Original line number Diff line number Diff line Loading @@ -110,20 +110,23 @@ public function optimize(array $js_assets, array $libraries) { } } if ($libraries) { // Generate a URL for the group, but do not process it inline, this is // done by \Drupal\system\controller\JsAssetController. $ajax_page_state = $this->requestStack->getCurrentRequest() ->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; // All group URLs have the same query arguments apart from the delta and // scope, so prepare them in advance. $language = $this->languageManager->getCurrentLanguage()->getId(); $query_args = [ 'language' => $language, 'theme' => $this->themeManager->getActiveTheme()->getName(), 'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)), 'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))), ]; $ajax_page_state = $this->requestStack->getCurrentRequest() ->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; if ($already_loaded) { $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)); $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded))); } // Generate a URL for the group, but do not process it inline, this is // done by \Drupal\system\controller\JsAssetController. foreach ($js_assets as $order => $js_asset) { if (!empty($js_asset['preprocessed'])) { $query = [ Loading core/modules/system/src/Controller/AssetControllerBase.php +16 −2 Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ namespace Drupal\system\Controller; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Asset\AssetCollectionGrouperInterface; use Drupal\Core\Asset\AssetCollectionOptimizerInterface; use Drupal\Core\Asset\AssetDumperUriInterface; Loading Loading @@ -131,6 +132,9 @@ public function deliver(Request $request, string $file_name) { if (!$request->query->has('language')) { throw new BadRequestHttpException('The language must be passed as a query argument'); } if (!$request->query->has('include')) { throw new BadRequestHttpException('The libraries to include must be passed as a query argument'); } $file_parts = explode('_', basename($file_name, '.' . $this->fileExtension), 2); // The hash is the second segment of the filename. Loading @@ -147,9 +151,19 @@ public function deliver(Request $request, string $file_name) { $this->themeManager->setActiveTheme($active_theme); $attached_assets = new AttachedAssets(); $attached_assets->setLibraries(explode(',', $request->query->get('include'))); $include_string = UrlHelper::uncompressQueryParameter($request->query->get('include')); if (!$include_string) { throw new BadRequestHttpException('The libraries to include are encoded incorrectly.'); } $attached_assets->setLibraries(explode(',', $include_string)); if ($request->query->has('exclude')) { $attached_assets->setAlreadyLoadedLibraries(explode(',', $request->query->get('exclude'))); $exclude_string = UrlHelper::uncompressQueryParameter($request->query->get('exclude')); if (!$exclude_string) { throw new BadRequestHttpException('The libraries to exclude are encoded incorrectly.'); } $attached_assets->setAlreadyLoadedLibraries(explode(',', $exclude_string)); } $groups = $this->getGroups($attached_assets, $request); Loading core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php +68 −3 Original line number Diff line number Diff line Loading @@ -116,6 +116,15 @@ protected function assertInvalidAggregates(string $url): void { $session->visit($this->omitTheme($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->omitInclude($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->invalidInclude($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->invalidExclude($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->setInvalidLibrary($url)); $this->assertSession()->statusCodeEquals(200); Loading Loading @@ -164,19 +173,21 @@ protected function replaceGroupHash(string $url): string { } /** * Replaces the 'libraries' entry in the given URL with an invalid value. * Replaces the 'include' entry in the given URL with an invalid value. * * @param string $url * The source URL. * * @return string * The URL with the 'library' query set to an invalid value. * The URL with the 'include' query set to an invalid value. */ protected function setInvalidLibrary(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); $parts['query']['libraries'] = ['system/llama']; $include = explode(',', UrlHelper::uncompressQueryParameter($parts['query']['include'])); $include[] = 'system/llama'; $parts['query']['include'] = UrlHelper::compressQueryParameter(implode(',', $include)); $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); Loading @@ -200,4 +211,58 @@ protected function omitTheme(string $url): string { return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } /** * Removes the 'include' query parameter from the given URL. * * @param string $url * The source URL. * * @return string * The URL with the 'include' parameter omitted. */ protected function omitInclude(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); unset($parts['query']['include']); $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } /** * Replaces the 'include' query parameter with an invalid value. * * @param string $url * The source URL. * * @return string * The URL with 'include' set to an arbitrary string. */ protected function invalidInclude(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); $parts['query']['include'] = 'abcdefghijklmnop'; $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } /** * Adds an invalid 'exclude' query parameter with an invalid value. * * @param string $url * The source URL. * * @return string * The URL with 'exclude' set to an arbitrary string. */ protected function invalidExclude(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); $parts['query']['exclude'] = 'abcdefghijklmnop'; $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } } Loading
core/lib/Drupal/Component/Utility/UrlHelper.php +43 −0 Original line number Diff line number Diff line Loading @@ -62,6 +62,49 @@ public static function buildQuery(array $query, $parent = '') { return implode('&', $params); } /** * Compresses a string for use in a query parameter. * * While RFC 1738 doesn't specify a maximum length for query strings, * browsers or server configurations may restrict URLs and/or query strings to * a certain length, often 1000 or 2000 characters. This method can be used to * compress a string into a URL-safe query parameter which will be shorter * than if it was used directly. * * @see \Drupal\Component\Utility\UrlHelper::uncompressQueryParameter() * * @param string $data * The data to compress. * * @return string * The data compressed into a URL-safe string. */ public static function compressQueryParameter(string $data): string { // Use 'base64url' encoding. Note that the '=' sign is only used for padding // on the right of the string, and is otherwise not part of the data. // @see https://datatracker.ietf.org/doc/html/rfc4648#section-5 // @see https://www.php.net/manual/en/function.base64-encode.php#123098 return str_replace(['+', '/', '='], ['-', '_', ''], base64_encode(gzcompress($data))); } /** * Takes a compressed parameter and converts it back to the original. * * @see \Drupal\Component\Utility\UrlHelper::compressQueryParameter() * * @param string $compressed * A string as compressed by * \Drupal\Component\Utility\UrlHelper::compressQueryParameter(). * * @return string|bool * The uncompressed data or FALSE on failure. */ public static function uncompressQueryParameter(string $compressed): string|bool { // Because this comes from user data, suppress the PHP warning that // gzcompress() throws if the base64-encoded string is invalid. return @gzuncompress(base64_decode(str_replace(['-', '_'], ['+', '/'], $compressed))); } /** * Filters a URL query parameter array to remove unwanted elements. * Loading
core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php +10 −6 Original line number Diff line number Diff line Loading @@ -102,18 +102,22 @@ public function optimize(array $css_assets, array $libraries) { $css_assets[$order]['data'] = $uri; } } // Generate a URL for each group of assets, but do not process them inline, // this is done using optimizeGroup() when the asset path is requested. $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; // All asset group URLs will have exactly the same query arguments, except // for the delta, so prepare them in advance. $query_args = [ 'language' => $this->languageManager->getCurrentLanguage()->getId(), 'theme' => $this->themeManager->getActiveTheme()->getName(), 'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)), 'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))), ]; $ajax_page_state = $this->requestStack->getCurrentRequest()->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; if ($already_loaded) { $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)); $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded))); } // Generate a URL for each group of assets, but do not process them inline, // this is done using optimizeGroup() when the asset path is requested. foreach ($css_assets as $order => $css_asset) { if (!empty($css_asset['preprocessed'])) { $query = ['delta' => "$order"] + $query_args; Loading
core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php +10 −7 Original line number Diff line number Diff line Loading @@ -110,20 +110,23 @@ public function optimize(array $js_assets, array $libraries) { } } if ($libraries) { // Generate a URL for the group, but do not process it inline, this is // done by \Drupal\system\controller\JsAssetController. $ajax_page_state = $this->requestStack->getCurrentRequest() ->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; // All group URLs have the same query arguments apart from the delta and // scope, so prepare them in advance. $language = $this->languageManager->getCurrentLanguage()->getId(); $query_args = [ 'language' => $language, 'theme' => $this->themeManager->getActiveTheme()->getName(), 'include' => implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries)), 'include' => UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($libraries))), ]; $ajax_page_state = $this->requestStack->getCurrentRequest() ->get('ajax_page_state'); $already_loaded = isset($ajax_page_state) ? explode(',', $ajax_page_state['libraries']) : []; if ($already_loaded) { $query_args['exclude'] = implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded)); $query_args['exclude'] = UrlHelper::compressQueryParameter(implode(',', $this->dependencyResolver->getMinimalRepresentativeSubset($already_loaded))); } // Generate a URL for the group, but do not process it inline, this is // done by \Drupal\system\controller\JsAssetController. foreach ($js_assets as $order => $js_asset) { if (!empty($js_asset['preprocessed'])) { $query = [ Loading
core/modules/system/src/Controller/AssetControllerBase.php +16 −2 Original line number Diff line number Diff line Loading @@ -2,6 +2,7 @@ namespace Drupal\system\Controller; use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Asset\AssetCollectionGrouperInterface; use Drupal\Core\Asset\AssetCollectionOptimizerInterface; use Drupal\Core\Asset\AssetDumperUriInterface; Loading Loading @@ -131,6 +132,9 @@ public function deliver(Request $request, string $file_name) { if (!$request->query->has('language')) { throw new BadRequestHttpException('The language must be passed as a query argument'); } if (!$request->query->has('include')) { throw new BadRequestHttpException('The libraries to include must be passed as a query argument'); } $file_parts = explode('_', basename($file_name, '.' . $this->fileExtension), 2); // The hash is the second segment of the filename. Loading @@ -147,9 +151,19 @@ public function deliver(Request $request, string $file_name) { $this->themeManager->setActiveTheme($active_theme); $attached_assets = new AttachedAssets(); $attached_assets->setLibraries(explode(',', $request->query->get('include'))); $include_string = UrlHelper::uncompressQueryParameter($request->query->get('include')); if (!$include_string) { throw new BadRequestHttpException('The libraries to include are encoded incorrectly.'); } $attached_assets->setLibraries(explode(',', $include_string)); if ($request->query->has('exclude')) { $attached_assets->setAlreadyLoadedLibraries(explode(',', $request->query->get('exclude'))); $exclude_string = UrlHelper::uncompressQueryParameter($request->query->get('exclude')); if (!$exclude_string) { throw new BadRequestHttpException('The libraries to exclude are encoded incorrectly.'); } $attached_assets->setAlreadyLoadedLibraries(explode(',', $exclude_string)); } $groups = $this->getGroups($attached_assets, $request); Loading
core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php +68 −3 Original line number Diff line number Diff line Loading @@ -116,6 +116,15 @@ protected function assertInvalidAggregates(string $url): void { $session->visit($this->omitTheme($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->omitInclude($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->invalidInclude($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->invalidExclude($url)); $this->assertSession()->statusCodeEquals(400); $session->visit($this->setInvalidLibrary($url)); $this->assertSession()->statusCodeEquals(200); Loading Loading @@ -164,19 +173,21 @@ protected function replaceGroupHash(string $url): string { } /** * Replaces the 'libraries' entry in the given URL with an invalid value. * Replaces the 'include' entry in the given URL with an invalid value. * * @param string $url * The source URL. * * @return string * The URL with the 'library' query set to an invalid value. * The URL with the 'include' query set to an invalid value. */ protected function setInvalidLibrary(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); $parts['query']['libraries'] = ['system/llama']; $include = explode(',', UrlHelper::uncompressQueryParameter($parts['query']['include'])); $include[] = 'system/llama'; $parts['query']['include'] = UrlHelper::compressQueryParameter(implode(',', $include)); $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); Loading @@ -200,4 +211,58 @@ protected function omitTheme(string $url): string { return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } /** * Removes the 'include' query parameter from the given URL. * * @param string $url * The source URL. * * @return string * The URL with the 'include' parameter omitted. */ protected function omitInclude(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); unset($parts['query']['include']); $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } /** * Replaces the 'include' query parameter with an invalid value. * * @param string $url * The source URL. * * @return string * The URL with 'include' set to an arbitrary string. */ protected function invalidInclude(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); $parts['query']['include'] = 'abcdefghijklmnop'; $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } /** * Adds an invalid 'exclude' query parameter with an invalid value. * * @param string $url * The source URL. * * @return string * The URL with 'exclude' set to an arbitrary string. */ protected function invalidExclude(string $url): string { // First replace the hash, so we don't get served the actual file on disk. $url = $this->replaceGroupHash($url); $parts = UrlHelper::parse($url); $parts['query']['exclude'] = 'abcdefghijklmnop'; $query = UrlHelper::buildQuery($parts['query']); return $this->getAbsoluteUrl($parts['path'] . '?' . $query . '#' . $parts['fragment']); } }