Merge pull request #10563 from Icinga/http-response-extra-headers

Allow to set extra headers in HTTP responses
This commit is contained in:
Julian Brost 2025-12-02 10:41:26 +01:00 committed by GitHub
commit 4ee4599224
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 190 additions and 0 deletions

View file

@ -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<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();

View file

@ -170,6 +170,7 @@ public:
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;

View file

@ -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;

View file

@ -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;

View file

@ -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');
});
}

View file

@ -26,6 +26,9 @@ public:
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);
};
}

View file

@ -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>

View file

@ -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()