diff --git a/core/core.services.yml b/core/core.services.yml index fc0d99f..bab58d0 100644 --- a/core/core.services.yml +++ b/core/core.services.yml @@ -33,6 +33,9 @@ parameters: - sftp - webcal - rtsp + + route_normalizer_enabled: true + cors.config: enabled: false allowedHeaders: [] @@ -832,6 +835,11 @@ services: arguments: ['@route_filter.lazy_collector'] tags: - { name: event_subscriber } + route_normalizer_request_subscriber: + class: Drupal\Core\EventSubscriber\RouteNormalizerRequestSubscriber + arguments: ['@url_generator', '@path.matcher', '%route_normalizer_enabled%'] + tags: + - { name: event_subscriber } url_generator.non_bubbling: class: Drupal\Core\Routing\UrlGenerator arguments: ['@router.route_provider', '@path_processor_manager', '@route_processor_manager', '@request_stack', '%filter_protocols%'] diff --git a/core/lib/Drupal/Core/EventSubscriber/RouteNormalizerRequestSubscriber.php b/core/lib/Drupal/Core/EventSubscriber/RouteNormalizerRequestSubscriber.php new file mode 100644 index 0000000..ba367f2 --- /dev/null +++ b/core/lib/Drupal/Core/EventSubscriber/RouteNormalizerRequestSubscriber.php @@ -0,0 +1,127 @@ +urlGenerator = $url_generator; + $this->pathMatcher = $path_matcher; + $this->routeNormalizerEnabled = $route_normalizer_enabled; + } + + /** + * Performs a redirect if the URL changes in routing. + * + * The redirect happens if a URL constructed from the current route is + * different from the requested one. Examples: + * - Language negotiation system detected a language to use, and that language + * has a path prefix: perform a redirect to the language prefixed URL. + * - A route that's set as the front page is requested: redirect to the front + * page. + * - Requested path has an alias: redirect to alias. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event + * The Event to process. + */ + public function onKernelRequestRedirect(GetResponseEvent $event) { + if ($this->shouldRedirect($event)) { + $request = $event->getRequest(); + // The "" placeholder can be used for all routes except the front + // page because it's not a real route. + $route_name = $this->pathMatcher->isFrontPage() ? '' : ''; + $options = [ + 'query' => $request->query->all(), + 'absolute' => TRUE, + ]; + $redirect_uri = $this->urlGenerator->generateFromRoute($route_name, [], $options); + $original_uri = $request->getSchemeAndHttpHost() . $request->getRequestUri(); + if ($redirect_uri != $original_uri) { + $response = new RedirectResponse($redirect_uri, 302); + $response->headers->set('X-Drupal-Route-Normalizer', 1); + $event->setResponse($response); + } + } + } + + /** + * Detects if a redirect can be performed during the current request. + * + * @param \Symfony\Component\HttpKernel\Event\GetResponseEvent $event + * The Event to process. + * + * @return bool + */ + protected function shouldRedirect(GetResponseEvent $event) { + return $this->routeNormalizerEnabled + && $event->isMasterRequest() + && ($request = $event->getRequest()) + && $request->isMethod('GET') + && !$request->query->has('destination') + && RequestHelper::isCleanUrl($request) + && !$request->attributes->get('_disable_route_normalizer'); + } + + /** + * {@inheritdoc} + */ + public static function getSubscribedEvents() { + $events[KernelEvents::REQUEST][] = array('onKernelRequestRedirect'); + return $events; + } + +} diff --git a/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php b/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php index a5e6570..d607c60 100644 --- a/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php +++ b/core/lib/Drupal/Core/PathProcessor/OutboundPathProcessorInterface.php @@ -14,7 +14,7 @@ * Processes the outbound path. * * @param string $path - * The path to process, with a leading slash. + * The URL-encoded path to process, with a leading slash. * @param array $options * (optional) An associative array of additional options, with the following * elements: @@ -43,7 +43,7 @@ * (optional) Object to collect path processors' bubbleable metadata. * * @return string - * The processed path. + * The processed URL-encoded path. */ public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL); diff --git a/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php b/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php index b85737f..71ea184 100644 --- a/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php +++ b/core/lib/Drupal/Core/PathProcessor/PathProcessorAlias.php @@ -2,6 +2,7 @@ namespace Drupal\Core\PathProcessor; +use Drupal\Component\Utility\UrlHelper; use Drupal\Core\Path\AliasManagerInterface; use Drupal\Core\Render\BubbleableMetadata; use Symfony\Component\HttpFoundation\Request; @@ -42,7 +43,7 @@ public function processInbound($path, Request $request) { public function processOutbound($path, &$options = [], Request $request = NULL, BubbleableMetadata $bubbleable_metadata = NULL) { if (empty($options['alias'])) { $langcode = isset($options['language']) ? $options['language']->getId() : NULL; - $path = $this->aliasManager->getAliasByPath($path, $langcode); + $path = UrlHelper::encodePath($this->aliasManager->getAliasByPath(rawurldecode($path), $langcode)); } return $path; } diff --git a/core/lib/Drupal/Core/Routing/UrlGenerator.php b/core/lib/Drupal/Core/Routing/UrlGenerator.php index 63dcd47..1224ef8 100644 --- a/core/lib/Drupal/Core/Routing/UrlGenerator.php +++ b/core/lib/Drupal/Core/Routing/UrlGenerator.php @@ -162,8 +162,8 @@ public function getPathFromRoute($name, $parameters = []) { * The route name or other identifying string from ::getRouteDebugMessage(). * * @return string - * The url path, without any base path, without the query string, not URL - * encoded. + * The URL-encoded 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. @@ -238,8 +238,8 @@ 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, not URL - * encoded. + * The URL-encoded path corresponding to the route, without the base path, + * not URL encoded. */ protected function getInternalPathFromRoute($name, SymfonyRoute $route, $parameters = [], &$query_params = []) { // The Route has a cache of its own and is not recompiled as long as it does diff --git a/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php b/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php index e63383d..a8cf44b 100644 --- a/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php +++ b/core/lib/Drupal/Core/Routing/UrlGeneratorInterface.php @@ -21,8 +21,8 @@ * \Symfony\Component\Routing\Generator\UrlGeneratorInterface::generate(). * * @return string - * The internal Drupal path corresponding to the route. This string is - * not urlencoded and will be an empty string for the front page. + * The internal Drupal URL-encoded 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 = []); diff --git a/core/lib/Drupal/Core/Routing/UrlMatcher.php b/core/lib/Drupal/Core/Routing/UrlMatcher.php index 9056903..a09cdca 100644 --- a/core/lib/Drupal/Core/Routing/UrlMatcher.php +++ b/core/lib/Drupal/Core/Routing/UrlMatcher.php @@ -38,7 +38,11 @@ public function finalMatch(RouteCollection $collection, Request $request) { $context->fromRequest($request); $this->setContext($context); - return $this->match($this->currentPath->getPath($request)); + // The matcher expects raw path while we have it already decoded by the + // \Drupal\Core\PathProcessor\PathProcessorDecode. Encode it to avoid double + // decoding of the route parameters. + $encoded_path = rawurlencode($this->currentPath->getPath($request)); + return $this->match($encoded_path); } } diff --git a/core/lib/Drupal/Core/Url.php b/core/lib/Drupal/Core/Url.php index 76dc46c..ac38926 100644 --- a/core/lib/Drupal/Core/Url.php +++ b/core/lib/Drupal/Core/Url.php @@ -776,7 +776,7 @@ public function toRenderArray() { * This path will not include any prefixes, fragments, or query strings. * * @return string - * The internal path for this route. + * The internal URL-encoded path for this route. * * @throws \UnexpectedValueException. * If this is a URI with no corresponding system path. diff --git a/core/lib/Drupal/Core/Utility/UnroutedUrlAssembler.php b/core/lib/Drupal/Core/Utility/UnroutedUrlAssembler.php index 6d68c1e..96d8a9a 100644 --- a/core/lib/Drupal/Core/Utility/UnroutedUrlAssembler.php +++ b/core/lib/Drupal/Core/Utility/UnroutedUrlAssembler.php @@ -109,6 +109,9 @@ protected function buildLocalUrl($uri, array $options = [], $collect_bubbleable_ // https://www.drupal.org/node/2417459 $uri = substr($uri, 5); + // The path should be URl-encoded before possible path processing. + $uri = UrlHelper::encodePath($uri); + // Allow (outbound) path processing, if needed. A valid use case is the path // alias overview form: // @see \Drupal\path\Controller\PathController::adminOverview(). @@ -150,7 +153,7 @@ protected function buildLocalUrl($uri, array $options = [], $collect_bubbleable_ $prefix = empty($uri) ? rtrim($options['prefix'], '/') : $options['prefix']; - $uri = str_replace('%2F', '/', rawurlencode($prefix . $uri)); + $uri = UrlHelper::encodePath($prefix) . $uri; $query = $options['query'] ? ('?' . UrlHelper::buildQuery($options['query'])) : ''; $url = $base . $options['script'] . $uri . $query . $options['fragment']; return $collect_bubbleable_metadata ? $generated_url->setGeneratedUrl($url) : $url; diff --git a/core/modules/big_pipe/src/Tests/BigPipeTest.php b/core/modules/big_pipe/src/Tests/BigPipeTest.php index a4d5562..6e00cc1 100644 --- a/core/modules/big_pipe/src/Tests/BigPipeTest.php +++ b/core/modules/big_pipe/src/Tests/BigPipeTest.php @@ -100,11 +100,11 @@ public function testNoJsDetection() { $this->cookies = []; // 3. Session (anonymous). - $this->drupalGet(Url::fromRoute('user.login', [], ['query' => ['trigger_session' => 1]])); - $this->drupalGet(Url::fromRoute('user.login')); + $this->drupalGet(Url::fromRoute('user.register', [], ['query' => ['trigger_session' => 1]])); + $this->drupalGet(Url::fromRoute('user.register')); $this->assertSessionCookieExists(TRUE); $this->assertBigPipeNoJsCookieExists(FALSE); - $this->assertRaw(''); + $this->assertRaw(''); $this->assertNoRaw($no_js_to_js_markup); $this->assertBigPipeNoJsMetaRefreshRedirect(); $this->assertBigPipeNoJsCookieExists(TRUE); diff --git a/core/modules/book/tests/src/Functional/BookTest.php b/core/modules/book/tests/src/Functional/BookTest.php index 66edec9..b4cb65b 100644 --- a/core/modules/book/tests/src/Functional/BookTest.php +++ b/core/modules/book/tests/src/Functional/BookTest.php @@ -432,6 +432,10 @@ public function testBookNavigationBlock() { // Test correct display of the block. $nodes = $this->createBook(); + // It may happen that user is redirected to the front page during the + // logout, so the front page may be already cached and we did no action to + // clear the cache so far. Do it now. + Cache::invalidateTags(['rendered']); $this->drupalGet(''); $this->assertText($block->label(), 'Book navigation block is displayed.'); $this->assertText($this->book->label(), format_string('Link to book root (@title) is displayed.', ['@title' => $nodes[0]->label()])); diff --git a/core/modules/image/src/PathProcessor/PathProcessorImageStyles.php b/core/modules/image/src/PathProcessor/PathProcessorImageStyles.php index 5fdbd2f..630afd7 100644 --- a/core/modules/image/src/PathProcessor/PathProcessorImageStyles.php +++ b/core/modules/image/src/PathProcessor/PathProcessorImageStyles.php @@ -65,6 +65,9 @@ public function processInbound($path, Request $request) { // Set the file as query parameter. $request->query->set('file', $file); + // Disable route normalizer since we changed the request object. + $request->attributes->set('_disable_route_normalizer', TRUE); + return $path_prefix . $image_style . '/' . $scheme; } else { diff --git a/core/modules/language/tests/src/Functional/LanguageListTest.php b/core/modules/language/tests/src/Functional/LanguageListTest.php index 8c671cf..bb1a05d 100644 --- a/core/modules/language/tests/src/Functional/LanguageListTest.php +++ b/core/modules/language/tests/src/Functional/LanguageListTest.php @@ -149,6 +149,10 @@ public function testLanguageList() { 'direction' => Language::DIRECTION_LTR, ]; $this->drupalPostForm('admin/config/regional/language/add', $edit, t('Add custom language')); + // As we changed the amount of languages, rebuilt the container so that + // \Drupal\language\LanguageServiceProvider (un)register its services and we + // can construct the correct URL. + $this->rebuildContainer(); $this->assertUrl(\Drupal::url('entity.configurable_language.collection', [], ['absolute' => TRUE])); $this->assertText($name, 'Name found.'); diff --git a/core/modules/path/tests/src/Functional/PathAliasTest.php b/core/modules/path/tests/src/Functional/PathAliasTest.php index a63ad3d..6ee0ef0 100644 --- a/core/modules/path/tests/src/Functional/PathAliasTest.php +++ b/core/modules/path/tests/src/Functional/PathAliasTest.php @@ -33,6 +33,12 @@ protected function setUp() { * Tests the path cache. */ public function testPathCache() { + + // Disable the route normalizer, because otherwise internal paths are + // redirected to their aliases and no "preload-paths:" cache is set. + $this->setContainerParameter('route_normalizer_enabled', FALSE); + $this->rebuildContainer(); + // Create test node. $node1 = $this->drupalCreateNode(); diff --git a/core/modules/simpletest/src/Tests/BrowserTest.php b/core/modules/simpletest/src/Tests/BrowserTest.php index fdfaff8..5ffa080 100644 --- a/core/modules/simpletest/src/Tests/BrowserTest.php +++ b/core/modules/simpletest/src/Tests/BrowserTest.php @@ -38,6 +38,10 @@ protected function setUp() { * Test \Drupal\simpletest\WebTestBase::getAbsoluteUrl(). */ public function testGetAbsoluteUrl() { + + // Change the frontpage to something else than the default "/user/login". + $this->config('system.site')->set('page.front', '/user/password')->save(); + $url = 'user/login'; $this->drupalGet($url); diff --git a/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php b/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php index 7e4f912..afe074d 100644 --- a/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php +++ b/core/modules/simpletest/src/Tests/SimpleTestBrowserTest.php @@ -56,6 +56,7 @@ public function testInternalBrowser() { $this->assertEqual(0, $this->container->get('current_user')->id(), 'Current user service updated.'); // Test the maximum redirection option. + $maximum_redirects_original = $this->maximumRedirects; $this->maximumRedirects = 1; $edit = [ 'name' => $user->getUsername(), @@ -66,6 +67,7 @@ public function testInternalBrowser() { ]); $headers = $this->drupalGetHeaders(TRUE); $this->assertEqual(count($headers), 2, 'Simpletest stopped following redirects after the first one.'); + $this->maximumRedirects = $maximum_redirects_original; // Remove the Simpletest private key file so we can test the protection // against requests that forge a valid testing user agent to gain access diff --git a/core/tests/Drupal/FunctionalTests/Routing/RouteNormalizerTest.php b/core/tests/Drupal/FunctionalTests/Routing/RouteNormalizerTest.php new file mode 100644 index 0000000..fc7ee11 --- /dev/null +++ b/core/tests/Drupal/FunctionalTests/Routing/RouteNormalizerTest.php @@ -0,0 +1,116 @@ +getEditable('system.site') + ->set('page.front', $front_page_path) + ->save(); + $this->drupalGet(Url::fromUri('base:' . $front_page_path)); + $this->assertSame(Url::fromRoute('') + ->setAbsolute() + ->toString(), $this->getSession()->getCurrentUrl()); + + // Test path alias redirect. + $this->drupalLogin($this->drupalCreateUser([ + 'administer url aliases', + ])); + $this->drupalGet(Url::fromRoute('path.admin_add')); + $edit = [ + 'source' => '/user/password', + 'alias' => '/my-cool/password/recovery/page', + ]; + $this->submitForm($edit, t('Save')); + $this->drupalGet(Url::fromUri('base:' . $edit['source'])); + $this->assertSame(Url::fromUri('base:' . $edit['alias']) + ->setAbsolute() + ->toString(), $this->getSession()->getCurrentUrl()); + + // Test language redirect. + $this->drupalLogin($this->drupalCreateUser([ + 'administer languages', + ])); + // We need more than one language to make the redirect work. + $this->drupalGet(Url::fromRoute('language.add')); + $edit = [ + 'predefined_langcode' => 'fr', + ]; + $this->submitForm($edit, t('Add language')); + $this->drupalGet(Url::fromRoute('language.negotiation')); + $edit = [ + 'language_interface[enabled][language-url]' => 1, + ]; + $this->submitForm($edit, t('Save settings')); + $this->drupalGet(Url::fromRoute('language.negotiation_url')); + $edit = [ + 'language_negotiation_url_part' => LanguageNegotiationUrl::CONFIG_PATH_PREFIX, + 'prefix[en]' => 'en', + 'prefix[fr]' => 'fr', + ]; + $this->submitForm($edit, t('Save configuration')); + $this->rebuildContainer(); + $url = Url::fromUri('base:/admin/config/regional/language') + ->setAbsolute() + ->toString(); + $prefix_count = substr_count($url, '/en/'); + $this->drupalGet($url); + $current_url = $this->getSession()->getCurrentUrl(); + $this->assertSame($prefix_count + 1, substr_count($current_url, '/en/'), 'The path prefix of the default language was added to the final URL.'); + $this->assertContains('/admin/config/regional/language', $current_url, 'Path preserved.'); + + // Test a redirect having special characters in source/destination paths. + /** @var \Drupal\Core\Menu\MenuLinkManagerInterface $menu_link_manager */ + $menu_link_manager = $this->container->get('plugin.manager.menu.link'); + /** @var \Drupal\Core\Menu\MenuLinkInterface $menu_link */ + $menu_link = $menu_link_manager->createInstance('menu_test.exotic_path'); + $exotic_path = rawurldecode($menu_link->getUrlObject()->getInternalPath()); + $this->drupalLogin($this->drupalCreateUser([ + 'administer url aliases', + ])); + $this->drupalGet(Url::fromRoute('path.admin_add')); + $edit = [ + 'source' => '/' . $exotic_path, + 'alias' => '/' . $exotic_path . rawurlencode(rawurlencode('#%&+/?')), + ]; + $this->submitForm($edit, t('Save')); + $this->drupalGet($exotic_path, ['alias' => TRUE]); + $this->assertContains(UrlHelper::encodePath($edit['alias']), $this->getSession() + ->getCurrentUrl(), 'Redirected to the alias.'); + } + +} diff --git a/sites/default/default.services.yml b/sites/default/default.services.yml index e1bbbc7..3da416f 100644 --- a/sites/default/default.services.yml +++ b/sites/default/default.services.yml @@ -154,6 +154,10 @@ parameters: - webcal - rtsp + # Enables the route normalizer. + # @default true + route_normalizer_enabled: true + # Configure Cross-Site HTTP requests (CORS). # Read https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS # for more information about the topic in general.