What changed, and why it matters
This commit introduces a new HTTP request parser for Bitcoin Core's internal web server. It is a defensive, by-the-book implementation that explicitly rejects malformed request lines, bad HTTP versions, NUL bytes, conflicting Content-Length headers, oversized bodies, and non-numeric content lengths. The code is accompanied by extensive unit tests demonstrating these protections. There is no indication in the commit or supplied references that this fixes a known vulnerability; it appears to be a refactoring or hardening change.
No immediate action required. Treat as routine hardening/refactoring. Reviewers may want to confirm the new parser is fully wired into request handling and that downstream callers correctly map `ContentTooLargeError` to HTTP 413 and other parse errors to HTTP 400, but that is outside the scope of this commit.
Security signals we found
New HTTP request parser with strict validation of request line, version, headers, and body
Explicit rejection of NUL bytes in request line to prevent C-string parser truncation issues
Strict HTTP version enforcement (major must be 1, minor 0-9, exactly one digit each)
Duplicate Content-Length header consistency check
Content-Length limited to 32 MiB with dedicated ContentTooLargeError exception
Extensive unit tests for malformed inputs and edge cases
Evidence from the diff
The patch adds an http_bitcoin::HTTPRequest class with LoadControlData, LoadHeaders, and LoadBody methods. It parses the request line, headers, and body using a util::LineReader. Security-relevant checks include: minimum request-line length; rejection of NUL bytes; strict version parsing (major==1, minor<=9, single digits); duplicate Content-Length value consistency; numeric Content-Length parsing; and a 32 MiB body-size cap via ContentTooLargeError. Tests cover valid requests and many malformed variants, including NUL injection, differing Content-Lengths, and oversized bodies.
Changed components
src/httpserver.cppsrc/httpserver.hsrc/test/httpserver_tests.cppInspect captured patch +317 / −0
diff --git a/src/httpserver.cpp b/src/httpserver.cpp
index a1b3e7a2..0b3d5d06 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<std::string> 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<std::string_view> parts{Split<std::string_view>(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<std::string_view> version_parts{Split<std::string_view>(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<uint8_t>(version_parts[0]);
+ auto minor = ToIntegral<uint8_t>(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<uint64_t>(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 245abb75..6bf18274 100644
--- a/src/httpserver.h
+++ b/src/httpserver.h
@@ -8,9 +8,11 @@
#include <functional>
#include <optional>
#include <span>
+#include <stdexcept>
#include <string>
#include <rpc/protocol.h>
+#include <util/byte_units.h>
#include <util/strencodings.h>
#include <util/string.h>
@@ -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 baf0ae40..e2b3f0c9 100644
--- a/src/test/httpserver_tests.cpp
+++ b/src/test/httpserver_tests.cpp
@@ -11,8 +11,11 @@
#include <boost/test/unit_test.hpp>
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()
Why this scored 28/100
Community notes
Notes can correct, qualify, or add evidence to the AI analysis. Every note shown here has been validated by a human moderator.
The AI analysis stands alone for now. Submit a note if you can add evidence or important context.