From 6d62cf239e1983a9fe47d210dbf34fdb2796a66c Mon Sep 17 00:00:00 2001
From: Alex Pott <alex.a.pott@googlemail.com>
Date: Thu, 26 Jan 2023 09:17:16 +0000
Subject: [PATCH] Issue #3303067 by catch, nod_, Wim Leers, olli, alexpott:
 Compress aggregate URL query strings

---
 .../Drupal/Component/Utility/UrlHelper.php    | 43 +++++++++++
 .../Core/Asset/CssCollectionOptimizerLazy.php | 16 +++--
 .../Core/Asset/JsCollectionOptimizerLazy.php  | 17 +++--
 .../src/Controller/AssetControllerBase.php    | 18 ++++-
 .../Asset/AssetOptimizationTest.php           | 71 ++++++++++++++++++-
 .../Tests/Component/Utility/UrlHelperTest.php | 24 +++++++
 6 files changed, 171 insertions(+), 18 deletions(-)

diff --git a/core/lib/Drupal/Component/Utility/UrlHelper.php b/core/lib/Drupal/Component/Utility/UrlHelper.php
index 79e275bdc05f..ef118716d0b8 100644
--- a/core/lib/Drupal/Component/Utility/UrlHelper.php
+++ b/core/lib/Drupal/Component/Utility/UrlHelper.php
@@ -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.
    *
diff --git a/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php
index a54c33cb9272..34bfaad71c7f 100644
--- a/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php
+++ b/core/lib/Drupal/Core/Asset/CssCollectionOptimizerLazy.php
@@ -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;
diff --git a/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php
index 688459a69b03..e2b315203563 100644
--- a/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php
+++ b/core/lib/Drupal/Core/Asset/JsCollectionOptimizerLazy.php
@@ -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 = [
diff --git a/core/modules/system/src/Controller/AssetControllerBase.php b/core/modules/system/src/Controller/AssetControllerBase.php
index cb83318b3917..023bc031f74a 100644
--- a/core/modules/system/src/Controller/AssetControllerBase.php
+++ b/core/modules/system/src/Controller/AssetControllerBase.php
@@ -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;
@@ -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.
@@ -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);
 
diff --git a/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php b/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php
index c7b71cd84dcd..c59daabe74ad 100644
--- a/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php
+++ b/core/tests/Drupal/FunctionalTests/Asset/AssetOptimizationTest.php
@@ -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);
 
@@ -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']);
@@ -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']);
+  }
+
 }
diff --git a/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php b/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
index c4d76e4604f5..a69954592e25 100644
--- a/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
+++ b/core/tests/Drupal/Tests/Component/Utility/UrlHelperTest.php
@@ -106,6 +106,30 @@ public function providerTestInvalidAbsolute() {
     return $this->dataEnhanceWithScheme($data);
   }
 
+  /**
+   * Tests that we get the same thing out that we put in.
+   */
+  public function testCompressUncompress() {
+    $data = [];
+    while (count($data) < 30) {
+      $data[] = 'drupal/drupal' . count($data);
+    }
+    $data = implode(',', $data);
+    $compressed = UrlHelper::compressQueryParameter($data);
+    $uncompressed = UrlHelper::uncompressQueryParameter($compressed);
+    $this->assertEquals($data, $uncompressed);
+    $this->assertLessThan(strlen($uncompressed), strlen($compressed));
+  }
+
+  /**
+   * Tests passing an invalid string as a compressed query parameter.
+   */
+  public function testUncompressInvalidString() {
+    // Pass an invalid string to ::uncompressQueryParameter() and ensure it
+    // doesn't result in a PHP warning.
+    $this->assertFalse(UrlHelper::uncompressQueryParameter('llama'));
+  }
+
   /**
    * Tests invalid absolute URLs.
    *
-- 
GitLab