diff --git a/src/httpserver.cpp b/src/httpserver.cpp index a1b3e7a2f1e..0b3d5d06cfb 100644 --- a/src/httpserver.cpp +++ b/src/httpserver.cpp @@ -717,6 +717,7 @@ void UnregisterHTTPHandler(const std::string &prefix, bool exactMatch) } namespace http_bitcoin { +using util::Split; std::optional HTTPHeaders::FindFirst(const std::string_view key) const { @@ -818,4 +819,74 @@ std::string HTTPResponse::StringifyHeaders() const HTTPStatusReasonString(m_status), m_headers.Stringify()); } + +bool HTTPRequest::LoadControlData(LineReader& reader) +{ + auto maybe_line = reader.ReadLine(); + if (!maybe_line) return false; + const std::string_view& request_line = *maybe_line; + + // Request Line aka Control Data https://httpwg.org/specs/rfc9110.html#rfc.section.6.2 + // Three words separated by spaces, terminated by \n or \r\n + if (request_line.length() < MIN_REQUEST_LINE_LENGTH) throw std::runtime_error("HTTP request line too short"); + + // NUL is not a valid tchar and would silently truncate + // C-string-based parsers rather than being rejected as malformed. + // tchar: https://www.rfc-editor.org/info/rfc7230/#section-3.2.6 + if (request_line.find('\0') != std::string_view::npos) throw std::runtime_error("Invalid request line contains NUL"); + + const std::vector parts{Split(request_line, " ")}; + if (parts.size() != 3) throw std::runtime_error("HTTP request line malformed"); + m_method = parts[0]; + m_target = parts[1]; + + if (parts[2].rfind("HTTP/") != 0) throw std::runtime_error("HTTP request line malformed"); + + // Version is exactly two decimal digits separated by a decimal point + // https://httpwg.org/specs/rfc9110.html#rfc.section.2.5 + const std::vector version_parts{Split(parts[2].substr(5), ".")}; + if (version_parts.size() != 2) throw std::runtime_error("HTTP request line malformed"); + if (version_parts[0].size() != 1 || version_parts[1].size() != 1) throw std::runtime_error("HTTP bad version"); + auto major = ToIntegral(version_parts[0]); + auto minor = ToIntegral(version_parts[1]); + if (!major || !minor || major != 1 || minor > 9) throw std::runtime_error("HTTP bad version"); + m_version.major = major.value(); + m_version.minor = minor.value(); + + return true; +} + +bool HTTPRequest::LoadHeaders(LineReader& reader) +{ + return m_headers.Read(reader); +} + +bool HTTPRequest::LoadBody(LineReader& reader) +{ + // https://httpwg.org/specs/rfc9112.html#message.body + + // No Content-length or Transfer-Encoding header means no body, see libevent evhttp_get_body() + // TODO: we must also implement Transfer-Encoding for chunk-reading + auto content_length_values{m_headers.FindAll("Content-Length")}; + if (content_length_values.empty()) return true; + + // Duplicate Content-Length headers are allowed only if they all have the same value + // https://www.rfc-editor.org/rfc/rfc7230#section-3.3.3 + const auto& first_content_length_value{content_length_values[0]}; + for (size_t i = 1; i < content_length_values.size(); ++i) { + if (content_length_values[i] != first_content_length_value) throw std::runtime_error("Differing Content-Length values"); + } + + const auto content_length{ToIntegral(first_content_length_value)}; + if (!content_length) throw std::runtime_error("Cannot parse Content-Length value"); + + if (*content_length > MAX_BODY_SIZE) throw ContentTooLargeError("Max body size exceeded"); + + // Not enough data in buffer for expected body + if (reader.Remaining() < *content_length) return false; + + m_body = reader.ReadLength(*content_length); + + return true; +} } // namespace http_bitcoin diff --git a/src/httpserver.h b/src/httpserver.h index 245abb750c9..6bf1827449d 100644 --- a/src/httpserver.h +++ b/src/httpserver.h @@ -8,9 +8,11 @@ #include #include #include +#include #include #include +#include #include #include @@ -192,6 +194,10 @@ private: }; namespace http_bitcoin { +using util::LineReader; + +//! Shortest valid request line, used by libevent in evhttp_parse_request_line() +constexpr size_t MIN_REQUEST_LINE_LENGTH = std::string_view("GET / HTTP/1.0").size(); //! Maximum size of each headers line in an HTTP request, //! also the maximum size of all headers total. @@ -199,6 +205,15 @@ namespace http_bitcoin { //! And libevent http.c evhttp_parse_headers_() constexpr size_t MAX_HEADERS_SIZE{8192}; +//! Maximum size of an HTTP request body +constexpr uint64_t MAX_BODY_SIZE{32_MiB}; + +//! Thrown when a request body exceeds MAX_BODY_SIZE +//! so the server can reply with more specific code 413 (content too large) vs general 400 (bad request) +struct ContentTooLargeError : std::runtime_error { + using std::runtime_error::runtime_error; +}; + class HTTPHeaders { public: @@ -258,6 +273,30 @@ public: std::string StringifyHeaders() const; }; + +class HTTPRequest +{ +public: + std::string m_method; + std::string m_target; + HTTPVersion m_version; + HTTPHeaders m_headers; + std::string m_body; + + /** + * Methods that attempt to parse HTTP request fields line-by-line + * from a receive buffer. + * @param[in] reader A LineReader object constructed over a span of data. + * @returns true If the request field was parsed. + * false If there was not enough data in the buffer to complete the field. + * @throws std::runtime_error if data is invalid. + */ + /// @{ + bool LoadControlData(LineReader& reader); + bool LoadHeaders(LineReader& reader); + bool LoadBody(LineReader& reader); + /// @} +}; } // namespace http_bitcoin #endif // BITCOIN_HTTPSERVER_H diff --git a/src/test/httpserver_tests.cpp b/src/test/httpserver_tests.cpp index baf0ae40ec7..e2b3f0c9b69 100644 --- a/src/test/httpserver_tests.cpp +++ b/src/test/httpserver_tests.cpp @@ -11,8 +11,11 @@ #include using http_bitcoin::HTTPHeaders; +using http_bitcoin::HTTPRequest; using http_bitcoin::HTTPResponse; +using http_bitcoin::MAX_BODY_SIZE; using http_bitcoin::MAX_HEADERS_SIZE; +using util::LineReader; BOOST_FIXTURE_TEST_SUITE(httpserver_tests, BasicTestingSetup) @@ -165,4 +168,208 @@ BOOST_AUTO_TEST_CASE(http_response_tests) "Content-Length: 41\r\n" "\r\n"); } + +BOOST_AUTO_TEST_CASE(http_request_tests) +{ + { + // Reading request captured from bitcoin-cli + constexpr std::string_view full_request = "POST / HTTP/1.1\r\n" + "Host: 127.0.0.1\r\n" + "Connection: close\r\n" + "Content-Type: application/json\r\n" + "Authorization: Basic X19jb29raWVfXzo5OGQ5ODQ3MWNmNjg0NzAzYTkzN2EzNzk0ZDFlODQ1NjZmYTRkZjJiMzFkYjhhODI4ZGY4MjVjOTg5ZGI4OTVl\r\n" + "Content-Length: 46\r\n" + "\r\n" + R"({"method":"getblockcount","params":[],"id":1})""\n"; + HTTPRequest req; + LineReader reader(full_request, MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK(req.LoadBody(reader)); + BOOST_CHECK_EQUAL(req.m_method, "POST"); + BOOST_CHECK_EQUAL(req.m_target, "/"); + BOOST_CHECK_EQUAL(req.m_version.major, 1); + BOOST_CHECK_EQUAL(req.m_version.minor, 1); + BOOST_CHECK_EQUAL(req.m_headers.FindFirst("Host"), "127.0.0.1"); + BOOST_CHECK_EQUAL(req.m_headers.FindFirst("Connection"), "close"); + BOOST_CHECK_EQUAL(req.m_headers.FindFirst("Content-Type"), "application/json"); + BOOST_CHECK_EQUAL(req.m_headers.FindFirst("Authorization"), "Basic X19jb29raWVfXzo5OGQ5ODQ3MWNmNjg0NzAzYTkzN2EzNzk0ZDFlODQ1NjZmYTRkZjJiMzFkYjhhODI4ZGY4MjVjOTg5ZGI4OTVl"); + BOOST_CHECK_EQUAL(req.m_headers.FindFirst("Content-Length"), "46"); + BOOST_CHECK_EQUAL(req.m_body.size(), 46); + BOOST_CHECK_EQUAL(req.m_body, R"({"method":"getblockcount","params":[],"id":1})""\n"); + } + { + // Malformed: no spaces between data + HTTPRequest req; + LineReader reader("GET/HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP request line too short"}); + } + { + // Malformed: too many spaces + HTTPRequest req; + LineReader reader("GET / HTTP / 1.0\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP request line malformed"}); + } + { + // Malformed: slash missing before version + HTTPRequest req; + LineReader reader("GET / HTTP1.0\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP request line too short"}); + } + { + // Malformed: no decimal in version + HTTPRequest req; + LineReader reader("GET / HTTP/11\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP request line too short"}); + } + { + // Malformed: version is not a number + HTTPRequest req; + LineReader reader("GET / HTTP/1.x\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP bad version"}); + } + { + // Malformed: version is out of range + HTTPRequest req; + LineReader reader("GET / HTTP/2.0\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP bad version"}); + } + { + // Malformed: version is out of range + HTTPRequest req; + LineReader reader("GET / HTTP/0.9\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP bad version"}); + } + { + // Malformed: version is out of range + HTTPRequest req; + LineReader reader("GET / HTTP/-1.0\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP bad version"}); + } + { + // Malformed: version is not exactly two integers and a dot + HTTPRequest req; + LineReader reader("GET / HTTP/1.00\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"HTTP bad version"}); + } + { + // Malformed: contains NUL + HTTPRequest req; + LineReader reader{std::string_view{"GET /safe\0/etc/passwd HTTP/1.00\r\nHost: 127.0.0.1\r\n\r\n", 50}, MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(req.LoadControlData(reader), std::runtime_error, HasReason{"Invalid request line contains NUL"}); + } + { + // Malformed: differing Content-Length values, case insensitive + constexpr std::string_view differing_length = "GET / HTTP/1.1\n" + "Host: 127.0.0.1\n" + "Content-Length: 8\n" + "content-length: 9\n\n" + "12345678"; + HTTPRequest req; + util::LineReader reader{differing_length, /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK_EXCEPTION(req.LoadBody(reader), std::runtime_error, HasReason{"Differing Content-Length values"}); + } + { + // Ok: multiple same Content-Length values + constexpr std::string_view differing_length = "GET / HTTP/1.1\n" + "Host: 127.0.0.1\n" + "Content-Length: 8\n" + "content-length: 8\n\n" + "12345678"; + HTTPRequest req; + util::LineReader reader{differing_length, /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK(req.LoadBody(reader)); + } + { + // Ok + HTTPRequest req; + LineReader reader("GET / HTTP/1.0\r\nHost: 127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK(req.LoadBody(reader)); + BOOST_CHECK_EQUAL(req.m_method, "GET"); + BOOST_CHECK_EQUAL(req.m_target, "/"); + BOOST_CHECK_EQUAL(req.m_version.major, 1); + BOOST_CHECK_EQUAL(req.m_version.minor, 0); + BOOST_CHECK_EQUAL(req.m_headers.FindFirst("Host"), "127.0.0.1"); + // no body is OK + BOOST_CHECK_EQUAL(req.m_body.size(), 0); + } + { + // Malformed: missing colon + HTTPRequest req; + LineReader reader("GET / HTTP/1.0\r\nHost=127.0.0.1\r\n\r\n", MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK_EXCEPTION(req.LoadHeaders(reader), std::runtime_error, HasReason{"HTTP header missing colon (:)"}); + } + { + // We might not have received enough data from the client which is not + // an error. We return false so the caller can try again later when the + // buffer has more data. + HTTPRequest req; + LineReader reader("GET / HTTP/1.0\r\nHost: ", MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(!req.LoadHeaders(reader)); + } + { + // No Content-Length: body is not read + HTTPRequest req; + LineReader reader("GET / HTTP/1.0\r\n\r\n" R"({"method":"getblockcount"})", MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK(req.LoadBody(reader)); + // Don't try to read request body if Content-Length is missing + BOOST_CHECK_EQUAL(req.m_body.size(), 0); + } + { + // Malformed: Content-Length is not a number + HTTPRequest req; + LineReader reader("GET / HTTP/1.0\r\nContent-Length: eleven\r\n\r\n" R"({"method":"getblockcount"})", MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK_EXCEPTION(req.LoadBody(reader), std::runtime_error, HasReason{"Cannot parse Content-Length value"}); + } + { + // Malformed: Content-Length is negative + HTTPRequest req; + LineReader reader("GET / HTTP/1.0\r\nContent-Length: -8\r\n\r\n" R"({"method":"getblockcount"})", MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK_EXCEPTION(req.LoadBody(reader), std::runtime_error, HasReason{"Cannot parse Content-Length value"}); + } + { + // Content-Length exceeds limit + constexpr auto excessive_size{MAX_BODY_SIZE + 1}; + std::string huge_body(excessive_size, 'x'); + const std::string request{"GET / HTTP/1.0\r\nContent-Length: " + util::ToString(excessive_size) + "\r\n\r\n" + std::move(huge_body)}; + HTTPRequest req; + LineReader reader(request, MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK_EXCEPTION(req.LoadBody(reader), http_bitcoin::ContentTooLargeError, HasReason{"Max body size exceeded"}); + } + { + // Content-Length exactly on the limit + std::string max_body(MAX_BODY_SIZE, 'x'); + const std::string request{"GET / HTTP/1.0\r\nContent-Length: " + util::ToString(MAX_BODY_SIZE) + "\r\n\r\n" + std::move(max_body)}; + HTTPRequest req; + LineReader reader(request, MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK(req.LoadBody(reader)); + } + { + // Content-Length indicates more data than we have in the buffer. + // Not an error; we wait for more data before completing the body. + HTTPRequest req; + LineReader reader("GET / HTTP/1.0\r\nContent-Length: 1024\r\n\r\n" R"({"method":"getblockcount"})", MAX_HEADERS_SIZE); + BOOST_CHECK(req.LoadControlData(reader)); + BOOST_CHECK(req.LoadHeaders(reader)); + BOOST_CHECK(!req.LoadBody(reader)); + } +} BOOST_AUTO_TEST_SUITE_END()