From 0acbee16dc02d4d59799898c55c0e3fcecc7ea94 Mon Sep 17 00:00:00 2001 From: yhirose Date: Sun, 11 Oct 2026 23:39:50 -0400 Subject: [PATCH] Reject an ambiguous or invalid Host in a request MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A request with more than one Host field line, or with a Host value holding a byte that cannot appear in uri-host [ ":" port ], is now answered with 400 (RFC 9112 §3.2). The handler sees only the first Host line, so such a request could reach it with an authority that differs from the one an intermediary acted on. Only the byte set is checked, not the full grammar. An empty value stays valid, and a request without a Host line is still accepted. Close #2623 --- httplib.h | 22 ++++++++++++++++++++++ test/test.cc | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 74 insertions(+) diff --git a/httplib.h b/httplib.h index 0114e27f..b261b80a 100644 --- a/httplib.h +++ b/httplib.h @@ -3968,6 +3968,7 @@ bool is_field_content(const std::string &s); bool is_field_value(const std::string &s); bool is_field_valid(const std::string &name, const std::string &value); bool is_request_target(const std::string &s); +bool is_host(const std::string &s); } // namespace fields } // namespace detail @@ -10322,6 +10323,17 @@ inline bool is_request_target(const std::string &s) { return std::all_of(s.begin(), s.end(), is_field_vchar); } +// RFC 9110 §7.2: the bytes of uri-host [ ":" port ], i.e. unreserved, +// sub-delims, "%", ":" and the IP-literal brackets. An empty value is valid. +inline bool is_host(const std::string &s) { + return std::all_of(s.begin(), s.end(), [](char c) { + return is_ascii_alnum(c) || c == '-' || c == '.' || c == '_' || c == '~' || + c == '!' || c == '$' || c == '&' || c == '\'' || c == '(' || + c == ')' || c == '*' || c == '+' || c == ',' || c == ';' || + c == '=' || c == '%' || c == ':' || c == '[' || c == ']'; + }); +} + } // namespace fields inline bool perform_websocket_handshake(Stream &strm, Request &req, @@ -14580,6 +14592,16 @@ Server::process_request(Stream &strm, const std::string &remote_addr, return write_response(strm, close_connection, req, res); } + // RFC 9112 §3.2: Reject more than one Host field line or an invalid Host + // value, so that every recipient of the request sees the same authority. + if (req.get_header_value_count("Host") > 1 || + !detail::fields::is_host(req.get_header_value("Host"))) { + connection_closed = true; + res.status = StatusCode::BadRequest_400; + output_error_log(Error::InvalidHeaders, &req); + return write_response(strm, close_connection, req, res); + } + // Check if the request URI doesn't exceed the limit if (req.target.size() > CPPHTTPLIB_REQUEST_URI_MAX_LENGTH) { connection_closed = true; diff --git a/test/test.cc b/test/test.cc index 33a91092..0541ff72 100644 --- a/test/test.cc +++ b/test/test.cc @@ -19686,6 +19686,58 @@ TEST(RequestSmugglingTest, DuplicateContentLengthSameValues) { ASSERT_EQ("HTTP/1.1 200 OK", response.substr(0, response.find("\r\n"))); } +TEST(HostHeaderValidationTest, AmbiguousOrInvalidHostRejected) { + Server svr; + svr.Get("/test", [](const Request &, Response &res) { + res.set_content("ok", "text/plain"); + }); + + auto port = svr.bind_to_any_port(HOST); + thread t = thread([&] { svr.listen_after_bind(); }); + auto se = detail::scope_exit([&] { + svr.stop(); + t.join(); + ASSERT_FALSE(svr.is_running()); + }); + + svr.wait_until_ready(); + + auto status_line = [&](const std::string &host_lines) { + std::string res; + send_request( + 5, "GET /test HTTP/1.1\r\n" + host_lines + "Connection: close\r\n\r\n", + &res, port); + return res.substr(0, res.find("\r\n")); + }; + + const char *accepted[] = { + "Host: localhost\r\n", + "Host: a.example:8080\r\n", + "Host: my_service\r\n", + "Host: [::1]:8080\r\n", + "Host: [fe80::1%25eth0]\r\n", + "Host: \r\n", // RFC 9110 §7.2: the target URI has no authority + }; + for (auto host_lines : accepted) { + EXPECT_EQ("HTTP/1.1 200 OK", status_line(host_lines)) << host_lines; + } + + const char *rejected[] = { + "Host: a.example\r\nHost: b.example\r\n", + "Host: a.example\r\nHost: a.example\r\n", + "Host: a@b.example\r\n", + "Host: a.example/b\r\n", + "Host: a.example\\b\r\n", + "Host: a.example?b\r\n", + "Host: a.example#b\r\n", + "Host: a.example b.example\r\n", + }; + for (auto host_lines : rejected) { + EXPECT_EQ("HTTP/1.1 400 Bad Request", status_line(host_lines)) + << host_lines; + } +} + TEST(HeaderSmugglingTest, ChunkedTrailerHeadersMerged) { Server svr;