From 3a8b4eee946f6e8905456f03ee5dd91fc0fb4a56 Mon Sep 17 00:00:00 2001
From: Nathaniel Catchpole <catch@35733.no-reply.drupal.org>
Date: Wed, 19 Oct 2016 10:54:55 +0100
Subject: [PATCH] Issue #2809789 by pwolanin, dawehner: UrlGenerator fails to
 urlencode aliases, incorrectly encodes "system" path

---
 .../lib/Drupal/Core/Routing/NullGenerator.php |  2 +-
 .../Core/Routing/RedirectDestination.php      |  2 +-
 core/lib/Drupal/Core/Routing/UrlGenerator.php | 95 ++++++++-----------
 .../Core/Routing/UrlGeneratorInterface.php    |  3 +-
 .../LanguageNegotiationContentEntity.php      | 15 +--
 .../LanguageNegotiationSession.php            |  5 -
 .../src/Tests/Path/UrlAlterFunctionalTest.php |  5 +
 .../path_encoded_test.info.yml                |  6 ++
 .../path_encoded_test.routing.yml             | 23 +++++
 .../Controller/PathEncodedTestController.php  | 21 ++++
 .../url_alter_test/src/PathProcessorTest.php  |  5 +
 .../Routing/PathEncodedTest.php               | 54 +++++++++++
 .../KernelTests/Core/Path/UrlAlterTest.php    | 28 ++++++
 .../Core/Routing/RedirectDestinationTest.php  |  3 +-
 .../Tests/Core/Routing/UrlGeneratorTest.php   | 35 +++++++
 15 files changed, 225 insertions(+), 77 deletions(-)
 create mode 100644 core/modules/system/tests/modules/path_encoded_test/path_encoded_test.info.yml
 create mode 100644 core/modules/system/tests/modules/path_encoded_test/path_encoded_test.routing.yml
 create mode 100644 core/modules/system/tests/modules/path_encoded_test/src/Controller/PathEncodedTestController.php
 create mode 100644 core/tests/Drupal/FunctionalTests/Routing/PathEncodedTest.php
 create mode 100644 core/tests/Drupal/KernelTests/Core/Path/UrlAlterTest.php

diff --git a/core/lib/Drupal/Core/Routing/NullGenerator.php b/core/lib/Drupal/Core/Routing/NullGenerator.php
index d9bc9b048e05..9d6db83d0fa0 100644
--- a/core/lib/Drupal/Core/Routing/NullGenerator.php
+++ b/core/lib/Drupal/Core/Routing/NullGenerator.php
@@ -52,7 +52,7 @@ protected function processRoute($name, Route $route, array &$parameters, Bubblea
   /**
    * {@inheritdoc}
    */
-  protected function getInternalPathFromRoute($name, Route $route, $parameters = array(), $query_params = array()) {
+  protected function getInternalPathFromRoute($name, Route $route, $parameters = array(), &$query_params = array()) {
     return $route->getPath();
   }
 
diff --git a/core/lib/Drupal/Core/Routing/RedirectDestination.php b/core/lib/Drupal/Core/Routing/RedirectDestination.php
index 873694a35574..1945e333c146 100644
--- a/core/lib/Drupal/Core/Routing/RedirectDestination.php
+++ b/core/lib/Drupal/Core/Routing/RedirectDestination.php
@@ -64,7 +64,7 @@ public function get() {
         $this->destination = $query->get('destination');
       }
       else {
-        $this->destination = $this->urlGenerator->generateFromRoute('<current>', [], ['query' => UrlHelper::buildQuery(UrlHelper::filterQueryParameters($query->all()))]);
+        $this->destination = $this->urlGenerator->generateFromRoute('<current>', [], ['query' => UrlHelper::filterQueryParameters($query->all())]);
       }
     }
 
diff --git a/core/lib/Drupal/Core/Routing/UrlGenerator.php b/core/lib/Drupal/Core/Routing/UrlGenerator.php
index ccf2b60d896f..b13d3dbbc915 100644
--- a/core/lib/Drupal/Core/Routing/UrlGenerator.php
+++ b/core/lib/Drupal/Core/Routing/UrlGenerator.php
@@ -149,12 +149,14 @@ public function getPathFromRoute($name, $parameters = array()) {
    *   The route parameters passed to the generator. Parameters that do not
    *   match any variables will be added to the result as query parameters.
    * @param array $query_params
-   *   Query parameters passed to the generator as $options['query'].
+   *   Query parameters passed to the generator as $options['query']. This may
+   *   be modified if there are extra parameters not used as route variables.
    * @param string $name
    *   The route name or other identifying string from ::getRouteDebugMessage().
    *
    * @return string
-   *   The url path, without any base path, including possible query string.
+   *   The url path, without any base path, without the query string, not URL
+   *   encoded.
    *
    * @throws MissingMandatoryParametersException
    *   When some parameters are missing that are mandatory for the route.
@@ -162,7 +164,7 @@ public function getPathFromRoute($name, $parameters = array()) {
    *   When a parameter value for a placeholder is not correct because it does
    *   not match the requirement.
    */
-  protected function doGenerate(array $variables, array $defaults, array $tokens, array $parameters, array $query_params, $name) {
+  protected function doGenerate(array $variables, array $defaults, array $tokens, array $parameters, array &$query_params, $name) {
     $variables = array_flip($variables);
     $mergedParams = array_replace($defaults, $this->context->getParameters(), $parameters);
 
@@ -208,30 +210,8 @@ protected function doGenerate(array $variables, array $defaults, array $tokens,
       $url = '/';
     }
 
-    // The contexts base URL is already encoded (see Symfony\Component\HttpFoundation\Request)
-    $url = str_replace($this->decodedChars[0], $this->decodedChars[1], rawurlencode($url));
-
-    // Drupal paths rarely include dots, so skip this processing if possible.
-    if (strpos($url, '/.') !== FALSE) {
-      // the path segments "." and ".." are interpreted as relative reference when
-      // resolving a URI; see http://tools.ietf.org/html/rfc3986#section-3.3
-      // so we need to encode them as they are not used for this purpose here
-      // otherwise we would generate a URI that, when followed by a user agent
-      // (e.g. browser), does not match this route
-      $url = strtr($url, array('/../' => '/%2E%2E/', '/./' => '/%2E/'));
-      if ('/..' === substr($url, -3)) {
-        $url = substr($url, 0, -2) . '%2E%2E';
-      }
-      elseif ('/.' === substr($url, -2)) {
-        $url = substr($url, 0, -1) . '%2E';
-      }
-    }
-
-    // Add a query string if needed, including extra parameters.
+    // Add extra parameters to the query parameters.
     $query_params += array_diff_key($parameters, $variables, $defaults);
-    if ($query_params && $query = UrlHelper::buildQuery($query_params)) {
-      $url .= '?' . $query;
-    }
 
     return $url;
   }
@@ -251,9 +231,10 @@ protected function doGenerate(array $variables, array $defaults, array $tokens,
    *   $parameters merged in.
    *
    * @return string
-   *   The url path corresponding to the route, without the base path.
+   *   The URL path corresponding to the route, without the base path, not URL
+   *   encoded.
    */
-  protected function getInternalPathFromRoute($name, SymfonyRoute $route, $parameters = array(), $query_params = array()) {
+  protected function getInternalPathFromRoute($name, SymfonyRoute $route, $parameters = array(), &$query_params = array()) {
     // The Route has a cache of its own and is not recompiled as long as it does
     // not get modified.
     $compiledRoute = $route->compile();
@@ -274,15 +255,13 @@ public function generate($name, $parameters = array(), $referenceType = self::AB
    */
   public function generateFromRoute($name, $parameters = array(), $options = array(), $collect_bubbleable_metadata = FALSE) {
     $options += array('prefix' => '');
+    if (!isset($options['query']) || !is_array($options['query'])) {
+      $options['query'] = array();
+    }
+
     $route = $this->getRoute($name);
     $generated_url = $collect_bubbleable_metadata ? new GeneratedUrl() : NULL;
 
-    $query_params = [];
-    // Symfony adds any parameters that are not path slugs as query strings.
-    if (isset($options['query']) && is_array($options['query'])) {
-      $query_params = $options['query'];
-    }
-
     $fragment = '';
     if (isset($options['fragment'])) {
       if (($fragment = trim($options['fragment'])) != '') {
@@ -292,7 +271,7 @@ public function generateFromRoute($name, $parameters = array(), $options = array
 
     // Generate a relative URL having no path, just query string and fragment.
     if ($route->getOption('_no_path')) {
-      $query = $query_params ? '?' . http_build_query($query_params, '', '&') : '';
+      $query = $options['query'] ? '?' . UrlHelper::buildQuery($options['query']) : '';
       $url = $query . $fragment;
       return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($url) : $url;
     }
@@ -302,13 +281,32 @@ public function generateFromRoute($name, $parameters = array(), $options = array
 
     $name = $this->getRouteDebugMessage($name);
     $this->processRoute($name, $route, $parameters, $generated_url);
-    $path = $this->getInternalPathFromRoute($name, $route, $parameters, $query_params);
+    $path = $this->getInternalPathFromRoute($name, $route, $parameters, $options['query']);
     // Outbound path processors might need the route object for the path, e.g.
     // to get the path pattern.
     $options['route'] = $route;
     if ($options['path_processing']) {
       $path = $this->processPath($path, $options, $generated_url);
     }
+    // The contexts base URL is already encoded
+    // (see Symfony\Component\HttpFoundation\Request).
+    $path = str_replace($this->decodedChars[0], $this->decodedChars[1], rawurlencode($path));
+
+    // Drupal paths rarely include dots, so skip this processing if possible.
+    if (strpos($path, '/.') !== FALSE) {
+      // the path segments "." and ".." are interpreted as relative reference when
+      // resolving a URI; see http://tools.ietf.org/html/rfc3986#section-3.3
+      // so we need to encode them as they are not used for this purpose here
+      // otherwise we would generate a URI that, when followed by a user agent
+      // (e.g. browser), does not match this route
+      $path = strtr($path, array('/../' => '/%2E%2E/', '/./' => '/%2E/'));
+      if ('/..' === substr($path, -3)) {
+        $path = substr($path, 0, -2) . '%2E%2E';
+      }
+      elseif ('/.' === substr($path, -2)) {
+        $path = substr($path, 0, -1) . '%2E';
+      }
+    }
 
     if (!empty($options['prefix'])) {
       $path = ltrim($path, '/');
@@ -316,6 +314,8 @@ public function generateFromRoute($name, $parameters = array(), $options = array
       $path = '/' . str_replace('%2F', '/', rawurlencode($prefix)) . $path;
     }
 
+    $query = $options['query'] ? '?' . UrlHelper::buildQuery($options['query']) : '';
+
     // The base_url might be rewritten from the language rewrite in domain mode.
     if (isset($options['base_url'])) {
       $base_url = $options['base_url'];
@@ -329,7 +329,7 @@ public function generateFromRoute($name, $parameters = array(), $options = array
         }
       }
 
-      $url = $base_url . $path . $fragment;
+      $url = $base_url . $path . $query . $fragment;
       return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($url) : $url;
     }
 
@@ -337,7 +337,7 @@ public function generateFromRoute($name, $parameters = array(), $options = array
 
     $absolute = !empty($options['absolute']);
     if (!$absolute || !$host = $this->context->getHost()) {
-      $url = $base_url . $path . $fragment;
+      $url = $base_url . $path . $query . $fragment;
       return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($url) : $url;
     }
 
@@ -363,29 +363,16 @@ public function generateFromRoute($name, $parameters = array(), $options = array
     if ($collect_bubbleable_metadata) {
       $generated_url->addCacheContexts(['url.site']);
     }
-    $url = $scheme . '://' . $host . $port . $base_url . $path . $fragment;
+    $url = $scheme . '://' . $host . $port . $base_url . $path . $query . $fragment;
     return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($url) : $url;
-
   }
 
   /**
    * Passes the path to a processor manager to allow alterations.
    */
   protected function processPath($path, &$options = array(), BubbleableMetadata $bubbleable_metadata = NULL) {
-    // Router-based paths may have a querystring on them.
-    if ($query_pos = strpos($path, '?')) {
-      // We don't need to do a strict check here because position 0 would mean we
-      // have no actual path to work with.
-      $actual_path = substr($path, 0, $query_pos);
-      $query_string = substr($path, $query_pos);
-    }
-    else {
-      $actual_path = $path;
-      $query_string = '';
-    }
-    $path = $this->pathProcessor->processOutbound($actual_path === '/' ? $actual_path : rtrim($actual_path, '/'), $options, $this->requestStack->getCurrentRequest(), $bubbleable_metadata);
-    $path .= $query_string;
-    return $path;
+    $actual_path = $path === '/' ? $path : rtrim($path, '/');
+    return $this->pathProcessor->processOutbound($actual_path, $options, $this->requestStack->getCurrentRequest(), $bubbleable_metadata);
   }
 
   /**
diff --git a/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php b/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php
index 70829cd68e95..b2b81968f5c1 100644
--- a/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php
+++ b/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php
@@ -21,7 +21,8 @@ interface UrlGeneratorInterface extends VersatileGeneratorInterface {
    *   \Symfony\Component\Routing\Generator\UrlGeneratorInterface::generate().
    *
    * @return string
-   *   The internal Drupal path corresponding to the route.
+   *   The internal Drupal path corresponding to the route. This string is
+   *   not urlencoded and will be an empty string for the front page.
    */
   public function getPathFromRoute($name, $parameters = array());
 
diff --git a/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationContentEntity.php b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationContentEntity.php
index 78edb896453d..4f4c7d47b67f 100644
--- a/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationContentEntity.php
+++ b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationContentEntity.php
@@ -2,7 +2,6 @@
 
 namespace Drupal\language\Plugin\LanguageNegotiation;
 
-use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Entity\ContentEntityInterface;
 use Drupal\Core\Entity\EntityManagerInterface;
 use Drupal\Core\PathProcessor\OutboundPathProcessorInterface;
@@ -118,20 +117,8 @@ public function processOutbound($path, &$options = [], Request $request = NULL,
         unset($options['language']);
       }
 
-      if (isset($options['query']) && is_string($options['query'])) {
-        $query = [];
-        parse_str($options['query'], $query);
-        $options['query'] = $query;
-      }
-      else {
-        $options['query'] = [];
-      }
-
       if (!isset($options['query'][static::QUERY_PARAMETER])) {
-        $query_addon = [static::QUERY_PARAMETER => $langcode];
-        $options['query'] += $query_addon;
-        // @todo Remove this once https://www.drupal.org/node/2507005 lands.
-        $path .= (strpos($path, '?') !== FALSE ? '&' : '?') . UrlHelper::buildQuery($query_addon);
+        $options['query'][static::QUERY_PARAMETER] = $langcode;
       }
 
       if ($bubbleable_metadata) {
diff --git a/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationSession.php b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationSession.php
index 5463b5a6bf7e..6c83e41f6b54 100644
--- a/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationSession.php
+++ b/core/modules/language/src/Plugin/LanguageNegotiation/LanguageNegotiationSession.php
@@ -102,11 +102,6 @@ public function processOutbound($path, &$options = array(), Request $request = N
       // enabled, and the corresponding option has been set, we must preserve
       // any explicit user language preference even with cookies disabled.
       if ($this->queryRewrite) {
-        if (isset($options['query']) && is_string($options['query'])) {
-          $query = array();
-          parse_str($options['query'], $query);
-          $options['query'] = $query;
-        }
         if (!isset($options['query'][$this->queryParam])) {
           $options['query'][$this->queryParam] = $this->queryValue;
         }
diff --git a/core/modules/system/src/Tests/Path/UrlAlterFunctionalTest.php b/core/modules/system/src/Tests/Path/UrlAlterFunctionalTest.php
index 743b74bdebac..c4143fd7f602 100644
--- a/core/modules/system/src/Tests/Path/UrlAlterFunctionalTest.php
+++ b/core/modules/system/src/Tests/Path/UrlAlterFunctionalTest.php
@@ -3,6 +3,7 @@
 namespace Drupal\system\Tests\Path;
 
 use Drupal\Core\Database\Database;
+use Drupal\Core\Url;
 use Drupal\simpletest\WebTestBase;
 use Drupal\taxonomy\Entity\Term;
 
@@ -74,6 +75,10 @@ function testUrlAlter() {
     $this->drupalGet("community/" . $term->id());
     $this->assertText($term_name, 'The community/{tid} path gets resolved correctly');
     $this->assertUrlOutboundAlter("/forum/" . $term->id(), "/community/" . $term->id());
+
+    // Test outbound query string altering.
+    $url = Url::fromRoute('user.login');
+    $this->assertIdentical(\Drupal::request()->getBaseUrl() . '/user/login?foo=bar', $url->toString());
   }
 
   /**
diff --git a/core/modules/system/tests/modules/path_encoded_test/path_encoded_test.info.yml b/core/modules/system/tests/modules/path_encoded_test/path_encoded_test.info.yml
new file mode 100644
index 000000000000..37208e9b62d5
--- /dev/null
+++ b/core/modules/system/tests/modules/path_encoded_test/path_encoded_test.info.yml
@@ -0,0 +1,6 @@
+name: 'Path encoded character test'
+type: module
+description: 'Support module for testing path aliases on a route with encoded characters in the path.'
+package: Testing
+version: VERSION
+core: 8.x
diff --git a/core/modules/system/tests/modules/path_encoded_test/path_encoded_test.routing.yml b/core/modules/system/tests/modules/path_encoded_test/path_encoded_test.routing.yml
new file mode 100644
index 000000000000..9a5b30cc3a2f
--- /dev/null
+++ b/core/modules/system/tests/modules/path_encoded_test/path_encoded_test.routing.yml
@@ -0,0 +1,23 @@
+path_encoded_test.colon:
+  path: '/hi/llamma:party'
+  defaults:
+    _title: 'Colon'
+    _controller: '\Drupal\path_encoded_test\Controller\PathEncodedTestController::simple'
+  requirements:
+    _access: 'TRUE'
+
+path_encoded_test.atsign:
+  path: '/bloggy/@Dries'
+  defaults:
+    _title: 'At sign'
+    _controller: '\Drupal\path_encoded_test\Controller\PathEncodedTestController::simple'
+  requirements:
+    _access: 'TRUE'
+
+path_encoded_test.parens:
+  path: '/cat(box)'
+  defaults:
+    _title: 'At sign'
+    _controller: '\Drupal\path_encoded_test\Controller\PathEncodedTestController::simple'
+  requirements:
+    _access: 'TRUE'
diff --git a/core/modules/system/tests/modules/path_encoded_test/src/Controller/PathEncodedTestController.php b/core/modules/system/tests/modules/path_encoded_test/src/Controller/PathEncodedTestController.php
new file mode 100644
index 000000000000..027c24f73fe5
--- /dev/null
+++ b/core/modules/system/tests/modules/path_encoded_test/src/Controller/PathEncodedTestController.php
@@ -0,0 +1,21 @@
+<?php
+
+namespace Drupal\path_encoded_test\Controller;
+
+use Symfony\Component\HttpFoundation\Response;
+
+/**
+ * Returns responses for path_encoded_test routes.
+ */
+class PathEncodedTestController {
+
+  /**
+   * Returns a HTML simple response.
+   *
+   * @return \Symfony\Component\HttpFoundation\Response
+   */
+  public function simple() {
+    return new Response('<html><body>PathEncodedTestController works</body></html>');
+  }
+
+}
diff --git a/core/modules/system/tests/modules/url_alter_test/src/PathProcessorTest.php b/core/modules/system/tests/modules/url_alter_test/src/PathProcessorTest.php
index e6b9fc2f61ae..b3ba56885092 100644
--- a/core/modules/system/tests/modules/url_alter_test/src/PathProcessorTest.php
+++ b/core/modules/system/tests/modules/url_alter_test/src/PathProcessorTest.php
@@ -49,6 +49,11 @@ public function processOutbound($path, &$options = array(), Request $request = N
       }
     }
 
+    // Verify that $options are alterable.
+    if ($path == '/user/login') {
+      $options['query']['foo'] = 'bar';
+    }
+
     // Rewrite forum/ to community/.
     return preg_replace('@^/forum(.*)@', '/community$1', $path);
   }
diff --git a/core/tests/Drupal/FunctionalTests/Routing/PathEncodedTest.php b/core/tests/Drupal/FunctionalTests/Routing/PathEncodedTest.php
new file mode 100644
index 000000000000..a8aee89711f4
--- /dev/null
+++ b/core/tests/Drupal/FunctionalTests/Routing/PathEncodedTest.php
@@ -0,0 +1,54 @@
+<?php
+
+namespace Drupal\FunctionalTests\Routing;
+
+use Drupal\Core\Url;
+use Drupal\Tests\BrowserTestBase;
+
+/**
+ * Tests url generation and routing for route paths with encoded characters.
+ *
+ * @group routing
+ */
+class PathEncodedTest extends BrowserTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['system', 'path_encoded_test'];
+
+  public function testGetEncoded() {
+    $route_paths = [
+      'path_encoded_test.colon' => '/hi/llamma:party',
+      'path_encoded_test.atsign' => '/bloggy/@Dries',
+      'path_encoded_test.parens' => '/cat(box)',
+    ];
+    foreach ($route_paths as $route_name => $path) {
+      $this->drupalGet(Url::fromRoute($route_name));
+      $this->assertSession()->pageTextContains('PathEncodedTestController works');
+    }
+  }
+
+  public function testAliasToEncoded() {
+    $route_paths = [
+      'path_encoded_test.colon' => '/hi/llamma:party',
+      'path_encoded_test.atsign' => '/bloggy/@Dries',
+      'path_encoded_test.parens' => '/cat(box)',
+    ];
+    /** @var \Drupal\Core\Path\AliasStorageInterface $alias_storage */
+    $alias_storage = $this->container->get('path.alias_storage');
+    $aliases = [];
+    foreach ($route_paths as $route_name => $path) {
+      $aliases[$route_name] = $this->randomMachineName();
+      $alias_storage->save($path, '/' . $aliases[$route_name]);
+    }
+    foreach ($route_paths as $route_name => $path) {
+      // The alias may be only a suffix of the generated path when the test is
+      // run with Drupal installed in a subdirectory.
+      $this->assertRegExp('@/' . rawurlencode($aliases[$route_name]) . '$@', Url::fromRoute($route_name)->toString());
+      $this->drupalGet(Url::fromRoute($route_name));
+      $this->assertSession()->pageTextContains('PathEncodedTestController works');
+    }
+  }
+
+}
diff --git a/core/tests/Drupal/KernelTests/Core/Path/UrlAlterTest.php b/core/tests/Drupal/KernelTests/Core/Path/UrlAlterTest.php
new file mode 100644
index 000000000000..37ac65ae8e88
--- /dev/null
+++ b/core/tests/Drupal/KernelTests/Core/Path/UrlAlterTest.php
@@ -0,0 +1,28 @@
+<?php
+
+namespace Drupal\KernelTests\Core\Path;
+
+use Drupal\Core\Url;
+use Drupal\KernelTests\KernelTestBase;
+
+/**
+ * Tests the capability to alter URLs.
+ *
+ * @group Path
+ *
+ * @see \Drupal\Core\Routing\UrlGenerator::processPath
+ */
+class UrlAlterTest extends KernelTestBase {
+
+  /**
+   * {@inheritdoc}
+   */
+  public static $modules = ['path', 'url_alter_test', 'user'];
+
+  public function testUrlWithQueryString() {
+    // Test outbound query string altering.
+    $url = Url::fromRoute('user.login');
+    $this->assertEquals(\Drupal::request()->getBaseUrl() . '/user/login?foo=bar', $url->toString());
+  }
+
+}
diff --git a/core/tests/Drupal/Tests/Core/Routing/RedirectDestinationTest.php b/core/tests/Drupal/Tests/Core/Routing/RedirectDestinationTest.php
index fd99e9907532..2d733c3da28e 100644
--- a/core/tests/Drupal/Tests/Core/Routing/RedirectDestinationTest.php
+++ b/core/tests/Drupal/Tests/Core/Routing/RedirectDestinationTest.php
@@ -2,6 +2,7 @@
 
 namespace Drupal\Tests\Core\Routing;
 
+use Drupal\Component\Utility\UrlHelper;
 use Drupal\Core\Routing\RedirectDestination;
 use Drupal\Tests\UnitTestCase;
 use Symfony\Component\HttpFoundation\Request;
@@ -51,7 +52,7 @@ protected function setupUrlGenerator() {
       ->willReturnCallback(function($route, $parameters, $options) {
         $query_string = '';
         if (!empty($options['query'])) {
-          $query_string = '?' . $options['query'];
+          $query_string = '?' . UrlHelper::buildQuery($options['query']);
         }
 
         return '/current-path' . $query_string;
diff --git a/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php b/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php
index 06e8c177f70e..b0c1ca33fe99 100644
--- a/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php
+++ b/core/tests/Drupal/Tests/Core/Routing/UrlGeneratorTest.php
@@ -68,6 +68,13 @@ class UrlGeneratorTest extends UnitTestCase {
    */
   protected $context;
 
+  /**
+   * The path processor.
+   *
+   * @var \Drupal\Core\PathProcessor\PathProcessorManager
+   */
+  protected $processorManager;
+
   /**
    * {@inheritdoc}
    */
@@ -156,6 +163,7 @@ protected function setUp() {
     $processor = new PathProcessorAlias($this->aliasManager);
     $processor_manager = new PathProcessorManager();
     $processor_manager->addOutbound($processor, 1000);
+    $this->processorManager = $processor_manager;
 
     $this->routeProcessorManager = $this->getMockBuilder('Drupal\Core\RouteProcessor\RouteProcessorManager')
       ->disableOriginalConstructor()
@@ -363,6 +371,13 @@ public function providerTestAliasGenerationWithOptions() {
       ['query' => ['page' => '1/2'], 'fragment' => 'bottom'],
       '/test/two/7?page=1/2#bottom',
     ];
+    // A NULL query string.
+    $data['query-with-NULL'] = [
+      'test_2',
+      ['narf' => '7'],
+      ['query' => NULL, 'fragment' => 'bottom'],
+      '/test/two/7#bottom',
+    ];
     return $data;
   }
 
@@ -489,6 +504,26 @@ public function providerTestNoPath() {
     ];
   }
 
+  /**
+   * @covers \Drupal\Core\Routing\UrlGenerator::generateFromRoute
+   *
+   * Note: We use absolute covers to let
+   * \Drupal\Tests\Core\Render\MetadataBubblingUrlGeneratorTest work.
+   */
+  public function testGenerateWithPathProcessorChangingQueryParameter() {
+    $path_processor = $this->getMock(OutboundPathProcessorInterface::CLASS);
+    $path_processor->expects($this->atLeastOnce())
+      ->method('processOutbound')
+      ->willReturnCallback(function ($path, &$options = array(), Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) {
+        $options['query'] = ['zoo' => 5];
+        return $path;
+      });
+    $this->processorManager->addOutbound($path_processor);
+
+    $options = [];
+    $this->assertGenerateFromRoute('test_2', ['narf' => 5], $options, '/goodbye/cruel/world?zoo=5', (new BubbleableMetadata())->setCacheMaxAge(Cache::PERMANENT));
+  }
+
   /**
    * Asserts \Drupal\Core\Routing\UrlGenerator::generateFromRoute()'s output.
    *
-- 
GitLab