Matthew Zipkin
2024-09-30 13:22:29 -04:00
parent 89c54ae4cb
commit 68b5d289d1
3 changed files with 247 additions and 0 deletions

View File

@@ -28,6 +28,7 @@
#include <optional>
#include <span>
#include <string>
#include <string_view>
#include <thread>
#include <unordered_map>
#include <vector>
@@ -714,3 +715,97 @@ void UnregisterHTTPHandler(const std::string &prefix, bool exactMatch)
pathHandlers.erase(i);
}
}
namespace http_bitcoin {
std::optional<std::string> 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<std::string_view> HTTPHeaders::FindAll(const std::string_view key) const
{
std::vector<std::string_view> 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

View File

@@ -10,6 +10,9 @@
#include <span>
#include <string>
#include <util/strencodings.h>
#include <util/string.h>
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<std::string> 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<std::string_view> 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<std::pair<std::string, std::string>> m_headers;
};
} // namespace http_bitcoin
#endif // BITCOIN_HTTPSERVER_H

View File

@@ -5,9 +5,13 @@
#include <httpserver.h>
#include <test/util/common.h>
#include <test/util/setup_common.h>
#include <util/string.h>
#include <boost/test/unit_test.hpp>
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<std::string_view>{"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()