Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 26 additions & 0 deletions lib/remote/apilistener.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -2008,6 +2009,31 @@ void ApiListener::ValidateTlsHandshakeTimeout(const Lazy<double>& lvalue, const
BOOST_THROW_EXCEPTION(ValidationError(this, { "tls_handshake_timeout" }, "Value must be greater than 0."));
}

void ApiListener::ValidateHttpResponseHeaders(const Lazy<Dictionary::Ptr>& 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<String>().GetData())) {
BOOST_THROW_EXCEPTION(ValidationError(this, { "http_response_headers", name },
"Header value is invalid."));
}
}
}
}

bool ApiListener::IsHACluster()
{
Zone::Ptr zone = Zone::GetLocalZone();
Expand Down
1 change: 1 addition & 0 deletions lib/remote/apilistener.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -170,6 +170,7 @@ class ApiListener final : public ObjectImpl<ApiListener>
protected:
void ValidateTlsProtocolmin(const Lazy<String>& lvalue, const ValidationUtils& utils) override;
void ValidateTlsHandshakeTimeout(const Lazy<double>& lvalue, const ValidationUtils& utils) override;
void ValidateHttpResponseHeaders(const Lazy<Dictionary::Ptr>& lvalue, const ValidationUtils& utils) override;

private:
Shared<boost::asio::ssl::context>::Ptr m_SSLContext;
Expand Down
1 change: 1 addition & 0 deletions lib/remote/apilistener.ti
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down
10 changes: 10 additions & 0 deletions lib/remote/httpserverconnection.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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<String>());
}
}
}
}

if (!EnsureValidHeaders(buf, request, response, m_ShuttingDown, yc)) {
break;
Expand Down
71 changes: 71 additions & 0 deletions lib/remote/httputility.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -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');
});
}
3 changes: 3 additions & 0 deletions lib/remote/httputility.hpp
Original file line number Diff line number Diff line change
Expand Up @@ -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);
};

}
Expand Down
1 change: 1 addition & 0 deletions test/CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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}
$<TARGET_OBJECTS:config>
Expand Down
77 changes: 77 additions & 0 deletions test/remote-httputility.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */

#include <BoostTestTargetConfig.h>
#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()
Loading