From 68b5d289d19c42de9bebf54a0555053d29721111 Mon Sep 17 00:00:00 2001 From: Matthew Zipkin Date: Mon, 30 Sep 2024 13:22:29 -0400 Subject: [PATCH] http: Implement HTTPHeaders class see: https://www.rfc-editor.org/rfc/rfc2616#section-4.2 https://www.rfc-editor.org/rfc/rfc7231#section-5 https://www.rfc-editor.org/rfc/rfc7231#section-7 https://httpwg.org/specs/rfc9111.html#header.field.definitions --- src/httpserver.cpp | 95 +++++++++++++++++++++++++++++++ src/httpserver.h | 49 ++++++++++++++++ src/test/httpserver_tests.cpp | 103 ++++++++++++++++++++++++++++++++++ 3 files changed, 247 insertions(+) diff --git a/src/httpserver.cpp b/src/httpserver.cpp index 367e29ca639..45824b7a2cf 100644 --- a/src/httpserver.cpp +++ b/src/httpserver.cpp @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -714,3 +715,97 @@ void UnregisterHTTPHandler(const std::string &prefix, bool exactMatch) pathHandlers.erase(i); } } + +namespace http_bitcoin { + +std::optional HTTPHeaders::FindFirst(const std::string_view key) const +{ + for (const auto& item : m_headers) { + if (CaseInsensitiveEqual(key, item.first)) { + return item.second; + } + } + return std::nullopt; +} + +std::vector HTTPHeaders::FindAll(const std::string_view key) const +{ + std::vector ret; + for (const auto& item : m_headers) { + if (CaseInsensitiveEqual(key, item.first)) { + ret.push_back(item.second); + } + } + return ret; +} + +void HTTPHeaders::Write(std::string&& key, std::string&& value) +{ + m_headers.emplace_back(std::move(key), std::move(value)); +} + +void HTTPHeaders::RemoveAll(std::string_view key) +{ + auto moved = std::ranges::remove_if(m_headers, [key] (auto& pair) { + return CaseInsensitiveEqual(key, pair.first); + }); + m_headers.erase(moved.begin(), moved.end()); +} + +bool HTTPHeaders::Read(util::LineReader& reader) +{ + // Headers https://httpwg.org/specs/rfc9110.html#rfc.section.6.3 + // A sequence of Field Lines https://httpwg.org/specs/rfc9110.html#rfc.section.5.2 + while (auto maybe_line = reader.ReadLine()) { + if (reader.Consumed() > MAX_HEADERS_SIZE) throw std::runtime_error("HTTP headers exceed size limit"); + + const std::string_view& line = *maybe_line; + + // An empty line indicates end of the headers section https://www.rfc-editor.org/rfc/rfc2616#section-4 + if (line.empty()) return true; + + // "Field values containing CR, LF, or NUL characters are invalid and dangerous" + // https://httpwg.org/specs/rfc9110.html#rfc.section.5.5 + // A sender MUST NOT generate a bare CR (a CR character not immediately followed by LF) + // within any protocol elements other than the content. + // A recipient of such a bare CR MUST consider that element to be invalid... + // https://httpwg.org/specs/rfc9112.html#rfc.section.2.2 + if (line.find_first_of("\r\n\0", 0, 3) != std::string_view::npos) throw std::runtime_error("Header contains invalid character"); + + // Header line must have at least one ":" + // keys are not allowed to have delimiters like ":" but values are + // https://httpwg.org/specs/rfc9110.html#rfc.section.5.6.2 + const size_t pos{line.find(':')}; + if (pos == std::string_view::npos) throw std::runtime_error("HTTP header missing colon (:)"); + + // Whitespace is strictly not allowed in the field-name (key) + // https://www.rfc-editor.org/rfc/rfc9110.html#section-5.6.2 + std::string_view key = line.substr(0, pos); + if (key.find_first_of(" \t\n\r\f\v") != std::string_view::npos) throw std::runtime_error("Invalid header field-name contains whitespace"); + // Whitespace is optional in the value and can be trimmed + std::string value = util::TrimString(std::string_view(line).substr(pos + 1)); + + // Header keys are Field Names: https://httpwg.org/specs/rfc9110.html#fields.names + // which consist of "tokens": https://httpwg.org/specs/rfc9110.html#rfc.section.5.6.2 + // that can not be empty. + if (key.empty()) throw std::runtime_error("Empty HTTP header name"); + + Write(std::string(key), std::move(value)); + } + + return false; +} + +std::string HTTPHeaders::Stringify() const +{ + std::string out; + for (const auto& [key, value] : m_headers) { + out += key + ": " + value + "\r\n"; + } + + // Headers are terminated by an empty line + out += "\r\n"; + + return out; +} +} // namespace http_bitcoin diff --git a/src/httpserver.h b/src/httpserver.h index 5d6ea3c8a54..a888979fa7d 100644 --- a/src/httpserver.h +++ b/src/httpserver.h @@ -10,6 +10,9 @@ #include #include +#include +#include + namespace util { class SignalInterrupt; } // namespace util @@ -187,4 +190,50 @@ private: struct event* ev; }; +namespace http_bitcoin { + +//! Maximum size of each headers line in an HTTP request, +//! also the maximum size of all headers total. +//! See https://github.com/bitcoin/bitcoin/pull/6859 +//! And libevent http.c evhttp_parse_headers_() +constexpr size_t MAX_HEADERS_SIZE{8192}; + +class HTTPHeaders +{ +public: + /** + * @param[in] key The field-name of the header to search for + * @returns The value of the first header that matches the provided key + * nullopt if key is not found + */ + std::optional FindFirst(std::string_view key) const; + /** + * @param[in] key The field-name of the header to search for + * @returns Views into all values matching the provided key (valid while this object is alive) + */ + std::vector FindAll(std::string_view key) const; + void Write(std::string&& key, std::string&& value); + /** + * @param[in] key The field-name of the header to search for and delete + */ + void RemoveAll(std::string_view key); + /** + * @returns false if LineReader hits the end of the buffer before reading an + * \n, meaning that we are still waiting on more data from the client. + * true after reading an entire HTTP headers section, terminated + * by an empty line and \n. + * @throws on exceeded read limit and on bad headers syntax (e.g. no ":" in a line) + */ + bool Read(util::LineReader& reader); + std::string Stringify() const; + +private: + /** + * Headers can have duplicate field names, so we use a vector of key-value pairs instead of a map. + * https://httpwg.org/specs/rfc9110.html#rfc.section.5.2 + */ + std::vector> m_headers; +}; +} // namespace http_bitcoin + #endif // BITCOIN_HTTPSERVER_H diff --git a/src/test/httpserver_tests.cpp b/src/test/httpserver_tests.cpp index 75c25cafff2..d2d03f8aa7b 100644 --- a/src/test/httpserver_tests.cpp +++ b/src/test/httpserver_tests.cpp @@ -5,9 +5,13 @@ #include #include #include +#include #include +using http_bitcoin::HTTPHeaders; +using http_bitcoin::MAX_HEADERS_SIZE; + BOOST_FIXTURE_TEST_SUITE(httpserver_tests, BasicTestingSetup) BOOST_AUTO_TEST_CASE(test_query_parameters) @@ -41,4 +45,103 @@ BOOST_AUTO_TEST_CASE(test_query_parameters) uri = "/rest/endpoint/someresource.json&p1=v1&p2=v2%"; BOOST_CHECK_EXCEPTION(GetQueryParameterFromUri(uri.c_str(), "p1"), std::runtime_error, HasReason("URI parsing failed, it likely contained RFC 3986 invalid characters")); } + +BOOST_AUTO_TEST_CASE(http_headers_tests) +{ + { + // Writing response headers + HTTPHeaders headers{}; + BOOST_CHECK(!headers.FindFirst("Cache-Control")); + headers.Write("Cache-Control", "no-cache"); + // Check case-insensitive key matching + BOOST_CHECK_EQUAL(headers.FindFirst("Cache-Control"), "no-cache"); + BOOST_CHECK_EQUAL(headers.FindFirst("cache-control"), "no-cache"); + // Additional values are appended, compared case-insensitive + headers.Write("cache-control", "max-age=60"); + BOOST_CHECK_EQUAL(headers.FindFirst("Cache-Control"), "no-cache"); + BOOST_CHECK((headers.FindAll("Cache-Control") == std::vector{"no-cache", "max-age=60"})); + // Add a few more + headers.Write("Pie", "apple"); + headers.Write("Sandwich", "ham"); + headers.Write("Coffee", "black"); + BOOST_CHECK_EQUAL(headers.FindFirst("Pie"), "apple"); + // Remove + headers.RemoveAll("Pie"); + BOOST_CHECK(!headers.FindFirst("Pie")); + // Combine for transmission + std::string headers_string{headers.Stringify()}; + BOOST_CHECK_EQUAL(headers_string, "Cache-Control: no-cache\r\n" + "cache-control: max-age=60\r\n" + "Sandwich: ham\r\n" + "Coffee: black\r\n" + "\r\n"); + } + { + // Reading request headers captured from bitcoin-cli + constexpr std::string_view bitcoin_cli_headers = "Host: 127.0.0.1\r\n" + "Connection: close\r\n" + "Content-Type: application/json\r\n" + "Authorization: Basic X19jb29raWVfXzozYzJkNTAxNDFlMGJiYmVhMTI5ODg3NzI5MTM3NTRmNThkNjc2OWMwZTYxZjgzNTgyNzEwYTY1OGRkYjVmZGQ3\r\n" + "Content-Length: 46\r\n"; + util::LineReader reader(bitcoin_cli_headers, /*max_line_length=*/MAX_HEADERS_SIZE); + HTTPHeaders headers{}; + headers.Read(reader); + BOOST_CHECK_EQUAL(headers.FindFirst("Host"), "127.0.0.1"); + BOOST_CHECK_EQUAL(headers.FindFirst("Connection"), "close"); + BOOST_CHECK_EQUAL(headers.FindFirst("Content-Type"), "application/json"); + BOOST_CHECK_EQUAL(headers.FindFirst("Authorization"), "Basic X19jb29raWVfXzozYzJkNTAxNDFlMGJiYmVhMTI5ODg3NzI5MTM3NTRmNThkNjc2OWMwZTYxZjgzNTgyNzEwYTY1OGRkYjVmZGQ3"); + BOOST_CHECK_EQUAL(headers.FindFirst("Content-Length"), "46"); + BOOST_CHECK(!headers.FindFirst("Pizza")); + } + // Ensure invalid headers are rejected + { + // missing a colon + util::LineReader reader{"key value\n", /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(HTTPHeaders{}.Read(reader), std::runtime_error, HasReason{"HTTP header missing colon (:)"}); + } + { + // missing a key + util::LineReader reader{":value\n", /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(HTTPHeaders{}.Read(reader), std::runtime_error, HasReason{"Empty HTTP header name"}); + } + { + // contains NUL + util::LineReader reader{std::string_view{"X-Custom: foo\0bar\n", 18}, /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(HTTPHeaders{}.Read(reader), std::runtime_error, HasReason{"Header contains invalid character"}); + } + { + // contains bare \r (not followed by \n) + util::LineReader reader{std::string_view{"X-Custom: foo\rbar\n"}, /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(HTTPHeaders{}.Read(reader), std::runtime_error, HasReason{"Header contains invalid character"}); + } + { + // contains odd \r preceding the expected CRLF + util::LineReader reader{"X-Custom: foo\r\r\n", /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(HTTPHeaders{}.Read(reader), std::runtime_error, HasReason{"Header contains invalid character"}); + } + { + // key contains whitespace + util::LineReader reader{"key : value\n", /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(HTTPHeaders{}.Read(reader), std::runtime_error, HasReason{"Invalid header field-name contains whitespace"}); + } + { + // Individual lines are below MAX_HEADERS_SIZE but the total is excessive + std::string lines; + lines.reserve(820 * 10); + for (int i = 0; i < 820; ++i) { + lines.append("key:value\n"); + } + std::string_view excessive_headers{lines}; + BOOST_CHECK_GT(excessive_headers.size(), MAX_HEADERS_SIZE); + util::LineReader reader{excessive_headers, /*max_line_length=*/MAX_HEADERS_SIZE}; + BOOST_CHECK_EXCEPTION(HTTPHeaders{}.Read(reader), std::runtime_error, HasReason{"HTTP headers exceed size limit"}); + } + { + // Ok + util::LineReader reader{"key: value\n", /*max_line_length=*/MAX_HEADERS_SIZE}; + HTTPHeaders headers{}; + headers.Read(reader); + BOOST_CHECK_EQUAL(headers.FindFirst("key"), "value"); + } +} BOOST_AUTO_TEST_SUITE_END()