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;