Repository: pallets/werkzeug

Issue:
Groups of slashes in path should be normalized and redirected
Originally reported at pallets/flask#1323.

`/x` and `//x` should route to the same location, possibly by redirecting. From the linked issue:

> On most operating systems, extra slashes resolve to one. URLs came from file system notation so it would seem that the two should respond in the same way.  
> 
> Apache removes duplicate slashes for [a good reason](https://issues.apache.org/jira/browse/NUTCH-1894).
> 
> Nginx has the [merge_slashes directive](http://nginx.org/en/docs/http/ngx_http_core_module.html#merge_slashes), which is on by default.
> 
> [RFC 3986](https://tools.ietf.org/html/rfc3986#section-3.3) seems to imply you can't have multiple, contiguous slashes

Possible solution is to add a `collapse_slashes` option similar to `strict_slashes`. 

Possibly related to #491.


Discussion:
This should apply to defining rules (`//x` becomes `/x` internally) as well as matching incoming paths (`//x` redirects to `/x`).

Resolving patch:
diff --git a/docs/conf.py b/docs/conf.py
--- a/docs/conf.py
+++ b/docs/conf.py
@@ -16,6 +16,7 @@
     "sphinx.ext.intersphinx",
     "pallets_sphinx_themes",
     "sphinx_issues",
+    "sphinxcontrib.log_cabinet",
 ]
 intersphinx_mapping = {"python": ("https://docs.python.org/3/", None)}
 issues_github_path = "pallets/werkzeug"
diff --git a/src/werkzeug/routing.py b/src/werkzeug/routing.py
--- a/src/werkzeug/routing.py
+++ b/src/werkzeug/routing.py
@@ -253,9 +253,14 @@ def get_response(self, environ):
         return redirect(self.new_url, self.code)
 
 
-class RequestSlash(RoutingException):
+class RequestPath(RoutingException):
     """Internal exception."""
 
+    __slots__ = ("path_info",)
+
+    def __init__(self, path_info):
+        self.path_info = path_info
+
 
 class RequestAliasRedirect(RoutingException):  # noqa: B903
     """This rule is an alias and wants to redirect to the canonical URL."""
@@ -582,6 +587,11 @@ class Rule(RuleFactory):
         Override the `Map` setting for `strict_slashes` only for this rule. If
         not specified the `Map` setting is used.
 
+    `merge_slashes`
+        Override the ``Map`` setting for ``merge_slashes`` for this rule.
+
+        .. versionadded:: 1.0
+
     `build_only`
         Set this to True and the rule will never match but will create a URL
         that can be build. This is useful if you have resources on a subdomain
@@ -634,6 +644,7 @@ def __init__(
         build_only=False,
         endpoint=None,
         strict_slashes=None,
+        merge_slashes=None,
         redirect_to=None,
         alias=False,
         host=None,
@@ -645,6 +656,7 @@ def __init__(
 
         self.map = None
         self.strict_slashes = strict_slashes
+        self.merge_slashes = merge_slashes
         self.subdomain = subdomain
         self.host = host
         self.defaults = defaults
@@ -726,6 +738,8 @@ def bind(self, map, rebind=False):
         self.map = map
         if self.strict_slashes is None:
             self.strict_slashes = map.strict_slashes
+        if self.merge_slashes is None:
+            self.merge_slashes = map.merge_slashes
         if self.subdomain is None:
             self.subdomain = map.default_subdomain
         self.compile()
@@ -766,9 +780,18 @@ def _build_regex(rule):
             index = 0
             for converter, arguments, variable in parse_rule(rule):
                 if converter is None:
-                    regex_parts.append(re.escape(variable))
-                    self._trace.append((False, variable))
-                    for part in variable.split("/"):
+                    for match in re.finditer(r"/+|[^/]+", variable):
+                        part = match.group(0)
+                        if part.startswith("/"):
+                            if self.merge_slashes:
+                                regex_parts.append(r"/+?")
+                                self._trace.append((False, "/"))
+                            else:
+                                regex_parts.append(part)
+                                self._trace.append((False, part))
+                            continue
+                        self._trace.append((False, part))
+                        regex_parts.append(re.escape(part))
                         if part:
                             self._static_weights.append((index, -len(part)))
                 else:
@@ -817,6 +840,8 @@ def match(self, path, method=None):
         :internal:
         """
         if not self.build_only:
+            require_redirect = False
+
             m = self._regex.search(path)
             if m is not None:
                 groups = m.groupdict()
@@ -832,7 +857,8 @@ def match(self, path, method=None):
                         method is None or self.methods is None or method in self.methods
                     )
                 ):
-                    raise RequestSlash()
+                    path += "/"
+                    require_redirect = True
                 # if we are not in strict slashes mode we have to remove
                 # a __suffix__
                 elif not self.strict_slashes:
@@ -848,6 +874,18 @@ def match(self, path, method=None):
                 if self.defaults:
                     result.update(self.defaults)
 
+                if self.merge_slashes:
+                    new_path = "|".join(self.build(result, False))
+                    if path.endswith("/") and not new_path.endswith("/"):
+                        new_path += "/"
+                    if new_path.count("/") < path.count("/"):
+                        path = new_path
+                        require_redirect = True
+
+                if require_redirect:
+                    path = path.split("|", 1)[1]
+                    raise RequestPath(path)
+
                 if self.alias and self.map.redirect_defaults:
                     raise RequestAliasRedirect(result)
 
@@ -1299,7 +1337,11 @@ class Map(object):
     :param default_subdomain: The default subdomain for rules without a
                               subdomain defined.
     :param charset: charset of the url. defaults to ``"utf-8"``
-    :param strict_slashes: Take care of trailing slashes.
+    :param strict_slashes: If a rule ends with a slash but the matched
+        URL does not, redirect to the URL with a trailing slash.
+    :param merge_slashes: Merge consecutive slashes when matching or
+        building URLs. Matches will redirect to the normalized URL.
+        Slashes in variable parts are not merged.
     :param redirect_defaults: This will redirect to the default rule if it
                               wasn't visited that way. This helps creating
                               unique URLs.
@@ -1315,11 +1357,14 @@ class Map(object):
                           enabled the `host` parameter to rules is used
                           instead of the `subdomain` one.
 
-    .. versionadded:: 0.5
-        `sort_parameters` and `sort_key` was added.
+    .. versionchanged:: 1.0
+        Added ``merge_slashes``.
 
-    .. versionadded:: 0.7
-        `encoding_errors` and `host_matching` was added.
+    .. versionchanged:: 0.7
+        Added ``encoding_errors`` and ``host_matching``.
+
+    .. versionchanged:: 0.5
+        Added ``sort_parameters`` and ``sort_key``.
     """
 
     #: A dict of default converters to be used.
@@ -1331,6 +1376,7 @@ def __init__(
         default_subdomain="",
         charset="utf-8",
         strict_slashes=True,
+        merge_slashes=True,
         redirect_defaults=True,
         converters=None,
         sort_parameters=False,
@@ -1347,6 +1393,7 @@ def __init__(
         self.charset = charset
         self.encoding_errors = encoding_errors
         self.strict_slashes = strict_slashes
+        self.merge_slashes = merge_slashes
         self.redirect_defaults = redirect_defaults
         self.host_matching = host_matching
 
@@ -1752,6 +1799,8 @@ def match(self, path_info=None, method=None, return_rule=False, query_args=None)
             query_args = self.query_args
         method = (method or self.default_method).upper()
 
+        require_redirect = False
+
         path = u"%s|%s" % (
             self.map.host_matching and self.server_name or self.subdomain,
             path_info and "/%s" % path_info.lstrip("/"),
@@ -1761,10 +1810,10 @@ def match(self, path_info=None, method=None, return_rule=False, query_args=None)
         for rule in self.map._rules:
             try:
                 rv = rule.match(path, method)
-            except RequestSlash:
+            except RequestPath as e:
                 raise RequestRedirect(
                     self.make_redirect_url(
-                        url_quote(path_info, self.map.charset, safe="/:|+") + "/",
+                        url_quote(e.path_info, self.map.charset, safe="/:|+"),
                         query_args,
                     )
                 )
@@ -1810,6 +1859,13 @@ def _handle_match(match):
                     )
                 )
 
+            if require_redirect:
+                raise RequestRedirect(
+                    self.make_redirect_url(
+                        url_quote(path_info, self.map.charset, safe="/:|+"), query_args
+                    )
+                )
+
             if return_rule:
                 return rule, rv
             else:


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -62,6 +62,50 @@ def test_basic_routing():
     assert excinfo.value.new_url == "http://example.org/bar/?foo=bar"
 
 
+def test_merge_slashes_match():
+    url_map = r.Map(
+        [
+            r.Rule("/no/tail", endpoint="no_tail"),
+            r.Rule("/yes/tail/", endpoint="yes_tail"),
+            r.Rule("/with/<path:path>", endpoint="with_path"),
+            r.Rule("/no//merge", endpoint="no_merge", merge_slashes=False),
+        ]
+    )
+    adapter = url_map.bind("localhost", "/")
+
+    with pytest.raises(r.RequestRedirect) as excinfo:
+        adapter.match("/no//tail")
+
+    assert excinfo.value.new_url.endswith("/no/tail")
+
+    with pytest.raises(r.RequestRedirect) as excinfo:
+        adapter.match("/yes//tail")
+
+    assert excinfo.value.new_url.endswith("/yes/tail/")
+
+    assert adapter.match("/no/tail")[0] == "no_tail"
+    assert adapter.match("/yes/tail/")[0] == "yes_tail"
+
+    _, rv = adapter.match("/with/http://example.com/")
+    assert rv["path"] == "http://example.com/"
+    _, rv = adapter.match("/with/x//y")
+    assert rv["path"] == "x//y"
+
+    assert adapter.match("/no//merge")[0] == "no_merge"
+
+
+def test_merge_slashes_build():
+    url_map = r.Map(
+        [
+            r.Rule("/yes//merge", endpoint="yes_merge"),
+            r.Rule("/no//merge", endpoint="no_merge", merge_slashes=False),
+        ]
+    )
+    adapter = url_map.bind("localhost", "/")
+    assert adapter.build("yes_merge") == "/yes/merge"
+    assert adapter.build("no_merge") == "/no//merge"
+
+
 def test_strict_slashes_redirect():
     map = r.Map(
         [