Repository: pallets/werkzeug (python)

Issue:
utils.get_current_url() fails to parse IPv6 IP address
(This has similar smells to it as #2903)

By the looks of it, https://github.com/pallets/werkzeug/blob/9a69323223616188d50f337ba03831a944e82d6f/src/werkzeug/sansio/utils.py#L97-L97 when called from https://github.com/pallets/werkzeug/blob/9a69323223616188d50f337ba03831a944e82d6f/src/werkzeug/sansio/request.py#L205-L210 isn't coping with a bare IPv6 address?

```
ValueError: Port could not be cast to integer value as '1901:0:3dbd::'
    raise ValueError(f"Port could not be cast to integer value as {port!r}")
  File "/usr/local/lib/python3.11/urllib/parse.py", line 182, in port
       ^^^^^^^^^^
    if parts.port:
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/urls.py", line 98, in uri_to_iri
           ^^^^^^^^^^^^^^^^^^^^^^^^
    return uri_to_iri("".join(url))
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/sansio/utils.py", line 137, in get_current_url
           ^^^^^^^^^^^^^^^^
    return get_current_url(
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/sansio/request.py", line 210, in url
            ^^^^^^^^^^^^^^
    value = self.fget(obj)  # type: ignore
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/utils.py", line 107, in __get__
                  ^^^^^^^^^^^^^^^^^
    "requestUrl": flask.request.url,
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/google/cloud/logging_v2/handlers/_helpers.py", line 79, in get_request_data_from_flask
                                                                    ^^^^^^^^^
    http_request, http_trace_id, http_span_id, http_trace_sampled = checker()
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/google/cloud/logging_v2/handlers/_helpers.py", line 273, in get_request_data
        ^^^^^^^^^^^^^^^^^^
    ) = get_request_data()
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/google/cloud/logging_v2/handlers/handlers.py", line 98, in filter
             ^^^^^^^^^^^^^^^^
    result = f.filter(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 830, in filter
         ^^^^^^^^^^^^^^^^^^^
    rv = self.filter(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 974, in handle
    hdlr.handle(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1706, in callHandlers
    self.callHandlers(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1644, in handle
    self.handle(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1634, in _log
    self._log(ERROR, msg, args, **kwargs)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1518, in error
    self.logger.error(
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 1414, in log_exception
    self.log_exception(exc_info)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 1393, in handle_exception
               ^^^^^^^^^^^^^^^^^^^^^^^^
    response = self.handle_exception(e)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 2193, in wsgi_app
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return wsgi_app(environ, start_response)
  File "/osv/gcp/appengine/main.py", line 37, in middleware
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return self.application(environ, start_response)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/whitenoise/base.py", line 81, in __call__
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return self.wsgi_app(environ, start_response)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 2213, in __call__
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    respiter = self.wsgi(environ, resp.start_response)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/gunicorn/workers/gthread.py", line 334, in handle_request
                ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Traceback (most recent call last):  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/gunicorn/workers/gthread.py", line 282, in handle    keepalive = self.handle_request(req, conn)
During handling of the above exception, another exception occurred:
ValueError: Port could not be cast to integer value as '1901:0:3dbd::'
    raise ValueError(f"Port could not be cast to integer value as {port!r}")
  File "/usr/local/lib/python3.11/urllib/parse.py", line 182, in port
       ^^^^^^^^^^
    if parts.port:
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/urls.py", line 98, in uri_to_iri
           ^^^^^^^^^^^^^^^^^^^^^^^^
    return uri_to_iri("".join(url))
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/sansio/utils.py", line 137, in get_current_url
           ^^^^^^^^^^^^^^^^
    return get_current_url(
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/sansio/request.py", line 210, in url
            ^^^^^^^^^^^^^^
    value = self.fget(obj)  # type: ignore
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/utils.py", line 107, in __get__
                  ^^^^^^^^^^^^^^^^^
    "requestUrl": flask.request.url,
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/google/cloud/logging_v2/handlers/_helpers.py", line 79, in get_request_data_from_flask
                                                                    ^^^^^^^^^
    http_request, http_trace_id, http_span_id, http_trace_sampled = checker()
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/google/cloud/logging_v2/handlers/_helpers.py", line 273, in get_request_data
        ^^^^^^^^^^^^^^^^^^
    ) = get_request_data()
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/google/cloud/logging_v2/handlers/handlers.py", line 98, in filter
             ^^^^^^^^^^^^^^^^
    result = f.filter(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 830, in filter
         ^^^^^^^^^^^^^^^^^^^
    rv = self.filter(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 974, in handle
    hdlr.handle(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1706, in callHandlers
    self.callHandlers(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1644, in handle
    self.handle(record)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1634, in _log
    self._log(INFO, msg, args, **kwargs)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 1489, in info
    root.info(msg, *args, **kwargs)
  File "/usr/local/lib/python3.11/logging/__init__.py", line 2141, in info
    logging.info('Handled %s - Path attempted: %s', error, request.path)
  File "/osv/gcp/appengine/frontend_handlers.py", line 707, in not_found_error
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return self.ensure_sync(handler)(e)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 1281, in handle_http_exception
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return self.handle_http_exception(e)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 1341, in handle_user_exception
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    rv = self.handle_user_exception(e)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 1486, in full_dispatch_request
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
Traceback (most recent call last):  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 2190, in wsgi_app    response = self.full_dispatch_request()
During handling of the above exception, another exception occurred:
werkzeug.exceptions.NotFound: 404 Not Found: The requested URL was not found on the server. If you entered the URL manually please check your spelling and try again.
    raise self.mapping[code](*args, **kwargs)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/werkzeug/exceptions.py", line 863, in __call__
    current_app.aborter(code, *args, **kwargs)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/helpers.py", line 277, in abort
    abort(404)
  File "/osv/gcp/appengine/frontend_handlers.py", line 254, in vulnerability_redirector
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return self.ensure_sync(self.view_functions[rule.endpoint])(**view_args)
  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 1469, in dispatch_request
         ^^^^^^^^^^^^^^^^^^^^^^^
Traceback (most recent call last):  File "/osv/gcp/appengine/.venv/lib/python3.11/site-packages/flask/app.py", line 1484, in full_dispatch_request    rv = self.dispatch_request()
```

Environment:

- Python version: 3.11
- Werkzeug version: 3.0.6


Pull request description:
wrap IPv6 SERVER_NAME in []
When using IPv6, `SERVER_NAME` is sent as `::1`. When the `Host` header isn't present, `SERVER_NAME` is used to construct `request.host`. Wrap it with `[]` so that it matches the format that the `Host` header would have.

fixes #2993 

Patch:
diff --git a/CHANGES.rst b/CHANGES.rst
index 6a2fd2ed..27a89e83 100644
--- a/CHANGES.rst
+++ b/CHANGES.rst
@@ -9,6 +9,9 @@ Unreleased
     ``list``, ``tuple``, or ``set`` when passing multiple values. It had been
     changed to accept any ``Collection``, but this matched types that should be
     treated as single values, such as ``bytes``. :issue:`2994`
+-   When the ``Host`` header is not set and ``Request.host`` falls back to the
+    WSGI ``SERVER_NAME`` value, if that value is an IPv6 address it is wrapped
+    in ``[]`` to match the ``Host`` header. :issue:`2993`
 
 
 Version 3.1.2
diff --git a/src/werkzeug/sansio/utils.py b/src/werkzeug/sansio/utils.py
index 14fa0ac8..ff7ceda3 100644
--- a/src/werkzeug/sansio/utils.py
+++ b/src/werkzeug/sansio/utils.py
@@ -71,6 +71,9 @@ def get_host(
     :return: Host, with port if necessary.
     :raise ~werkzeug.exceptions.SecurityError: If the host is not
         trusted.
+
+    .. versionchanged:: 3.1.3
+        If ``SERVER_NAME`` is IPv6, it is wrapped in ``[]``.
     """
     host = ""
 
@@ -79,6 +82,11 @@ def get_host(
     elif server is not None:
         host = server[0]
 
+        # If SERVER_NAME is IPv6, wrap it in [] to match Host header.
+        # Check for : because domain or IPv4 can't have that.
+        if ":" in host and host[0] != "[":
+            host = f"[{host}]"
+
         if server[1] is not None:
             host = f"{host}:{server[1]}"
 


Test patch:
diff --git a/tests/sansio/test_utils.py b/tests/sansio/test_utils.py
index d43de66c..a63e7c66 100644
--- a/tests/sansio/test_utils.py
+++ b/tests/sansio/test_utils.py
@@ -14,12 +14,16 @@ from werkzeug.sansio.utils import get_host
         ("https", "spam", None, "spam"),
         ("https", "spam:443", None, "spam"),
         ("http", "spam:8080", None, "spam:8080"),
+        ("http", "127.0.0.1:8080", None, "127.0.0.1:8080"),
+        ("http", "[::1]:8080", None, "[::1]:8080"),
         ("ws", "spam", None, "spam"),
         ("ws", "spam:80", None, "spam"),
         ("wss", "spam", None, "spam"),
         ("wss", "spam:443", None, "spam"),
         ("http", None, ("spam", 80), "spam"),
         ("http", None, ("spam", 8080), "spam:8080"),
+        ("http", None, ("127.0.0.1", 8080), "127.0.0.1:8080"),
+        ("http", None, ("::1", 8080), "[::1]:8080"),
         ("http", None, ("unix/socket", None), "unix/socket"),
         ("http", "spam", ("eggs", 80), "spam"),
     ],