Repository: pallets/werkzeug

Issue:
long host header with port fails idna encoding
Request from a service with hostname longer than 64 characters fails with obscure HTTP:400 error.

Hi, 
i have a Flask App running on a Kubernetes cluster and the app is behind a Kubernetes service, which has quite long name (~60 chars); 
After i upgraded the Flask from `2.2.3` to `2.3.2`, i started to get HTTP400 errors when i send a request over the service, but it works when i shorten the name of the Kubernetes service or i use POD address directly as Kubernetes health probe does.

```html
<!doctype html>
<html lang=en>
<title>400 Bad Request</title>
<h1>Bad Request</h1>
<p>The browser (or proxy) sent a request that this server could not understand.</p>
```

I added minimum app and test-case into comments.

I was able to see the actual error by setting "TRAP_BAD_REQUEST_ERRORS"  as described in the [comment](https://github.com/pallets/flask/blob/ea316d232a00d6c66a48a13bf36bcb9885326a55/flask/app.py#L1690).

Full traceback:
```bash
[11/May/2023:15:16:15 +0000][100.96.7.10][ua: "-"][referer: "-"]GET sync HTTP/1.1,500,1 ms
werkzeug.exceptions.BadHost: 400 Bad Request: The browser (or proxy) sent a request that this server could not understand.
    raise BadHost() from e
  File "/usr/local/lib/python3.11/site-packages/werkzeug/routing/map.py", line 257, in bind
           ^^^^^^^^^
    return Map.bind(
  File "/usr/local/lib/python3.11/site-packages/werkzeug/routing/map.py", line 368, in bind_to_environ
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    return self.url_map.bind_to_environ(
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1883, in create_url_adapter
                       ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    self.url_adapter = app.create_url_adapter(self.request)
  File "/usr/local/lib/python3.11/site-packages/flask/ctx.py", line 316, in __init__
    ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    raise request.routing_exception  # type: ignore
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1440, in raise_routing_exception
    self.raise_routing_exception(req)
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1458, in dispatch_request
         ^^^^^^^^^^^^^^^^^^^^^^^
    rv = self.dispatch_request()
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1484, in full_dispatch_request
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    rv = self.handle_user_exception(e)
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 1486, in full_dispatch_request
               ^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    response = self.full_dispatch_request()
  File "/usr/local/lib/python3.11/site-packages/flask/app.py", line 2190, in wsgi_app
Traceback (most recent call last):

The above exception was the direct cause of the following exception:

UnicodeError: encoding with 'idna' codec failed (UnicodeError: label too long)
                  ^^^^^^^^^^^^^^^^^^^^^^^^^^
    server_name = server_name.encode("idna").decode("ascii")
  File "/usr/local/lib/python3.11/site-packages/werkzeug/routing/map.py", line 255, in bind
Traceback (most recent call last):

The above exception was the direct cause of the following exception:

UnicodeError: label too long
    raise UnicodeError("label too long")
  File "/usr/local/lib/python3.11/encodings/idna.py", line 165, in encode
Traceback (most recent call last):
```

I googled a bit and seems it's [known issue](https://github.com/python/cpython/issues/77139) and it has actually been reported already in 2018. Small snippet that could replicate issue:

```bash
# length of name of new kubernetes service with port is longer than 64 characters
>>> len("data-sync-service-dcfe8582-45d8-41e5-bc45-3d7a94cfa9cd-df265:2008")
65

>>> "data-sync-service-dcfe8582-45d8-41e5-bc45-3d7a94cfa9cd-df265:2008".encode("idna").decode("ascii")                                                     Traceback (most recent call last):
  File "/usr/sap/H00/HDB00/exe/Python3/lib/python3.8/encodings/idna.py", line 167, in encode
    raise UnicodeError("label too long")

UnicodeError: label too long
```

* I would expect to see a error message in logs or get [HTTP 414](https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/414) response. It would make it easier to find the root cause.

* It would be great if  newer version of Flask could handle longer `server_names`, because it's still working in `2.2.3`.

Environment:

- Python version: Python 3.11.3
- Flask version: flask==2.3.2



Discussion:
## MVP app

```python
# usage: flask --app hello run
from flask import Flask

app = Flask(__name__)
# uncomment to see actual error and exception
#app.config["TRAP_BAD_REQUEST_ERRORS"] = True

@app.route("/")
def hello_world():
    return "<p>Hello, World!</p>"
```


## MVP test case

```python
# test_hello.py
# usage: 
#   python -m unittest test_hello.py

import unittest

import hello


class TestHello(unittest.TestCase):
    def setUp(self):
        self.app = hello.app
        self.app_context = self.app.app_context()
        self.app_context.push()
        self.client = self.app.test_client()

    def tearDown(self):
        self.app_context.pop()

    def test_hello(self):
        response = self.client.get('/')

        assert response.status_code == 200

    def test_with_long_long_host_name(self):
        test_base_url = "data-sync-service-dcfe8582-45d8-41e5-bc45-3d7a94cfa9cd-df265:2008"

        response = self.client.get("/", headers=[("Host", test_base_url)])

        assert response.status_code == 200, f"Got status code {response.status_code}, expected 200"
```
Whatever you're using is generating names that are invalid according to the DNS standard.

https://datatracker.ietf.org/doc/html/rfc1035#section-2.3.4

> Various objects and parameters in the DNS have size limits.
> labels          63 octets or less
> names           255 octets or less

"label" refers to each dot-separated part, "name" refers to the entire string.
@davidism no quite, the name of kubernetes service is valid DNS label - it's less than 63 characters: https://kubernetes.io/docs/concepts/overview/working-with-objects/names/#rfc-1035-label-names

Problem is that a http client  sends the DNS label with port number: https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/host 
And it makes the `idna` parser to fail.

I see, the `server_name` parameter might have a `:port` suffix, so we should split that first since it wouldn't take part in encoding.
I encountered the same problem.

How about also adding `server_name` to `BadHost` raised at https://github.com/pallets/werkzeug/blob/2.3.4/src/werkzeug/routing/map.py#L257. It would have certainly sped up finding the root cause.

Resolving patch:
diff --git a/src/werkzeug/routing/map.py b/src/werkzeug/routing/map.py
--- a/src/werkzeug/routing/map.py
+++ b/src/werkzeug/routing/map.py
@@ -251,6 +251,9 @@ def bind(
         if path_info is None:
             path_info = "/"
 
+        # Port isn't part of IDNA, and might push a name over the 63 octet limit.
+        server_name, port_sep, port = server_name.partition(":")
+
         try:
             server_name = server_name.encode("idna").decode("ascii")
         except UnicodeError as e:
@@ -258,7 +261,7 @@ def bind(
 
         return MapAdapter(
             self,
-            server_name,
+            f"{server_name}{port_sep}{port}",
             script_name,
             subdomain,
             url_scheme,


Test patch:
diff --git a/tests/test_routing.py b/tests/test_routing.py
--- a/tests/test_routing.py
+++ b/tests/test_routing.py
@@ -1045,6 +1045,14 @@ def test_external_building_with_port_bind_to_environ_wrong_servername():
     assert adapter.subdomain == "<invalid>"
 
 
+def test_bind_long_idna_name_with_port():
+    map = r.Map([r.Rule("/", endpoint="index")])
+    adapter = map.bind("🐍" + "a" * 52 + ":8443")
+    name, _, port = adapter.server_name.partition(":")
+    assert len(name) == 63
+    assert port == "8443"
+
+
 def test_converter_parser():
     args, kwargs = r.parse_converter_args("test, a=1, b=3.0")