Repository: tornadoweb/tornado (python)

Issue:
500 error if non-RFC Host is being passed in headers.
With 6.5.1 (and 6.5) I am getting 500 error if I add `Host: http//1.1.1.1:80` or similar non-RFC values.

```
2025-06-11 23:12:48,612 - tornado.application - ERROR - Uncaught exception
Traceback (most recent call last):
  File "/usr/local/lib/python3.12/site-packages/tornado/http1connection.py", line 222, in _read_message
    header_recv_future = delegate.headers_received(start_line, headers)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/tornado/routing.py", line 255, in headers_received
    request = httputil.HTTPServerRequest(
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/tornado/httputil.py", line 506, in __init__
    raise HTTPInputError("Invalid Host header: %r" % self.host)
tornado.httputil.HTTPInputError: Invalid Host header: 'http//1.1.1.1:80'
2025-06-11 23:12:48,614 - tornado.application - ERROR - Uncaught exception
Traceback (most recent call last):
  File "/usr/local/lib/python3.12/site-packages/tornado/http1connection.py", line 222, in _read_message
    header_recv_future = delegate.headers_received(start_line, headers)
                         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/tornado/routing.py", line 255, in headers_received
    request = httputil.HTTPServerRequest(
              ^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/tornado/httputil.py", line 506, in __init__
    raise HTTPInputError("Invalid Host header: %r" % self.host)
tornado.httputil.HTTPInputError: Invalid Host header: 'http//1.1.1.1:80'

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/lib/python3.12/site-packages/tornado/http1connection.py", line 221, in _read_message
    with _ExceptionLoggingContext(app_log):
         ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
  File "/usr/local/lib/python3.12/site-packages/tornado/http1connection.py", line 70, in __exit__
    raise _QuietException
tornado.http1connection._QuietException

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/usr/local/lib/python3.12/site-packages/tornado/http1connection.py", line 302, in _read_message
    delegate.on_connection_close()
  File "/usr/local/lib/python3.12/site-packages/tornado/routing.py", line 282, in on_connection_close
    assert self.delegate is not None
           ^^^^^^^^^^^^^^^^^^^^^^^^^
AssertionError
```
To reproduce:
`Dockerfile`
```
FROM python:3.12-slim
WORKDIR /app
RUN apt-get update && apt-get install -y \
    gcc \
    && rm -rf /var/lib/apt/lists/*
RUN pip install --no-cache-dir tornado==6.5.1 requests
COPY . .
EXPOSE 8888
CMD ["python", "server.py"]
```
`server.py`
```
#!/usr/bin/env python3

import asyncio
import tornado.web


class MainHandler(tornado.web.RequestHandler):

    def get(self):
        self.write({"message": "Hello from Tornado 6.5.1", "status": "running"})


def make_app():
    return tornado.web.Application([
        (r"/", MainHandler),
    ], debug=True)


async def main():
    app = make_app()
    app.listen(8888)

    print("Tornado server started on http://0.0.0.0:8888")
    print("Tornado version: 6.5.1")

    await asyncio.Event().wait()


if __name__ == "__main__":
    asyncio.run(main())
```
`client.py`
```
#!/usr/bin/env python3

import requests


def main() -> None:
    session = requests.Session()
    session.headers.update({"Host": "http//1.1.1.1:80"})

    try:
        response = session.get("http://localhost:8888/")
        print(f"Status: {response.status_code}")
        print(f"Response: {response.json()}")
    except Exception as e:
        print(f"Error: {e}")
    finally:
        session.close()


if __name__ == "__main__":
    main()
```
To run:
```
podman build -t tornado-bug . && podman run -it --rm -p 8888:8888 tornado-bug
```
and from another terminal: `python client.py`

Also I think this line can be removed 😄 
https://github.com/tornadoweb/tornado/blob/bb07ce9d1f0e9529b6fe9c2cebe78cfb238b4a0c/tornado/httputil.py#L505

Pull request description:
http1connection: Improve error logging for invalid host headers
This was previously being logged as an uncaught exception in application code, which is wrong for a malformed request. HTTPInputError now passes through the app-error logging to be caught and reported as a 400 (which logs at the warning level to the access log and info to the general log).

Fixes #3510

Patch:
diff --git a/tornado/http1connection.py b/tornado/http1connection.py
index 8dd0c9b6..5b0dd1b8 100644
--- a/tornado/http1connection.py
+++ b/tornado/http1connection.py
@@ -66,6 +66,9 @@ class _ExceptionLoggingContext:
     ) -> None:
         if value is not None:
             assert typ is not None
+            # Let HTTPInputError pass through to higher-level handler
+            if isinstance(value, httputil.HTTPInputError):
+                return None
             self.logger.error("Uncaught exception", exc_info=(typ, value, tb))
             raise _QuietException
 
diff --git a/tornado/routing.py b/tornado/routing.py
index 245070c7..ff833d5e 100644
--- a/tornado/routing.py
+++ b/tornado/routing.py
@@ -279,8 +279,8 @@ class _RoutingDelegate(httputil.HTTPMessageDelegate):
         self.delegate.finish()
 
     def on_connection_close(self) -> None:
-        assert self.delegate is not None
-        self.delegate.on_connection_close()
+        if self.delegate is not None:
+            self.delegate.on_connection_close()
 
 
 class _DefaultMessageDelegate(httputil.HTTPMessageDelegate):


Test patch:
diff --git a/tornado/test/httpclient_test.py b/tornado/test/httpclient_test.py
index 08c809a2..77c0d6eb 100644
--- a/tornado/test/httpclient_test.py
+++ b/tornado/test/httpclient_test.py
@@ -442,7 +442,7 @@ Transfer-Encoding: chunked
         # test if client hangs on tricky invalid gzip
         # curl/simple httpclient have different behavior (exception, logging)
         with ExpectLog(
-            app_log, "(Uncaught exception|Exception in callback)", required=False
+            gen_log, ".*Malformed HTTP message.*unconsumed gzip data", required=False
         ):
             try:
                 response = self.fetch("/invalid_gzip")
diff --git a/tornado/test/httpserver_test.py b/tornado/test/httpserver_test.py
index f197cfef..008078ed 100644
--- a/tornado/test/httpserver_test.py
+++ b/tornado/test/httpserver_test.py
@@ -462,6 +462,18 @@ class HTTPServerRawTest(AsyncHTTPTestCase):
             self.io_loop.add_timeout(datetime.timedelta(seconds=0.05), self.stop)
             self.wait()
 
+    def test_invalid_host_header_with_whitespace(self):
+        with ExpectLog(
+            gen_log, ".*Malformed HTTP message.*Invalid Host header", level=logging.INFO
+        ):
+            self.stream.write(b"GET / HTTP/1.0\r\nHost: foo bar\r\n\r\n")
+            start_line, headers, response = self.io_loop.run_sync(
+                lambda: read_stream_body(self.stream)
+            )
+            self.assertEqual("HTTP/1.1", start_line.version)
+            self.assertEqual(400, start_line.code)
+            self.assertEqual("Bad Request", start_line.reason)
+
     def test_chunked_request_body(self):
         # Chunked requests are not widely supported and we don't have a way
         # to generate them in AsyncHTTPClient, but HTTPServer will read them.