Reject an ambiguous or invalid Host in a request

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
This commit is contained in:
yhirose
2026-10-11 23:39:50 -04:00
parent 1f12bbaabb
commit 0acbee16dc
2 changed files with 74 additions and 0 deletions
+22
View File
@@ -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;
+52
View File
@@ -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;