Repository: pallets/werkzeug (python)

Issue:
function `append_slash_redirect` misredirects
file `test.py`

```python
from flask import Flask, request
from werkzeug.utils import append_slash_redirect

app = Flask("test")

@app.route('/<path:path>')
def r(path):
    if not path.endswith('/'):
        return append_slash_redirect(request.environ)
    return path
```
run `FLASK_APP=test.py flask run`

A request for http://localhost:5000/parent/child gives a redirect that browsers interpret as `http://localhost:5000/parent/parent/child/`

Expecting a redirect that browsers interpret as `http://localhost:5000/parent/child/`

Possible solution:

The code strips the front '/' which leads to this situation. i.e. the returned location header is relative: 'parent/child/'
Use `environ["PATH_INFO"].rstrip("/") + "/"` instead


Pull request description:
Fix append_slash_redirect when PATH_INFO has internal slashes
(This seems to have been a contentious issue — please don't hate me.)

This addresses an issue — reported before in #1972 — namely that `werkzeug.utils.append_slash_redirect` does not work correctly if `PATH_INFO` contains internal slashes.

Suppose a request comes in for `http://example.com/app/foo/bar`.  Assume this makes it to the WSGI app with `environ["SCRIPT_NAME"] = "/app"` and `environ["PATH_INFO"] = "/foo/bar"`.  The app, in order to append a slash to the URL returns `append_slash_redirect(environ)`.  As things stand, this issues a redirect to the relative path `"foo/bar/"`.  That path gets interpreted relative to the base URL implied by the original request (`"http://example.com/app/foo/bar"`) resulting in a final location of `"http://example.com/app/foo/foo/bar/"`.  This is not quite what is wanted — one `foo` is enough for us.

The solution is to redirect to a relative path (as is currently done), but redirect to a relative path that contains only the trailing component PATH_INFO.

This PR fixes that and includes new tests that exercise the problem.


## Relation to Prior Work

Note that this issue is subtly different than that purportedly "fixed" by PR #1538.  PR #1538, as demonstrated by the tests included in that PR wants to redirect to an absolute path based on `PATH_INFO`. This results in (incorrectly) discarding any leading path components that may be in SCRIPT_NAME.

There is also #1842.  Due to its brevity, it is unclear precisely what issue is being described therein, but the proposed fix is the same as the (incorrect) solution proposed in #1538.


<!--
Before opening a PR, open a ticket describing the issue or feature the
PR will address. Follow the steps in CONTRIBUTING.rst.

Replace this comment with a description of the change. Describe how it
addresses the linked ticket.
-->

<!--
Link to relevant issues or previous PRs, one per line. Use "fixes" to
automatically close an issue.
-->

## Issues Fixed

- fixes #1972

<!--
Ensure each step in CONTRIBUTING.rst is complete by adding an "x" to
each box below.

If only docs were changed, these aren't relevant and can be removed.
-->

## Checklist

- [x] Add tests that demonstrate the correct behavior of the change. Tests should fail without the change.
- [n/a] Add or update relevant docs, in the docs folder and in code.
- [x] Add an entry in `CHANGES.rst` summarizing the change and linking to the issue.
- [n/a] Add `.. versionchanged::` entries in any relevant code docs.
- [x] Run `pre-commit` hooks and fix any issues.
- [x] Run `pytest` and `tox`, no tests failed.


Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index bbc18357..ada72a7b 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -88,6 +88,8 @@ Unreleased
     match early. :pr:`2350`
 -   The test client doesn't set duplicate headers for ``CONTENT_LENGTH``
     and ``CONTENT_TYPE``. :pr:`2348`
+-   ``append_slash_redirect`` handles ``PATH_INFO`` with internal
+    slashes. :issue:`1972`, :pr:`2338`
 
 
 Version 2.0.3
diff --git a/src/werkzeug/utils.py b/src/werkzeug/utils.py
index eb8b5172..3293ad04 100644
--- a/src/werkzeug/utils.py
+++ b/src/werkzeug/utils.py
@@ -288,17 +288,35 @@ def redirect(
 
 
 def append_slash_redirect(environ: "WSGIEnvironment", code: int = 301) -> "Response":
-    """Redirects to the same URL but with a slash appended.  The behavior
-    of this function is undefined if the path ends with a slash already.
+    """Redirect to the current URL with a slash appended.
 
-    :param environ: the WSGI environment for the request that triggers
-                    the redirect.
+    If the current URL is ``/user/42``, the redirect URL will be
+    ``42/``. When joined to the current URL during response
+    processing or by the browser, this will produce ``/user/42/``.
+
+    The behavior is undefined if the path ends with a slash already. If
+    called unconditionally on a URL, it may produce a redirect loop.
+
+    :param environ: Use the path and query from this WSGI environment
+        to produce the redirect URL.
     :param code: the status code for the redirect.
+
+    .. versionchanged:: 2.1
+        Produce a relative URL that only modifies the last segment.
+        Relevant when the current path has multiple segments.
     """
-    new_path = environ["PATH_INFO"].strip("/") + "/"
+    tail = environ["PATH_INFO"].rpartition("/")[2]
+
+    if not tail:
+        new_path = "./"
+    else:
+        new_path = f"{tail}/"
+
     query_string = environ.get("QUERY_STRING")
+
     if query_string:
-        new_path += f"?{query_string}"
+        new_path = f"{new_path}?{query_string}"
+
     return redirect(new_path, code)
 
 


Test patch:
diff --git a/tests/test_utils.py b/tests/test_utils.py
index 52c6f84f..3f70f1a9 100644
--- a/tests/test_utils.py
+++ b/tests/test_utils.py
@@ -236,14 +236,28 @@ def test_header_set_duplication_bug():
     )
 
 
-def test_append_slash_redirect():
+@pytest.mark.parametrize(
+    "path, base_url, expected_location",
+    [
+        ("foo", "http://example.org/app", "http://example.org/app/foo/"),
+        ("/foo", "http://example.org/app", "http://example.org/app/foo/"),
+        ("/foo/bar", "http://example.org/", "http://example.org/foo/bar/"),
+        ("/foo/bar", "http://example.org/app", "http://example.org/app/foo/bar/"),
+        ("/foo?baz", "http://example.org/", "http://example.org/foo/?baz"),
+        ("/foo/", "http://example.org/", "http://example.org/foo/"),
+        ("/foo/", "http://example.org/app", "http://example.org/app/foo/"),
+        ("/", "http://example.org/", "http://example.org/"),
+        ("/", "http://example.org/app", "http://example.org/app/"),
+    ],
+)
+def test_append_slash_redirect(path, base_url, expected_location):
     def app(env, sr):
         return utils.append_slash_redirect(env)(env, sr)
 
     client = Client(app)
-    response = client.get("foo", base_url="http://example.org/app")
+    response = client.get(path, base_url=base_url)
     assert response.status_code == 301
-    assert response.headers["Location"] == "http://example.org/app/foo/"
+    assert response.headers["Location"] == expected_location
 
 
 def test_cached_property_doc():