diff --git a/lib/remote/apilistener.cpp b/lib/remote/apilistener.cpp index 59e537f76e..774e9efb19 100644 --- a/lib/remote/apilistener.cpp +++ b/lib/remote/apilistener.cpp @@ -8,6 +8,7 @@ #include "remote/apifunction.hpp" #include "remote/configpackageutility.hpp" #include "remote/configobjectutility.hpp" +#include "remote/httputility.hpp" #include "base/atomic-file.hpp" #include "base/convert.hpp" #include "base/defer.hpp" @@ -2008,6 +2009,31 @@ void ApiListener::ValidateTlsHandshakeTimeout(const Lazy& lvalue, const BOOST_THROW_EXCEPTION(ValidationError(this, { "tls_handshake_timeout" }, "Value must be greater than 0.")); } +void ApiListener::ValidateHttpResponseHeaders(const Lazy& lvalue, const ValidationUtils& utils) +{ + ObjectImpl::ValidateHttpResponseHeaders(lvalue, utils); + + if (Dictionary::Ptr headers = lvalue(); headers) { + ObjectLock lock(headers); + for (auto& [name, value] : headers) { + if (!HttpUtility::IsValidHeaderName(name.GetData())) { + BOOST_THROW_EXCEPTION(ValidationError(this, { "http_response_headers", name }, + "Header name is invalid.")); + } + + if (!value.IsString()) { + BOOST_THROW_EXCEPTION(ValidationError(this, { "http_response_headers", name }, + "Header value must be a string.")); + } + + if (!HttpUtility::IsValidHeaderValue(value.Get().GetData())) { + BOOST_THROW_EXCEPTION(ValidationError(this, { "http_response_headers", name }, + "Header value is invalid.")); + } + } + } +} + bool ApiListener::IsHACluster() { Zone::Ptr zone = Zone::GetLocalZone(); diff --git a/lib/remote/apilistener.hpp b/lib/remote/apilistener.hpp index 866af7614e..7b98db9646 100644 --- a/lib/remote/apilistener.hpp +++ b/lib/remote/apilistener.hpp @@ -170,6 +170,7 @@ class ApiListener final : public ObjectImpl protected: void ValidateTlsProtocolmin(const Lazy& lvalue, const ValidationUtils& utils) override; void ValidateTlsHandshakeTimeout(const Lazy& lvalue, const ValidationUtils& utils) override; + void ValidateHttpResponseHeaders(const Lazy& lvalue, const ValidationUtils& utils) override; private: Shared::Ptr m_SSLContext; diff --git a/lib/remote/apilistener.ti b/lib/remote/apilistener.ti index 55ec749c59..8c9fdab407 100644 --- a/lib/remote/apilistener.ti +++ b/lib/remote/apilistener.ti @@ -55,6 +55,7 @@ class ApiListener : ConfigObject [config, deprecated] String access_control_allow_headers; [config, deprecated] String access_control_allow_methods; + [config] Dictionary::Ptr http_response_headers; [state, no_user_modify] Timestamp log_message_timestamp; diff --git a/lib/remote/httpserverconnection.cpp b/lib/remote/httpserverconnection.cpp index 39fa2d79de..6ae1474523 100644 --- a/lib/remote/httpserverconnection.cpp +++ b/lib/remote/httpserverconnection.cpp @@ -466,6 +466,16 @@ void HttpServerConnection::ProcessMessages(boost::asio::yield_context yc) request.Parser().body_limit(-1); response.set(http::field::server, l_ServerHeader); + if (auto listener (ApiListener::GetInstance()); listener) { + if (Dictionary::Ptr headers = listener->GetHttpResponseHeaders(); headers) { + ObjectLock lock(headers); + for (auto& [header, value] : headers) { + if (value.IsString()) { + response.set(header, value.Get()); + } + } + } + } if (!EnsureValidHeaders(buf, request, response, m_ShuttingDown, yc)) { break; diff --git a/lib/remote/httputility.cpp b/lib/remote/httputility.cpp index b53a8721b7..a3c853812a 100644 --- a/lib/remote/httputility.cpp +++ b/lib/remote/httputility.cpp @@ -78,3 +78,74 @@ void HttpUtility::SendJsonError(HttpResponse& response, HttpUtility::SendJsonBody(response, params, result); } + +/** + * Check if the given string is suitable to be used as an HTTP header name. + * + * @param name The value to check for validity + * @return true if the argument is a valid header name, false otherwise + */ +bool HttpUtility::IsValidHeaderName(std::string_view name) +{ + /* + * Derived from the following syntax definition in RFC9110: + * + * field-name = token + * token = 1*tchar + * tchar = "!" / "#" / "$" / "%" / "&" / "'" / "*" / "+" / "-" / "." / "^" / "_" / "`" / "|" / "~" / DIGIT / ALPHA + * ALPHA = %x41-5A / %x61-7A ; A-Z / a-z + * DIGIT = %x30-39 ; 0-9 + * + * References: + * - https://datatracker.ietf.org/doc/html/rfc9110#section-5.1 + * - https://datatracker.ietf.org/doc/html/rfc9110#appendix-A + * - https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1 + */ + + return !name.empty() && std::all_of(name.begin(), name.end(), [](char c) { + switch (c) { + case '!': case '#': case '$': case '%': case '&': case '\'': case '*': case '+': + case '-': case '.': case '^': case '_': case '`': case '|': case '~': + return true; + default: + return ('0' <= c && c <= '9') || ('A' <= c && c <= 'Z') || ('a' <= c && c <= 'z'); + } + }); +} + +/** + * Check if the given string is suitable to be used as an HTTP header value. + * + * @param value The value to check for validity + * @return true if the argument is a valid header value, false otherwise + */ +bool HttpUtility::IsValidHeaderValue(std::string_view value) +{ + /* + * Derived from the following syntax definition in RFC9110: + * + * field-value = *field-content + * field-content = field-vchar [ 1*( SP / HTAB / field-vchar ) field-vchar ] + * field-vchar = VCHAR / obs-text + * obs-text = %x80-FF + * VCHAR = %x21-7E ; visible (printing) characters + * + * References: + * - https://datatracker.ietf.org/doc/html/rfc9110#section-5.5 + * - https://datatracker.ietf.org/doc/html/rfc9110#appendix-A + * - https://www.rfc-editor.org/rfc/rfc5234#appendix-B.1 + */ + + if (!value.empty()) { + // Must not start or end with space or tab. + for (char c : {*value.begin(), *value.rbegin()}) { + if (c == ' ' || c == '\t') { + return false; + } + } + } + + return std::all_of(value.begin(), value.end(), [](char c) { + return c == ' ' || c == '\t' || ('\x21' <= c && c <= '\x7e') || ('\x80' <= c && c <= '\xff'); + }); +} diff --git a/lib/remote/httputility.hpp b/lib/remote/httputility.hpp index 6f64277136..6ad1720ea8 100644 --- a/lib/remote/httputility.hpp +++ b/lib/remote/httputility.hpp @@ -26,6 +26,9 @@ class HttpUtility static void SendJsonBody(HttpResponse& response, const Dictionary::Ptr& params, const Value& val); static void SendJsonError(HttpResponse& response, const Dictionary::Ptr& params, const int code, const String& info = {}, const String& diagnosticInformation = {}); + + static bool IsValidHeaderName(std::string_view name); + static bool IsValidHeaderValue(std::string_view value); }; } diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt index 0bf4b84133..c165891b51 100644 --- a/test/CMakeLists.txt +++ b/test/CMakeLists.txt @@ -123,6 +123,7 @@ set(base_test_SOURCES remote-configpackageutility.cpp remote-httpserverconnection.cpp remote-httpmessage.cpp + remote-httputility.cpp remote-url.cpp ${base_OBJS} $ diff --git a/test/remote-httputility.cpp b/test/remote-httputility.cpp new file mode 100644 index 0000000000..0a31b2cd35 --- /dev/null +++ b/test/remote-httputility.cpp @@ -0,0 +1,77 @@ +/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ + +#include +#include "remote/httputility.hpp" +#include "test/icingaapplication-fixture.hpp" + +using namespace icinga; + +BOOST_AUTO_TEST_SUITE(remote_httputility) + +BOOST_AUTO_TEST_CASE(IsValidHeaderName) +{ + // Use string_view literals (""sv) to allow test inputs containing '\0'. + using namespace std::string_view_literals; + + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("Host"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("X-Powered-By"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("Content-Security-Policy"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("Strict-Transport-Security"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("lowercase-is-fine-too"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("everything-from-the-spec-!#$%&'*+-.^_`|~0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("-this-seems-to-be-allowed-too-"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("~http~is~weird~"sv), true); + + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName(""sv /* empty header name is invalid */), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("spaces are not allowed"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("tabs\tare\tnot\tallowed"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("nul-is-bad\0"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("del-is-bad\x7f"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("non-ascii-is-bad\x80"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderName("non-ascii-is-bad\xff"sv), false); +} + +BOOST_AUTO_TEST_CASE(IsValidHeaderValue) +{ + // Use string_view literals (""sv) to allow test inputs containing '\0'. + using namespace std::string_view_literals; + + auto everything = []{ + std::string s = "everything-from-the-spec \t "; + for (int i = 0x21; i <= 0x7e; ++i) { + s.push_back(char(i)); + } + for (int i = 0x80; i <= 0xff; ++i) { + s.push_back(char(i)); + } + + // Sanity checks: + for (char c : {'\x00', '\x08', '\x0a', '\x1f', '\x7f'}) { + BOOST_CHECK_EQUAL(s.find(c), std::string::npos); + } + for (char c : {'\t' /* == 0x09 */, ' ' /* == 0x20 */, '\x21', '\x7e', '\x80', '\xff'}) { + BOOST_CHECK_NE(s.find(c), std::string::npos); + } + + return s; + }; + + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue(""sv /* empty header value is allowed */), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("example.com"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("default-src 'self'; img-src 'self' example.com"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("max-age=31536000"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("spaces are allowed"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("tabs\tare\tallowed"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("non-ascii-is-allowed\x80"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("non-ascii-is-allowed\xff"sv), true); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue(everything()), true); + + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("nul-is-bad\0"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("del-is-bad\x7f"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue(" no leading spaces"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("no trailing spaces "sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("\tno leading tabs"sv), false); + BOOST_CHECK_EQUAL(HttpUtility::IsValidHeaderValue("no trailing tabs\t"sv), false); +} + +BOOST_AUTO_TEST_SUITE_END()