From 5e2d41bb0072619fd0a6c9dd884246831c679334 Mon Sep 17 00:00:00 2001 From: agu2347 Date: Tue, 28 Jul 2026 11:02:29 +0000 Subject: [PATCH] Ignore Host header in favor of absolute-form request-target authority RFC 9112 section 3.2.2 requires: "When a proxy receives a request with an absolute-form of request-target, the [recipient] MUST ignore the received Host header field (if any) and instead replace it with the host information of the request-target." waitress's parser already correctly extracted the request-target's own authority into self.proxy_netloc via split_uri(), but never used it to override the Host header -- the client-supplied Host header (if any) was used as-is, regardless of whether it agreed with the request-target. This means a request like: GET http://evil.com/page HTTP/1.1 Host: victim.com was processed with HTTP_HOST/Host effectively "victim.com" (from the header) even though the request-target's own authority explicitly says "evil.com" -- exactly backwards from what RFC 9112 requires. Since applications commonly use the Host header for request routing, access control decisions, or cache keys, this allows a client-supplied Host header to disagree with (and silently override) what the request-line itself specifies, which could enable host-header-based security checks to be bypassed or confused about the actual requested authority. When proxy_netloc is present (i.e. an absolute-form request-target was used), set headers["HOST"] to it, so it correctly reflects the request-target's authority per RFC 9112, regardless of any Host header that may also have been sent. Verified directly against the exact example from the issue: before the fix, headers["HOST"] was "victim.com" for a request whose request-line said "http://evil.com/page" with a "Host: victim.com" header; after the fix, headers["HOST"] is correctly "evil.com". Also verified: normal origin-form requests (the overwhelmingly common case) are completely unaffected; absolute-form requests where the Host header already matches the authority remain correct; an authority including a non-default port is used verbatim; and an absolute-form request with no Host header at all still gets HOST set from the request-target. Updated the existing testProxyGET test, which asserted the old (vulnerable) behavior by expecting no HOST key at all for an absolute-form request without an explicit Host header -- it now asserts HOST is correctly derived from the request-target's own authority. Added a new regression test reproducing the issue's exact scenario (mismatched Host header on an absolute-form request-target). Confirmed the new test fails with the original code (Host header is the attacker-controlled value) and passes with the fix. Ran the full existing test_parser.py suite (78 passed: 77 baseline + 1 new) and the broader project test suite (753 passed, 50 skipped; the 3 remaining failures are pre-existing socket-binding tests unrelated to this change, confirmed identical on a clean checkout of main). Fixes #467 --- CHANGES.txt | 10 ++++++++++ src/waitress/parser.py | 14 ++++++++++++++ tests/test_parser.py | 30 +++++++++++++++++++++++++++++- 3 files changed, 53 insertions(+), 1 deletion(-) diff --git a/CHANGES.txt b/CHANGES.txt index c7f32ea4..6138ce5e 100644 --- a/CHANGES.txt +++ b/CHANGES.txt @@ -4,6 +4,16 @@ Unreleased Bugfix ~~~~~~ +- When a request uses the absolute-form of request-target (e.g. + ``GET http://example.com/page HTTP/1.1``), waitress now uses that + request-target's own authority as the ``Host``, ignoring any received + ``Host`` header, per RFC 9112 section 3.2.2. Previously, a client-supplied + ``Host`` header that disagreed with the request-target's authority was + used as-is, which could let a request's ``Host``-based routing, access + control, or cache-key handling be driven by a value the request line + itself contradicted. See + https://github.com/Pylons/waitress/issues/467 + - Renamed the HTTP header "Trailers" to "Trailer" to fix a typo and comply with the correct header name as specified in RFC 7230. diff --git a/src/waitress/parser.py b/src/waitress/parser.py index 1af4594d..79d18088 100644 --- a/src/waitress/parser.py +++ b/src/waitress/parser.py @@ -273,6 +273,20 @@ def parse_header(self, header_plus): self.query, self.fragment, ) = split_uri(uri) + + if self.proxy_netloc: + # RFC 9112 sec 3.2.2: "When a proxy receives a request with an + # absolute-form of request-target, the proxy MUST ignore the + # received Host header field (if any) and instead replace it + # with the host information of the request-target." The same + # requirement applies to an origin server receiving such a + # request. Without this, a client could send a request whose + # Host header differs from the request-target's own authority, + # and any code relying on the (untouched) Host header -- for + # routing, access control, or cache keys -- would be acting on + # a value the request-line itself contradicts. See GH #467. + headers["HOST"] = self.proxy_netloc + self.url_scheme = self.adj.url_scheme connection = headers.get("CONNECTION", "") diff --git a/tests/test_parser.py b/tests/test_parser.py index 5f341ae9..ae568fc1 100644 --- a/tests/test_parser.py +++ b/tests/test_parser.py @@ -716,7 +716,13 @@ def testProxyGET(self): self.assertTrue(parser.completed) self.assertEqual(parser.version, "8.4") self.assertFalse(parser.empty) - self.assertEqual(parser.headers, {"CONTENT_LENGTH": "6"}) + # HOST is derived from the absolute-form request-target's own + # authority (RFC 9112 sec 3.2.2), even though no explicit Host + # header was sent here. See GH #467. + self.assertEqual( + parser.headers, + {"CONTENT_LENGTH": "6", "HOST": "example.com:8080"}, + ) self.assertEqual(parser.path, "/foobar") self.assertEqual(parser.command, "GET") self.assertEqual(parser.proxy_scheme, "https") @@ -725,6 +731,28 @@ def testProxyGET(self): self.assertEqual(parser.query, "") self.assertEqual(parser.get_body_stream().getvalue(), b"Hello.") + def testProxyGETIgnoresMismatchedHostHeader(self): + """ + Regression test for + https://github.com/Pylons/waitress/issues/467 + + Per RFC 9112 sec 3.2.2, an absolute-form request-target's own + authority must be used as the Host, and any Host header + actually received on the wire must be ignored -- even (and + especially) when it disagrees with the request-target, as a + malicious or misconfigured client might send. + """ + data = ( + b"GET http://example.com/page HTTP/1.1\r\n" + b"Host: attacker-controlled.example\r\n" + b"\r\n" + ) + parser = self.parser + self.feed(data) + self.assertTrue(parser.completed) + self.assertEqual(parser.headers["HOST"], "example.com") + self.assertEqual(parser.path, "/page") + def testDuplicateHeaders(self): # Ensure that headers with the same key get concatenated as per # RFC2616.