Add filter-expression permission

This allows preventing ApiUsers from evaluating their own DSL expressions for
improved security.
This commit is contained in:
Julian Brost 2026-05-22 17:06:27 +02:00
parent 91eeb41ff1
commit 324d5bb815
19 changed files with 217 additions and 25 deletions

View file

@ -363,6 +363,15 @@ Run the following command to:
icinga2 api setup
```
For new installations, it is recommended to set the following additional attribute inside the `ApiListener` object
definition in `/etc/icinga2/features-enabled/api.conf`. This will already enforce stricter permissions as they will
become the default with v2.17 (see the [upgrading documentation](16-upgrading-icinga-2.md#upgrading-to-2-16-2) for the
version that introduced that setting for more details):
```
enforce_filter_expression_permission = true
```
Restart Icinga 2 for these changes to take effect.
```bash

View file

@ -1088,28 +1088,29 @@ object ApiListener "api" {
Configuration Attributes:
Name | Type | Description
--------------------------------------|-----------------------|----------------------------------
cert\_path | String | **Deprecated.** Path to the public key.
key\_path | String | **Deprecated.** Path to the private key.
ca\_path | String | **Deprecated.** Path to the CA certificate file.
ticket\_salt | String | **Optional.** Private key for [CSR auto-signing](06-distributed-monitoring.md#distributed-monitoring-setup-csr-auto-signing). **Required** for a signing master instance.
crl\_path | String | **Optional.** Path to the CRL file.
bind\_host | String | **Optional.** The IP address the api listener should be bound to. If not specified, the ApiListener is bound to `::` and listens for both IPv4 and IPv6 connections or to `0.0.0.0` if IPv6 is not supported by the operating system.
bind\_port | Number | **Optional.** The port the api listener should be bound to. Defaults to `5665`.
accept\_config | Boolean | **Optional.** Accept zone configuration. Defaults to `false`.
accept\_commands | Boolean | **Optional.** Accept remote commands. Defaults to `false`.
max\_anonymous\_clients | Number | **Optional.** Limit the number of anonymous client connections (not configured endpoints and signing requests).
cipher\_list | String | **Optional.** Cipher list that is allowed. For a list of available ciphers run `openssl ciphers`. Defaults to `ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256`.
tls\_protocolmin | String | **Optional.** Minimum TLS protocol version. Since v2.11, only `TLSv1.2` is supported. Defaults to `TLSv1.2`.
tls\_handshake\_timeout | Number | **Deprecated.** TLS Handshake timeout. Defaults to `10s`.
connect\_timeout | Number | **Optional.** Timeout for establishing new connections. Affects both incoming and outgoing connections. Within this time, the TCP and TLS handshakes must complete and either a HTTP request or an Icinga cluster connection must be initiated. Defaults to `15s`.
access\_control\_allow\_origin | Array | **Optional.** Specifies an array of origin URLs that may access the API. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Origin)
access\_control\_allow\_credentials | Boolean | **Deprecated.** Indicates whether or not the actual request can be made using credentials. Defaults to `true`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Credentials)
access\_control\_allow\_headers | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP headers can be used when making the actual request. Defaults to `Authorization`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Headers)
access\_control\_allow\_methods | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP methods can be used when making the actual request. Defaults to `GET, POST, PUT, DELETE`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Methods)
environment | String | **Optional.** Used as suffix in TLS SNI extension name; default from constant `ApiEnvironment`, which is empty.
http\_response\_headers | Dictionary | **Optional.** Additional headers to add to HTTP responses, for example `{"Strict-Transport-Security" = "max-age=31536000"}`. Defaults to none.
Name | Type | Description
----------------------------------------|------------|----------------------------------
cert\_path | String | **Deprecated.** Path to the public key.
key\_path | String | **Deprecated.** Path to the private key.
ca\_path | String | **Deprecated.** Path to the CA certificate file.
ticket\_salt | String | **Optional.** Private key for [CSR auto-signing](06-distributed-monitoring.md#distributed-monitoring-setup-csr-auto-signing). **Required** for a signing master instance.
crl\_path | String | **Optional.** Path to the CRL file.
bind\_host | String | **Optional.** The IP address the api listener should be bound to. If not specified, the ApiListener is bound to `::` and listens for both IPv4 and IPv6 connections or to `0.0.0.0` if IPv6 is not supported by the operating system.
bind\_port | Number | **Optional.** The port the api listener should be bound to. Defaults to `5665`.
accept\_config | Boolean | **Optional.** Accept zone configuration. Defaults to `false`.
accept\_commands | Boolean | **Optional.** Accept remote commands. Defaults to `false`.
max\_anonymous\_clients | Number | **Optional.** Limit the number of anonymous client connections (not configured endpoints and signing requests).
cipher\_list | String | **Optional.** Cipher list that is allowed. For a list of available ciphers run `openssl ciphers`. Defaults to `ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256`.
tls\_protocolmin | String | **Optional.** Minimum TLS protocol version. Since v2.11, only `TLSv1.2` is supported. Defaults to `TLSv1.2`.
tls\_handshake\_timeout | Number | **Deprecated.** TLS Handshake timeout. Defaults to `10s`.
connect\_timeout | Number | **Optional.** Timeout for establishing new connections. Affects both incoming and outgoing connections. Within this time, the TCP and TLS handshakes must complete and either a HTTP request or an Icinga cluster connection must be initiated. Defaults to `15s`.
access\_control\_allow\_origin | Array | **Optional.** Specifies an array of origin URLs that may access the API. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Origin)
access\_control\_allow\_credentials | Boolean | **Deprecated.** Indicates whether or not the actual request can be made using credentials. Defaults to `true`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Credentials)
access\_control\_allow\_headers | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP headers can be used when making the actual request. Defaults to `Authorization`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Headers)
access\_control\_allow\_methods | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP methods can be used when making the actual request. Defaults to `GET, POST, PUT, DELETE`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Methods)
environment | String | **Optional.** Used as suffix in TLS SNI extension name; default from constant `ApiEnvironment`, which is empty.
http\_response\_headers | Dictionary | **Optional.** Additional headers to add to HTTP responses, for example `{"Strict-Transport-Security" = "max-age=31536000"}`. Defaults to none.
enforce\_filter\_expression\_permission | Boolean | **Optional.** Enforce the `filter-expression` permission. Defaults to `false` until v2.17 for compatibility.
The attributes `access_control_allow_credentials`, `access_control_allow_headers` and `access_control_allow_methods`
are controlled by Icinga 2 and are not changeable by config any more.

View file

@ -299,6 +299,18 @@ Available permissions for specific URL endpoints:
types | /v1/types | Yes | 1
variables | /v1/variables | Yes | 1
Available permissions that are not bound to specific URL endpoints:
Permissions | Description
------------------------------|------------
filter-expression | Allows the user to provide their own [advanced filter expressions](12-icinga2-api.md#icinga2-api-advanced-filters).
!!! warning
The `filter-expression` permission was introduced in v2.16.2 and is only enforced if the
[`enforce_filter_expression_permission` attribute of `ApiListener`](09-object-types.md#objecttype-apilistener)
is set to `true`. For compatibility reasons, this will not be enforced by default until v2.17.
The required actions or types can be replaced by using a wildcard match ("\*").
@ -435,6 +447,16 @@ The syntax for these filters is the same like for [apply rule expressions](03-mo
The `filter` parameter can only be specified once, complex filters must
be defined once in the provided string value.
!!! warning
In order to use these advanced filters, the `ApiUser` must be granted the `filter-expression` permission,
which should only be done for trusted users. The evaluation happens in the main Icinga 2 worker process and may be
abused for denial-of-service attacks, potentially crashing the Icinga 2 daemon.
Note that before v2.17, this permission is not enforced by default but only if the
[`enforce_filter_expression_permission` attribute of `ApiListener`](09-object-types.md#objecttype-apilistener)
is set accordingly.
> **Note**
>
> Filters used as URL parameter must be URL-encoded. The following examples

View file

@ -8,6 +8,23 @@ Specific version upgrades are described below. Please note that version
updates are incremental. An upgrade from v2.6 to v2.8 requires to
follow the instructions for v2.7 too.
## Upgrading to v2.16.2, v2.15.4, or v2.14.9 <a id="upgrading-to-2-16-2"></a>
### New `filter-expression` permission
When using the Icinga 2 REST API, filter expressions are a powerful tool. However, that power can also be abused for
denial-of-service attacks by authenticated users. Given that these filter expressions are Icinga 2 DSL expressions and
their evaluation happens in the main Icinga 2 worker process, this may also crash the Icinga 2 daemon.
In order to allow mitigating such problems, a new permission named `filter-expression` was added. **Before v2.17, this
permission will not be enforced by default** due to the impact on existing installations. Nonetheless, we recommend
reviewing your `ApiUser` configuration, explicitly allowing the new permission where necessary and enabling the
enforcement of the permission using the [`enforce_filter_expression_permission` attribute of
`ApiListener`](09-object-types.md#objecttype-apilistener). If an `ApiUser` makes use of the permission while the
permission is not enforced yet, it will be logged. Thus, it is also possible to upgrade, observe the logs, grant the
permission as needed or adapting API clients to avoid using filters if possible, and enable the enforcement at a later
time.
## Upgrading to v2.16 <a id="upgrading-to-2-16"></a>
### Migrating from ElasticsearchWriter to OTLPMetricsWriter

View file

@ -54,6 +54,9 @@ bool ActionsHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user);
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No objects found.",

View file

@ -234,6 +234,18 @@ void ApiListener::OnAllConfigLoaded()
if (!m_LocalEndpoint)
BOOST_THROW_EXCEPTION(ScriptError("Endpoint object for '" + GetIdentity() + "' is missing.", GetDebugInfo()));
if (!GetEnforceFilterExpressionPermission()) {
Log(LogWarning, "ApiListener") << "Security notice:\n"
" Currently, all ApiUsers are allowed to use Icinga 2 DSL filter expressions\n"
" in API queries because enforce_filter_expression_permission is set to false.\n"
" This can pose a security risk as filters are evaluated within the Icinga 2\n"
" process and their complexity can be used for denial of service attacks. The\n"
" new '" << ApiUser::FilterExpressionPerm << "' permission can be used to allow this for individual\n"
" ApiUsers, which should only be granted to trusted users. It is recommended\n"
" to set enforce_filter_expression_permission to true to enforce the\n"
" permission. This will become the default in v2.17.";
}
}
/**

View file

@ -63,6 +63,10 @@ class ApiListener : ConfigObject
[no_user_modify] String identity;
[state, no_user_modify] Dictionary::Ptr last_failed_zones_stage_validation;
[config, no_user_modify] bool enforce_filter_expression_permission {
default {{{ return false; }}}
};
};
}

View file

@ -21,6 +21,8 @@ public:
static ApiUser::Ptr GetByClientCN(const String& cn);
static ApiUser::Ptr GetByAuthHeader(const String& auth_header);
static inline const String FilterExpressionPerm = "filter-expression";
};
}

View file

@ -58,6 +58,9 @@ bool DeleteObjectHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user);
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No objects found.",

View file

@ -92,7 +92,18 @@ bool EventsHandler::HandleRequest(
}
}
EventsSubscriber subscriber (std::move(eventTypes), HttpUtility::GetLastParameter(params, "filter"), l_ApiQuery);
String filter = "";
if (params && params->Contains("filter")) {
if (!FilterUtility::HasPermission(request.User(), ApiUser::FilterExpressionPerm, nullptr)) {
HttpUtility::SendJsonError(response, params, 403,
"Missing permission: " + ApiUser::FilterExpressionPerm);
return true;
}
filter = HttpUtility::GetLastParameter(params, "filter");
}
EventsSubscriber subscriber (std::move(eventTypes), std::move(filter), l_ApiQuery);
response.result(http::status::ok);
response.set(http::field::content_type, "application/json");

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-2.0-or-later
#include "remote/filterutility.hpp"
#include "remote/apilistener.hpp"
#include "remote/httputility.hpp"
#include "config/applyrule.hpp"
#include "config/configcompiler.hpp"
@ -300,6 +301,22 @@ bool FilterUtility::HasPermission(const ApiUser::Ptr& user, const String& permis
}
}
// Requiring the "filter-expression" permission to use any filter expression is an incompatible change. Therefore,
// there is a config option to configure whether that permission check is enforced or not. If it is not enforced,
// a message is logged, allowing the admin to determine which ApiUsers require this permission. This allows granting
// the permission and/or adapting clients as needed until this message does not show up anymore and then switching
// to enforcing the permission check.
if (!foundPermission && permission == ApiUser::FilterExpressionPerm) {
ApiListener::Ptr listener = ApiListener::GetInstance();
if (listener && !listener->GetEnforceFilterExpressionPermission()) {
Log(LogWarning, "FilterUtility") << "ApiUser '" << user->GetName()
<< "' was allowed to use a filter expression despite missing the '" << ApiUser::FilterExpressionPerm
<< "' permission due to ApiListener.enforce_filter_expression_permission = false.";
return true;
}
}
if (!foundPermission) {
Log(LogWarning, "FilterUtility")
<< "Missing permission: " << requiredPermission;
@ -311,7 +328,7 @@ bool FilterUtility::HasPermission(const ApiUser::Ptr& user, const String& permis
void FilterUtility::CheckPermission(const ApiUser::Ptr& user, const String& permission, std::unique_ptr<Expression>* permissionFilter)
{
if (!HasPermission(user, permission, permissionFilter)) {
BOOST_THROW_EXCEPTION(ScriptError("Missing permission: " + permission.ToLower()));
BOOST_THROW_EXCEPTION(MissingPermissionError("Missing permission: " + permission.ToLower()));
}
}
@ -386,6 +403,7 @@ std::vector<Value> FilterUtility::GetFilterTargets(const QueryDescription& qd, c
frame.PermChecker = permissionChecker;
if (query->Contains("filter")) {
CheckPermission(user, ApiUser::FilterExpressionPerm, nullptr);
String filter = HttpUtility::GetLastParameter(query, "filter");
std::unique_ptr<Expression> ufilter = ConfigCompiler::CompileText("<API query>", filter);
Dictionary::Ptr filter_vars = query->Get("filter_vars");

View file

@ -62,6 +62,19 @@ public:
const Object::Ptr& target, const String& variableName = String());
};
/**
* Exception to report a missing permission to an API user.
*
* IMPORTANT: The what() message is reported back to the user and MUST NOT contain sensitive information like names of
* objects they are not allowed to access. When using the exception, also pay attention that throwing the exception
* does not introduce a sidechannel. For example, it should not be returned if a user-specified object exists but the
* user is not allowed to access it, otherwise they would learn that the object exists.
*/
class MissingPermissionError : public ScriptError
{
using ScriptError::ScriptError;
};
}
#endif /* FILTERUTILITY_H */

View file

@ -56,6 +56,9 @@ bool ModifyObjectHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user);
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No objects found.",

View file

@ -178,6 +178,9 @@ bool ObjectQueryHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user);
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No objects found.",

View file

@ -102,6 +102,9 @@ bool StatusHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user);
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No objects found.",

View file

@ -119,6 +119,9 @@ bool TemplateQueryHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user, "tmpl");
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No templates found.",

View file

@ -81,6 +81,9 @@ bool TypeQueryHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user);
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No objects found.",

View file

@ -91,6 +91,9 @@ bool VariableQueryHandler::HandleRequest(
try {
objs = FilterUtility::GetFilterTargets(qd, params, user, "variable");
} catch (const MissingPermissionError& ex) {
HttpUtility::SendJsonError(response, params, 403, ex.what());
return true;
} catch (const std::exception& ex) {
HttpUtility::SendJsonError(response, params, 404,
"No variables found.",

View file

@ -15,6 +15,60 @@ BOOST_AUTO_TEST_SUITE(remote_filterutility,
*boost::unit_test::label("config"))
// clang-format on
BOOST_FIXTURE_TEST_CASE(filter_expression_permission, IcingaApplicationFixture)
{
auto createObjects = []() {
String config = R"CONFIG({
object CheckCommand "dummy" {
command = "/bin/echo"
}
object ApiUser "withFilterPermission" {
permissions = [ "objects/query/*", "filter-expression" ]
}
object ApiUser "withoutFilterPermission" {
permissions = [ "objects/query/*" ]
}
object Host "host1" {
address = "host1"
check_command = "dummy"
}
})CONFIG";
std::unique_ptr<Expression> expr = ConfigCompiler::CompileText("<test>", config);
expr->Evaluate(*ScriptFrame::GetCurrentFrame());
};
ConfigItem::RunWithActivationContext(new Function("CreateTestObjects", createObjects));
auto userWithPerm = ApiUser::GetByName("withFilterPermission");
auto userWithoutPerm = ApiUser::GetByName("withoutFilterPermission");
QueryDescription qd;
qd.Types.insert("Host");
qd.Permission = "objects/query/Host";
Dictionary::Ptr queryParams = new Dictionary();
queryParams->Set("type", "Host");
// This is a filter that uses a get_object call on an object the permissionFilterUser
// has access to. A second user is tested that has access to everything, to make sure
// the filter evaluates properly in the first place.
queryParams->Set("filter", "true");
std::vector<Value> objs;
BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, userWithPerm));
BOOST_CHECK_EQUAL(objs.size(), 1);
BOOST_CHECK_EXCEPTION(FilterUtility::GetFilterTargets(qd, queryParams, userWithoutPerm), std::exception,
[](const std::exception& ex) {
boost::test_tools::assertion_result result{std::string_view(ex.what()) == "Missing permission: filter-expression"};
result.message() << "got exception: " << ex.what();
return result;
});
}
BOOST_FIXTURE_TEST_CASE(safe_function_permissions, IcingaApplicationFixture)
{
auto createObjects = []() {
@ -29,6 +83,7 @@ object ApiUser "allPermissionsUser" {
object ApiUser "permissionFilterUser" {
permissions = [
"filter-expression",
{
permission = "objects/query/Host"
filter = {{ host.name == {{{host1}}} }}
@ -161,6 +216,7 @@ object ApiUser "allPermissionsUser" {
object ApiUser "permissionFilterUser" {
permissions = [
"filter-expression",
"objects/query/Host",
{
permission = "variables"
@ -170,7 +226,10 @@ object ApiUser "permissionFilterUser" {
}
object ApiUser "noVariablePermUser" {
permissions = [ "objects/query/Host" ]
permissions = [
"filter-expression",
"objects/query/Host",
]
}
object Host "host1" {