Version in base suite: 2.14.6-1 Base version: icinga2_2.14.6-1 Target version: icinga2_2.14.6-1+deb13u1 Base file: /srv/ftp-master.debian.org/ftp/pool/main/i/icinga2/icinga2_2.14.6-1.dsc Target file: /srv/ftp-master.debian.org/policy/pool/main/i/icinga2/icinga2_2.14.6-1+deb13u1.dsc .gitlab-ci.yml | 2 changelog | 14 control | 2 gbp.conf | 2 patches/0001-Add-filter-expression-permission.patch | 502 ++++++++++ patches/0001-Add-permission-checking-to-script-frames-and-filter-.patch | 361 +++++++ patches/0001-Add-test-cases-for-checking-permissions-in-filter-ex.patch | 421 ++++++++ patches/0001-Add-tests-for-JsonDecode-depth-limit-in-combination-.patch | 63 + patches/0001-Add-upgrading-docs-for-v2.14.7.patch | 29 patches/0001-Also-execute-JsonDecode-stack-size-test-in-pthread-i.patch | 133 ++ patches/0001-Avoid-multiple-if-in-a-single-function-call-expressi.patch | 70 + patches/0001-Check-for-permission-in-get_object.patch | 28 patches/0001-Check-if-client-is-a-valid-endpoint-before-updating-.patch | 21 patches/0001-ConfigWriter-EmitScope-Escape-import.patch | 53 + patches/0001-Declare-functions-as-unsafe-that-aren-t-useful-in-fi.patch | 43 patches/0001-DerefExpression-Add-missing-nullptr-check.patch | 42 patches/0001-Don-t-shut-down-JSON-RPC-connection-if-a-message-fai.patch | 46 patches/0001-Don-t-use-boost-asio-io_context-strand-method-remove.patch | 32 patches/0001-Don-t-use-boost-asio-ip-tcp-resolver-query.patch | 49 patches/0001-Don-t-use-removed-boost-asio-spawn-overload-if-Boost.patch | 100 + patches/0001-Filter-global-variables-when-Sandboxed.patch | 173 +++ patches/0001-In-a-coroutine-re-throw-everything-ex.-std-exception.patch | 185 +++ patches/0001-JsonDecode-add-depth-limit.patch | 247 ++++ patches/0001-JsonDecode-include-path-in-JSON-depth-error.patch | 238 ++++ patches/0001-Prefer-icinga-String-GetData-over-icinga-String-CStr.patch | 45 patches/0001-Prevent-HTTP-requests-from-creating-deeply-nested-da.patch | 59 + patches/0001-Remove-TicketSalt-in-VariableQueryHandler-as-early-a.patch | 61 + patches/0001-Restore-single-argument-Json.decode-in-the-DSL.patch | 92 + patches/0001-Send-signals-as-Icinga-user-in-safe-reload-and-logro.patch | 51 + patches/0001-SpawnCoroutine-move-callback-into-wrapper-lambda.patch | 27 patches/0001-tests-add-SpawnSynchronizedCoroutine-to-base-json.cp.patch | 50 patches/series | 27 32 files changed, 3266 insertions(+), 2 deletions(-) dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp6okguu9z/icinga2_2.14.6-1.dsc: no acceptable signature found dpkg-source: warning: cannot verify inline signature for /srv/release.debian.org/tmp/tmp6okguu9z/icinga2_2.14.6-1+deb13u1.dsc: no acceptable signature found diff -Nru icinga2-2.14.6/debian/.gitlab-ci.yml icinga2-2.14.6/debian/.gitlab-ci.yml --- icinga2-2.14.6/debian/.gitlab-ci.yml 2024-11-15 16:29:56.000000000 +0000 +++ icinga2-2.14.6/debian/.gitlab-ci.yml 2026-07-07 04:21:07.000000000 +0000 @@ -3,6 +3,8 @@ - https://salsa.debian.org/salsa-ci-team/pipeline/raw/master/recipes/debian.yml variables: + RELEASE: 'trixie' + SALSA_CI_DISABLE_USCAN: 1 SALSA_CI_ENABLE_BUILD_PACKAGE_TWICE: 1 reprotest: diff -Nru icinga2-2.14.6/debian/changelog icinga2-2.14.6/debian/changelog --- icinga2-2.14.6/debian/changelog 2025-05-28 10:47:15.000000000 +0000 +++ icinga2-2.14.6/debian/changelog 2026-07-07 04:21:07.000000000 +0000 @@ -1,3 +1,17 @@ +icinga2 (2.14.6-1+deb13u1) trixie-security; urgency=medium + + * Team upload. + * Update branch in gbp.conf & Vcs-Git URL. + * Add upstream patches for security issues from 2.14.7. + Fixes: CVE-2025-61907, CVE-2025-61908, CVE-2025-61909 + * Add upstream patches from 2.14.8 for Boost 1.87 compatibility. + * Add upstream patches for security issues from 2.14.9. + Fixes: GHSA-wh38-wg57-5w7g, GHSA-jgqj-x5j9-vgcm, GHSA-vj39-ww8j-vvx5 + * Add upstream patch from 2.14.10 to fix regression in 2.14.9. + * Disable uscan Salsa CI job, not relevant for stable branches. + + -- Bas Couwenberg Tue, 07 Jul 2026 06:21:07 +0200 + icinga2 (2.14.6-1) unstable; urgency=high * Team upload. diff -Nru icinga2-2.14.6/debian/control icinga2-2.14.6/debian/control --- icinga2-2.14.6/debian/control 2025-03-20 11:04:25.000000000 +0000 +++ icinga2-2.14.6/debian/control 2026-07-07 04:21:07.000000000 +0000 @@ -33,7 +33,7 @@ tzdata Standards-Version: 4.7.2 Vcs-Browser: https://salsa.debian.org/nagios-team/icinga2 -Vcs-Git: https://salsa.debian.org/nagios-team/icinga2.git +Vcs-Git: https://salsa.debian.org/nagios-team/icinga2.git -b trixie Homepage: https://icinga.com Rules-Requires-Root: no diff -Nru icinga2-2.14.6/debian/gbp.conf icinga2-2.14.6/debian/gbp.conf --- icinga2-2.14.6/debian/gbp.conf 2024-11-15 16:29:56.000000000 +0000 +++ icinga2-2.14.6/debian/gbp.conf 2026-07-07 04:21:07.000000000 +0000 @@ -6,7 +6,7 @@ # The default name for the Debian branch is "master". # Change it if the name is different (for instance, "debian/unstable"). -debian-branch = master +debian-branch = trixie # git-import-orig uses the following names for the upstream tags. # Change the value if you are not using git-import-orig diff -Nru icinga2-2.14.6/debian/patches/0001-Add-filter-expression-permission.patch icinga2-2.14.6/debian/patches/0001-Add-filter-expression-permission.patch --- icinga2-2.14.6/debian/patches/0001-Add-filter-expression-permission.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Add-filter-expression-permission.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,502 @@ +From 47b02302a32f4cc88772ad9753bdf81d8ba6a4dc Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Fri, 22 May 2026 17:06:27 +0200 +Subject: Add filter-expression permission + +This allows preventing ApiUsers from evaluating their own DSL expressions for +improved security. + +(cherry picked from commit 324d5bb815f95deb7b87b22e42c6f152cdfd352e) +--- + doc/02-installation.md | 9 +++++ + doc/09-object-types.md | 43 ++++++++++---------- + doc/12-icinga2-api.md | 22 +++++++++++ + doc/16-upgrading-icinga-2.md | 17 ++++++++ + lib/remote/actionshandler.cpp | 3 ++ + lib/remote/apilistener.cpp | 12 ++++++ + lib/remote/apilistener.ti | 4 ++ + lib/remote/apiuser.hpp | 2 + + lib/remote/deleteobjecthandler.cpp | 3 ++ + lib/remote/eventshandler.cpp | 13 +++++- + lib/remote/filterutility.cpp | 20 +++++++++- + lib/remote/filterutility.hpp | 13 ++++++ + lib/remote/modifyobjecthandler.cpp | 3 ++ + lib/remote/objectqueryhandler.cpp | 3 ++ + lib/remote/statushandler.cpp | 3 ++ + lib/remote/templatequeryhandler.cpp | 3 ++ + lib/remote/typequeryhandler.cpp | 3 ++ + lib/remote/variablequeryhandler.cpp | 3 ++ + test/CMakeLists.txt | 1 + + test/remote-filterutility.cpp | 61 ++++++++++++++++++++++++++++- + 20 files changed, 217 insertions(+), 24 deletions(-) + +--- a/doc/02-installation.md ++++ b/doc/02-installation.md +@@ -362,6 +362,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 +--- a/doc/09-object-types.md ++++ b/doc/09-object-types.md +@@ -1088,27 +1088,28 @@ 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. ++ 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. ++ 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. +--- a/doc/12-icinga2-api.md ++++ b/doc/12-icinga2-api.md +@@ -299,6 +299,18 @@ Available permissions for specific URL e + 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 + 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 +--- a/doc/16-upgrading-icinga-2.md ++++ b/doc/16-upgrading-icinga-2.md +@@ -8,6 +8,23 @@ Specific version upgrades are described + 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 ++ ++### 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.14.7 + + This version includes a fix to the logrotate configuration in `/etc/logrotate.d/icinga2`. As this file is tracked as a +--- a/lib/remote/actionshandler.cpp ++++ b/lib/remote/actionshandler.cpp +@@ -56,6 +56,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.", +--- a/lib/remote/apilistener.cpp ++++ b/lib/remote/apilistener.cpp +@@ -232,6 +232,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."; ++ } + } + + /** +--- a/lib/remote/apilistener.ti ++++ b/lib/remote/apilistener.ti +@@ -61,6 +61,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; }}} ++ }; + }; + + } +--- a/lib/remote/apiuser.hpp ++++ b/lib/remote/apiuser.hpp +@@ -20,6 +20,8 @@ public: + + static ApiUser::Ptr GetByClientCN(const String& cn); + static ApiUser::Ptr GetByAuthHeader(const String& auth_header); ++ ++ static inline const String FilterExpressionPerm = "filter-expression"; + }; + + } +--- a/lib/remote/deleteobjecthandler.cpp ++++ b/lib/remote/deleteobjecthandler.cpp +@@ -57,6 +57,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.", +--- a/lib/remote/eventshandler.cpp ++++ b/lib/remote/eventshandler.cpp +@@ -98,7 +98,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(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); + + server.StartStreaming(); + +--- a/lib/remote/filterutility.cpp ++++ b/lib/remote/filterutility.cpp +@@ -1,6 +1,7 @@ + /* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + + #include "remote/filterutility.hpp" ++#include "remote/apilistener.hpp" + #include "remote/httputility.hpp" + #include "config/applyrule.hpp" + #include "config/configcompiler.hpp" +@@ -299,6 +300,22 @@ bool FilterUtility::HasPermission(const + } + } + ++ // 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; +@@ -310,7 +327,7 @@ bool FilterUtility::HasPermission(const + void FilterUtility::CheckPermission(const ApiUser::Ptr& user, const String& permission, std::unique_ptr* permissionFilter) + { + if (!HasPermission(user, permission, permissionFilter)) { +- BOOST_THROW_EXCEPTION(ScriptError("Missing permission: " + permission.ToLower())); ++ BOOST_THROW_EXCEPTION(MissingPermissionError("Missing permission: " + permission.ToLower())); + } + } + +@@ -385,6 +402,7 @@ std::vector FilterUtility::GetFil + frame.PermChecker = permissionChecker; + + if (query->Contains("filter")) { ++ CheckPermission(user, ApiUser::FilterExpressionPerm, nullptr); + String filter = HttpUtility::GetLastParameter(query, "filter"); + std::unique_ptr ufilter = ConfigCompiler::CompileText("", filter); + Dictionary::Ptr filter_vars = query->Get("filter_vars"); +--- a/lib/remote/filterutility.hpp ++++ b/lib/remote/filterutility.hpp +@@ -61,6 +61,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 */ +--- a/lib/remote/modifyobjecthandler.cpp ++++ b/lib/remote/modifyobjecthandler.cpp +@@ -55,6 +55,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.", +--- a/lib/remote/objectqueryhandler.cpp ++++ b/lib/remote/objectqueryhandler.cpp +@@ -158,6 +158,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.", +--- a/lib/remote/statushandler.cpp ++++ b/lib/remote/statushandler.cpp +@@ -101,6 +101,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.", +--- a/lib/remote/templatequeryhandler.cpp ++++ b/lib/remote/templatequeryhandler.cpp +@@ -118,6 +118,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.", +--- a/lib/remote/typequeryhandler.cpp ++++ b/lib/remote/typequeryhandler.cpp +@@ -82,6 +82,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.", +--- a/lib/remote/variablequeryhandler.cpp ++++ b/lib/remote/variablequeryhandler.cpp +@@ -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.", +--- a/test/CMakeLists.txt ++++ b/test/CMakeLists.txt +@@ -220,6 +220,7 @@ add_boost_test(base + remote_configpackageutility/ValidateName + remote_filterutility/safe_function_permissions + remote_filterutility/variable_expression_permissions ++ remote_filterutility/filter_expression_permission + remote_url/id_and_path + remote_url/parameters + remote_url/get_and_set +--- a/test/remote-filterutility.cpp ++++ b/test/remote-filterutility.cpp +@@ -15,6 +15,60 @@ BOOST_AUTO_TEST_SUITE(remote_filterutili + *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 expr = ConfigCompiler::CompileText("", 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 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" { diff -Nru icinga2-2.14.6/debian/patches/0001-Add-permission-checking-to-script-frames-and-filter-.patch icinga2-2.14.6/debian/patches/0001-Add-permission-checking-to-script-frames-and-filter-.patch --- icinga2-2.14.6/debian/patches/0001-Add-permission-checking-to-script-frames-and-filter-.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Add-permission-checking-to-script-frames-and-filter-.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,361 @@ +From c69ceda78affaa6c9de45093186619778ec12b67 Mon Sep 17 00:00:00 2001 +From: Johannes Schmidt +Date: Mon, 22 Sep 2025 12:25:26 +0200 +Subject: Add permission checking to script frames and filter utilities + +--- + lib/base/CMakeLists.txt | 1 + + lib/base/scriptframe.cpp | 5 +- + lib/base/scriptframe.hpp | 5 +- + lib/base/scriptpermission.cpp | 15 ++++ + lib/base/scriptpermission.hpp | 28 ++++++ + lib/remote/filterutility.cpp | 133 ++++++++++++++++++++++++++-- + lib/remote/filterutility.hpp | 2 + + lib/remote/variablequeryhandler.cpp | 13 +-- + 8 files changed, 178 insertions(+), 24 deletions(-) + create mode 100644 lib/base/scriptpermission.cpp + create mode 100644 lib/base/scriptpermission.hpp + +--- a/lib/base/CMakeLists.txt ++++ b/lib/base/CMakeLists.txt +@@ -62,6 +62,7 @@ set(base_SOURCES + ringbuffer.cpp ringbuffer.hpp + scriptframe.cpp scriptframe.hpp + scriptglobal.cpp scriptglobal.hpp ++ scriptpermission.cpp scriptpermission.hpp + scriptutils.cpp scriptutils.hpp + serializer.cpp serializer.hpp + shared.hpp +--- a/lib/base/scriptframe.cpp ++++ b/lib/base/scriptframe.cpp +@@ -45,13 +45,13 @@ INITIALIZE_ONCE_WITH_PRIORITY([]() { + }, InitializePriority::FreezeNamespaces); + + ScriptFrame::ScriptFrame(bool allocLocals) +- : Locals(allocLocals ? new Dictionary() : nullptr), Self(ScriptGlobal::GetGlobals()), Sandboxed(false), Depth(0) ++ : Locals(allocLocals ? new Dictionary() : nullptr), PermChecker(new ScriptPermissionChecker), Self(ScriptGlobal::GetGlobals()), Sandboxed(false), Depth(0) + { + InitializeFrame(); + } + + ScriptFrame::ScriptFrame(bool allocLocals, Value self) +- : Locals(allocLocals ? new Dictionary() : nullptr), Self(std::move(self)), Sandboxed(false), Depth(0) ++ : Locals(allocLocals ? new Dictionary() : nullptr), PermChecker(new ScriptPermissionChecker), Self(std::move(self)), Sandboxed(false), Depth(0) + { + InitializeFrame(); + } +@@ -63,6 +63,7 @@ void ScriptFrame::InitializeFrame() + if (frames && !frames->empty()) { + ScriptFrame *frame = frames->top(); + ++ PermChecker = frame->PermChecker; + Sandboxed = frame->Sandboxed; + } + +--- a/lib/base/scriptframe.hpp ++++ b/lib/base/scriptframe.hpp +@@ -5,7 +5,7 @@ + + #include "base/i2-base.hpp" + #include "base/dictionary.hpp" +-#include "base/array.hpp" ++#include "base/scriptpermission.hpp" + #include + #include + +@@ -15,8 +15,9 @@ namespace icinga + struct ScriptFrame + { + Dictionary::Ptr Locals; ++ ScriptPermissionChecker::Ptr PermChecker; /* inherited by next frame */ + Value Self; +- bool Sandboxed; ++ bool Sandboxed; /* inherited by next frame */ + int Depth; + + ScriptFrame(bool allocLocals); +--- /dev/null ++++ b/lib/base/scriptpermission.cpp +@@ -0,0 +1,15 @@ ++/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ ++ ++#include "base/scriptpermission.hpp" ++ ++using namespace icinga; ++ ++bool ScriptPermissionChecker::CanAccessGlobalVariable(const String&) ++{ ++ return true; ++} ++ ++bool ScriptPermissionChecker::CanAccessConfigObject(const ConfigObject::Ptr&) ++{ ++ return true; ++} +--- /dev/null ++++ b/lib/base/scriptpermission.hpp +@@ -0,0 +1,28 @@ ++/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ ++ ++#pragma once ++ ++#include "base/string.hpp" ++#include "base/shared-object.hpp" ++#include "base/configobject.hpp" ++ ++namespace icinga { ++ ++class ScriptPermissionChecker : public SharedObject ++{ ++public: ++ DECLARE_PTR_TYPEDEFS(ScriptPermissionChecker); ++ ++ ScriptPermissionChecker() = default; ++ ScriptPermissionChecker(const ScriptPermissionChecker&) = delete; ++ ScriptPermissionChecker(ScriptPermissionChecker&&) = delete; ++ ScriptPermissionChecker& operator=(const ScriptPermissionChecker&) = delete; ++ ScriptPermissionChecker& operator=(ScriptPermissionChecker&&) = delete; ++ ++ ~ScriptPermissionChecker() override = default; ++ ++ virtual bool CanAccessGlobalVariable(const String& varName); ++ virtual bool CanAccessConfigObject(const ConfigObject::Ptr& obj); ++}; ++ ++} // namespace icinga +--- a/lib/remote/filterutility.cpp ++++ b/lib/remote/filterutility.cpp +@@ -15,6 +15,120 @@ + + using namespace icinga; + ++Dictionary::Ptr FilterUtility::GetTargetForVar(const String& name, const Value& value) ++{ ++ return new Dictionary({ ++ { "name", name }, ++ { "type", value.GetReflectionType()->GetName() }, ++ { "value", value } ++ }); ++} ++ ++/** ++ * Controls access to an object or variable based on an ApiUser's permissions. ++ * ++ * This is accomplished by caching the generated filter expressions so they don't have to be ++ * regenerated again and again when access is repeatedly checked in script functions and when ++ * evaluating expressions. ++ */ ++class FilterExprPermissionChecker : public ScriptPermissionChecker ++{ ++public: ++ DECLARE_PTR_TYPEDEFS(FilterExprPermissionChecker); ++ ++ explicit FilterExprPermissionChecker(ApiUser::Ptr user) : m_User(std::move(user)) {} ++ ++ /** ++ * Check if the user has the given permission and cache the result if they do. ++ * ++ * This is a wrapper around FilterUtility::CheckPermission() that caches the generated ++ * filter expression for later use when checking permissions inside sandboxed ScriptFrames. ++ * ++ * Like FilterUtility::CheckPermission() an exception is thrown if the user does not have ++ * the requested permission. ++ * ++ * If the user has permission and there is a filter for the given permission, the filter ++ * expression is generated, cached and then a pointer to it is returned, otherwise a ++ * nullptr will be returned. ++ * ++ * Since the optionally returned pointer is a raw-pointer and this class retains ownership ++ * over the expression it is only valid for the lifetime of the @c FilterExprPermissionChecker ++ * object that returned it. ++ * ++ * @param permissionString The permission string to check against the ApiUser member of this class. ++ * ++ * @return a pointer to the generated permission expression if the permission has a filter, or nullptr if not. ++ */ ++ Expression* CheckPermission(const String& permissionString) ++ { ++ auto [it, inserted] = m_PermCache.try_emplace(permissionString); ++ auto& [hasPermission, permissionExpr] = it->second; ++ ++ if (inserted) { ++ FilterUtility::CheckPermission(m_User, permissionString, &permissionExpr); ++ } else if (!hasPermission) { ++ BOOST_THROW_EXCEPTION(ScriptError("Missing permission: " + permissionString.ToLower())); ++ } ++ ++ hasPermission = true; ++ return permissionExpr.get(); ++ } ++ ++ /** ++ * Checks if this object's ApiUser has permissions to access variable `varName`. ++ * ++ * @param varName The name of the variable to check for access ++ * ++ * @return 'true' if the variable can be accessed, 'false' if it can't. ++ */ ++ bool CanAccessGlobalVariable(const String& varName) override ++ { ++ auto obj = FilterUtility::GetTargetForVar(varName, ScriptGlobal::Get(varName)); ++ return CheckPermissionAndEvalFilter("variables", obj, "variable"); ++ } ++ ++ /** ++ * Checks if this object's ApiUser has permissions to access ConfigObject `obj`. ++ * ++ * @param obj A pointer to the ConfigObject to check for access ++ * ++ * @return 'true' if the object can be accessed, 'false' if it can't. ++ */ ++ bool CanAccessConfigObject(const ConfigObject::Ptr& obj) override ++ { ++ ASSERT(obj); ++ ++ String perm = "objects/query/" + obj->GetReflectionType()->GetName(); ++ String varName = obj->GetReflectionType()->GetName().ToLower(); ++ ++ return CheckPermissionAndEvalFilter(perm, obj, varName); ++ } ++ ++private: ++ bool CheckPermissionAndEvalFilter(const String& permissionString, const Object::Ptr& obj, const String& varName) ++ { ++ auto [it, inserted] = m_PermCache.try_emplace(permissionString); ++ auto& [hasPermission, permissionExpr] = it->second; ++ ++ if (inserted) { ++ hasPermission = FilterUtility::HasPermission(m_User, permissionString, &permissionExpr); ++ } ++ ++ if (hasPermission && permissionExpr) { ++ ScriptFrame permissionFrame(false, new Namespace()); ++ // Sandboxing is lifted because this only evaluates the function from the ++ // ApiUser->permissions->filter ++ permissionFrame.Sandboxed = false; ++ return FilterUtility::EvaluateFilter(permissionFrame, permissionExpr.get(), obj, varName); ++ } ++ ++ return hasPermission; ++ } ++ ++ std::unordered_map>> m_PermCache; ++ ApiUser::Ptr m_User; ++}; ++ + Type::Ptr FilterUtility::TypeFromPluralName(const String& pluralName) + { + String uname = pluralName; +@@ -211,8 +325,8 @@ std::vector FilterUtility::GetFil + else + provider = new ConfigObjectTargetProvider(); + +- std::unique_ptr permissionFilter; +- CheckPermission(user, qd.Permission, &permissionFilter); ++ FilterExprPermissionChecker::Ptr permissionChecker = new FilterExprPermissionChecker{user}; ++ auto* permissionFilter = permissionChecker->CheckPermission(qd.Permission); + + Namespace::Ptr permissionFrameNS = new Namespace(); + ScriptFrame permissionFrame(false, permissionFrameNS); +@@ -228,7 +342,7 @@ std::vector FilterUtility::GetFil + String name = HttpUtility::GetLastParameter(query, attr); + Object::Ptr target = provider->GetTargetByName(type, name); + +- if (!FilterUtility::EvaluateFilter(permissionFrame, permissionFilter.get(), target, variableName)) ++ if (!FilterUtility::EvaluateFilter(permissionFrame, permissionFilter, target, variableName)) + BOOST_THROW_EXCEPTION(ScriptError("Access denied to object '" + name + "' of type '" + type + "'")); + + result.emplace_back(std::move(target)); +@@ -244,7 +358,7 @@ std::vector FilterUtility::GetFil + for (const String& name : names) { + Object::Ptr target = provider->GetTargetByName(type, name); + +- if (!FilterUtility::EvaluateFilter(permissionFrame, permissionFilter.get(), target, variableName)) ++ if (!FilterUtility::EvaluateFilter(permissionFrame, permissionFilter, target, variableName)) + BOOST_THROW_EXCEPTION(ScriptError("Access denied to object '" + name + "' of type '" + type + "'")); + + result.emplace_back(std::move(target)); +@@ -268,6 +382,7 @@ std::vector FilterUtility::GetFil + Namespace::Ptr frameNS = new Namespace(); + ScriptFrame frame(false, frameNS); + frame.Sandboxed = true; ++ frame.PermChecker = permissionChecker; + + if (query->Contains("filter")) { + String filter = HttpUtility::GetLastParameter(query, "filter"); +@@ -322,7 +437,7 @@ std::vector FilterUtility::GetFil + + if (targeted) { + for (auto& target : targets) { +- if (FilterUtility::EvaluateFilter(permissionFrame, permissionFilter.get(), target, variableName)) { ++ if (FilterUtility::EvaluateFilter(permissionFrame, permissionFilter, target, variableName)) { + result.emplace_back(std::move(target)); + } + } +@@ -335,16 +450,16 @@ std::vector FilterUtility::GetFil + } + } + +- provider->FindTargets(type, [&permissionFrame, &permissionFilter, &frame, &ufilter, &result, variableName](const Object::Ptr& target) { +- FilteredAddTarget(permissionFrame, permissionFilter.get(), frame, &*ufilter, result, variableName, target); ++ provider->FindTargets(type, [&permissionFrame, permissionFilter, &frame, &ufilter, &result, variableName](const Object::Ptr& target) { ++ FilteredAddTarget(permissionFrame, permissionFilter, frame, &*ufilter, result, variableName, target); + }); + } + } else { + /* Ensure to pass a nullptr as filter expression. + * GCC 8.1.1 on F28 causes problems, see GH #6533. + */ +- provider->FindTargets(type, [&permissionFrame, &permissionFilter, &frame, &result, variableName](const Object::Ptr& target) { +- FilteredAddTarget(permissionFrame, permissionFilter.get(), frame, nullptr, result, variableName, target); ++ provider->FindTargets(type, [&permissionFrame, permissionFilter, &frame, &result, variableName](const Object::Ptr& target) { ++ FilteredAddTarget(permissionFrame, permissionFilter, frame, nullptr, result, variableName, target); + }); + } + } +--- a/lib/remote/filterutility.hpp ++++ b/lib/remote/filterutility.hpp +@@ -50,6 +50,8 @@ struct QueryDescription + class FilterUtility + { + public: ++ ++ static Dictionary::Ptr GetTargetForVar(const String& name, const Value& value); + static Type::Ptr TypeFromPluralName(const String& pluralName); + static void CheckPermission(const ApiUser::Ptr& user, const String& permission, std::unique_ptr* filter = nullptr); + static bool HasPermission(const ApiUser::Ptr& user, const String& permission, std::unique_ptr* permissionFilter = nullptr); +--- a/lib/remote/variablequeryhandler.cpp ++++ b/lib/remote/variablequeryhandler.cpp +@@ -19,15 +19,6 @@ class VariableTargetProvider final : pub + public: + DECLARE_PTR_TYPEDEFS(VariableTargetProvider); + +- static Dictionary::Ptr GetTargetForVar(const String& name, const Value& value) +- { +- return new Dictionary({ +- { "name", name }, +- { "type", value.GetReflectionType()->GetName() }, +- { "value", value } +- }); +- } +- + void FindTargets(const String& type, + const std::function& addTarget) const override + { +@@ -35,14 +26,14 @@ public: + Namespace::Ptr globals = ScriptGlobal::GetGlobals(); + ObjectLock olock(globals); + for (const Namespace::Pair& kv : globals) { +- addTarget(GetTargetForVar(kv.first, kv.second.Val)); ++ addTarget(FilterUtility::GetTargetForVar(kv.first, kv.second.Val)); + } + } + } + + Value GetTargetByName(const String& type, const String& name) const override + { +- return GetTargetForVar(name, ScriptGlobal::Get(name)); ++ return FilterUtility::GetTargetForVar(name, ScriptGlobal::Get(name)); + } + + bool IsValidType(const String& type) const override diff -Nru icinga2-2.14.6/debian/patches/0001-Add-test-cases-for-checking-permissions-in-filter-ex.patch icinga2-2.14.6/debian/patches/0001-Add-test-cases-for-checking-permissions-in-filter-ex.patch --- icinga2-2.14.6/debian/patches/0001-Add-test-cases-for-checking-permissions-in-filter-ex.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Add-test-cases-for-checking-permissions-in-filter-ex.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,421 @@ +From 3900402d7eb9c2a8007ae933a7f527e299dd0eed Mon Sep 17 00:00:00 2001 +From: Johannes Schmidt +Date: Wed, 3 Sep 2025 11:38:39 +0200 +Subject: Add test-cases for checking permissions in filter exprs + +--- + test/CMakeLists.txt | 4 + + test/config-ops.cpp | 43 ++++- + test/remote-filterutility.cpp | 322 ++++++++++++++++++++++++++++++++++ + 3 files changed, 368 insertions(+), 1 deletion(-) + create mode 100644 test/remote-filterutility.cpp + +--- a/test/CMakeLists.txt ++++ b/test/CMakeLists.txt +@@ -34,6 +34,7 @@ set(base_test_SOURCES + icinga-notification.cpp + icinga-perfdata.cpp + methods-pluginnotificationtask.cpp ++ remote-filterutility.cpp + remote-configpackageutility.cpp + remote-url.cpp + ${base_OBJS} +@@ -173,6 +174,7 @@ add_boost_test(base + config_apply/gettargetservices_noindexer_service + config_ops/simple + config_ops/advanced ++ config_ops/sandboxed_ticket_salt + icinga_checkresult/host_1attempt + icinga_checkresult/host_2attempts + icinga_checkresult/host_3attempts +@@ -216,6 +218,8 @@ add_boost_test(base + icinga_perfdata/empty_warn_crit_min_max + methods_pluginnotificationtask/truncate_long_output + remote_configpackageutility/ValidateName ++ remote_filterutility/safe_function_permissions ++ remote_filterutility/variable_expression_permissions + remote_url/id_and_path + remote_url/parameters + remote_url/get_and_set +--- a/test/config-ops.cpp ++++ b/test/config-ops.cpp +@@ -6,7 +6,8 @@ + + using namespace icinga; + +-BOOST_AUTO_TEST_SUITE(config_ops) ++BOOST_AUTO_TEST_SUITE(config_ops, ++ *boost::unit_test::label("config")) + + BOOST_AUTO_TEST_CASE(simple) + { +@@ -243,4 +244,44 @@ BOOST_AUTO_TEST_CASE(advanced) + BOOST_CHECK(func->Invoke() == 3); + } + ++BOOST_AUTO_TEST_CASE(sandboxed_ticket_salt) ++{ ++ ScriptFrame frame(true, new Namespace); ++ std::unique_ptr expr; ++ ++ auto ns = ScriptGlobal::GetGlobals(); ++ ns->Set("TicketSalt", "testvalue"); ++ ++ expr = ConfigCompiler::CompileText("", "TicketSalt"); ++ BOOST_CHECK_EQUAL(expr->Evaluate(frame).GetValue(), "testvalue"); ++ ++ expr = ConfigCompiler::CompileText("", "globals.TicketSalt"); ++ BOOST_CHECK_EQUAL(expr->Evaluate(frame).GetValue(), "testvalue"); ++ ++ expr = ConfigCompiler::CompileText("", "*&TicketSalt"); ++ BOOST_CHECK_EQUAL(expr->Evaluate(frame).GetValue(), "testvalue"); ++ ++ expr = ConfigCompiler::CompileText("", "globals.TicketSalt = {{{other}}}"); ++ BOOST_CHECK_NO_THROW(expr->Evaluate(frame)); ++ ++ frame.Sandboxed = true; ++ ns->Set("TicketSalt", "testvalue", false); ++ ++ // Accessing TicketSalt in a sandboxed context is like trying to access a variable that doesn't exist. ++ // In case of direct access, it will throw a ScriptError. ++ expr = ConfigCompiler::CompileText("", "TicketSalt"); ++ BOOST_CHECK_THROW(expr->Evaluate(frame).GetValue(), ScriptError); ++ ++ // In case of other ways of accessing it, like through the global scope, it evaluates to Empty ++ expr = ConfigCompiler::CompileText("", "globals.TicketSalt"); ++ BOOST_CHECK_EQUAL(expr->Evaluate(frame).GetValue(), ""); ++ ++ // Same for (the different ways of) trying to access it via a reference. ++ expr = ConfigCompiler::CompileText("", "*&TicketSalt"); ++ BOOST_CHECK_EQUAL(expr->Evaluate(frame).GetValue(), ""); ++ ++ expr = ConfigCompiler::CompileText("", "globals.TicketSalt = {{{other}}}"); ++ BOOST_CHECK_THROW(expr->Evaluate(frame), ScriptError); ++} ++ + BOOST_AUTO_TEST_SUITE_END() +--- /dev/null ++++ b/test/remote-filterutility.cpp +@@ -0,0 +1,322 @@ ++/* Icinga 2 | (c) 2025 Icinga GmbH | GPLv2+ */ ++ ++#include ++#include "icinga/host.hpp" ++#include "remote/apiuser.hpp" ++#include "remote/filterutility.hpp" ++#include "test/icingaapplication-fixture.hpp" ++#include "config/configcompiler.hpp" ++#include "config/configitem.hpp" ++ ++using namespace icinga; ++ ++// clang-format off ++BOOST_AUTO_TEST_SUITE(remote_filterutility, ++ *boost::unit_test::label("config")) ++// clang-format on ++ ++BOOST_FIXTURE_TEST_CASE(safe_function_permissions, IcingaApplicationFixture) ++{ ++ auto createObjects = []() { ++ String config = R"CONFIG({ ++object CheckCommand "dummy" { ++ command = "/bin/echo" ++} ++ ++object ApiUser "allPermissionsUser" { ++ permissions = [ "*" ] ++} ++ ++object ApiUser "permissionFilterUser" { ++ permissions = [ ++ { ++ permission = "objects/query/Host" ++ filter = {{ host.name == {{{host1}}} }} ++ }, ++ { ++ permission = "objects/query/Service" ++ filter = {{ service.name == {{{svc1}}} }} ++ } ++ ] ++} ++ ++object Host "host1" { ++ address = "host1" ++ check_command = "dummy" ++} ++ ++object Host "host2" { ++ address = "host2" ++ check_command = "dummy" ++} ++ ++object Service "svc1" { ++ host_name = "host1" ++ check_command = "dummy" ++} ++ ++object Service "svc2" { ++ host_name = "host2" ++ check_command = "dummy" ++} ++})CONFIG"; ++ std::unique_ptr expr = ConfigCompiler::CompileText("", config); ++ expr->Evaluate(*ScriptFrame::GetCurrentFrame()); ++ }; ++ ++ ConfigItem::RunWithActivationContext(new Function("CreateTestObjects", createObjects)); ++ ++ auto allPermissionsUser = ApiUser::GetByName("allPermissionsUser"); ++ auto permissionFilterUser = ApiUser::GetByName("permissionFilterUser"); ++ ++ QueryDescription qd; ++ qd.Types.insert("Host"); ++ qd.Types.insert("Service"); ++ 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", "get_object(Host,{{{host1}}}).name == {{{host1}}}"); ++ ++ std::vector objs; ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 2); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ ++ // We need to test again with querying services, while still using the get_object(Host) filter, ++ // because we need to verify permissions in filters work regardless of whether the object type ++ // that is queried is the same or different from the one that is checked in the filters. ++ qd.Permission = "objects/query/Service"; ++ queryParams->Set("type", "Service"); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 2); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ ++ // Now test again with a filter that always evaluates to false. ++ // Both users shouldn't find objects. ++ queryParams->Set("filter", "get_object(Host,{{{host2}}}).name == {{{host1}}}"); ++ qd.Permission = "objects/query/Host"; ++ queryParams->Set("type", "Host"); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK(objs.empty()); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK(objs.empty()); ++ ++ // Again, the same test with querying service objects instead of hosts. ++ qd.Permission = "objects/query/Service"; ++ queryParams->Set("type", "Service"); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK(objs.empty()); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK(objs.empty()); ++ ++ // In the previous asserts we have established that filters work as intended with valid permissions. ++ // Now test again with a valid filter that tries to access a host object the permissionFilterUser ++ // doesn't have access to. It should still return an empty array. ++ queryParams->Set("filter", "get_object(Host,{{{host2}}}).name == {{{host2}}}"); ++ qd.Permission = "objects/query/Host"; ++ queryParams->Set("type", "Host"); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 2); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK(objs.empty()); ++ ++ // Again, the same test with querying service objects instead of hosts. ++ qd.Permission = "objects/query/Service"; ++ queryParams->Set("type", "Service"); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 2); ++ ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK(objs.empty()); ++} ++ ++BOOST_FIXTURE_TEST_CASE(variable_expression_permissions, IcingaApplicationFixture) ++{ ++ auto createObjects = []() { ++ String config = R"CONFIG({ ++object CheckCommand "dummy" { ++ command = "/bin/echo" ++} ++ ++object ApiUser "allPermissionsUser" { ++ permissions = [ "*" ] ++} ++ ++object ApiUser "permissionFilterUser" { ++ permissions = [ ++ "objects/query/Host", ++ { ++ permission = "variables" ++ filter = {{ variable.name != {{{SuperSecretConstant}}} }} ++ } ++ ] ++} ++ ++object ApiUser "noVariablePermUser" { ++ permissions = [ "objects/query/Host" ] ++} ++ ++object Host "host1" { ++ address = "host1" ++ check_command = "dummy" ++} ++})CONFIG"; ++ std::unique_ptr expr = ConfigCompiler::CompileText("", config); ++ expr->Evaluate(*ScriptFrame::GetCurrentFrame()); ++ }; ++ ++ ConfigItem::RunWithActivationContext(new Function("CreateTestObjects", createObjects)); ++ ++ auto allPermissionsUser = ApiUser::GetByName("allPermissionsUser"); ++ auto permissionFilterUser = ApiUser::GetByName("permissionFilterUser"); ++ auto noVariablePermUser = ApiUser::GetByName("noVariablePermUser"); ++ ++ QueryDescription qd; ++ qd.Types.insert("Host"); ++ qd.Types.insert("Service"); ++ qd.Permission = "objects/query/Host"; ++ ++ ScriptGlobal::Set("VeryUsefulConstant", "Test1"); ++ ScriptGlobal::Set("SuperSecretConstant", "MyCleartextBankingPassword"); ++ ScriptGlobal::Set("TicketSalt", "Test2"); ++ ++ Dictionary::Ptr queryParams = new Dictionary(); ++ queryParams->Set("type", "Host"); ++ ++ std::vector objs; ++ ++ // First test a simple variable access. ++ // We expect the user with the right permissions to be able to query the object, ++ // while for a user without permission a ScriptError should be thrown. ++ queryParams->Set("filter", "VeryUsefulConstant == {{{Test1}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser), ScriptError); ++ ++ // The variable can also be referenced and dereferenced. ++ // Unlike the variable access above indirectly accessing the variable without permissions does not ++ // throw a ScriptError but just returns an empty string. ++ queryParams->Set("filter", "*&VeryUsefulConstant == {{{Test1}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // The variable can also be referenced and dereferenced via get(), which should have the ++ // same result as above. ++ queryParams->Set("filter", "(&VeryUsefulConstant).get() == {{{Test1}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // Global variables can also be accessed via an IndexerExpression. The result should be the same as above. ++ queryParams->Set("filter", "globals[{{{VeryUsefulConstant}}}] == {{{Test1}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // Now we verify that a user that isn't allowed to access a constant is not able to do so in a ++ // filter expression. ++ // The allPermissionsUser should be able to access the variable. ++ // The permissionFilterUser should receive an exception because they are specifically ++ // forbidden from reading that variable. ++ // Same for the noVariablePermUser, which as before isn't allowed to read any variable. ++ queryParams->Set("filter", "SuperSecretConstant == {{{MyCleartextBankingPassword}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser), ScriptError); ++ BOOST_REQUIRE_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser), ScriptError); ++ ++ // Repeat the other ways to access secret variables, again, only the allPermissionsUser should ++ // be able to use it. ++ queryParams->Set("filter", "*&SuperSecretConstant == {{{MyCleartextBankingPassword}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // Repeat the other ways to access secret variables, again, only the allPermissionsUser should ++ // be able to use it. ++ queryParams->Set("filter", "(&SuperSecretConstant).get() == {{{MyCleartextBankingPassword}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // Repeat the other ways to access secret variables, again, only the allPermissionsUser should ++ // be able to use it. ++ queryParams->Set("filter", "globals[{{{SuperSecretConstant}}}] == {{{MyCleartextBankingPassword}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 1); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // We also need to verify that even a user with all permissions can not access the TicketSalt variable. ++ // Like in the other cases above, direct access should throw. ++ queryParams->Set("filter", "TicketSalt == {{{Test2}}}"); ++ BOOST_REQUIRE_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser), ScriptError); ++ BOOST_REQUIRE_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser), ScriptError); ++ BOOST_REQUIRE_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser), ScriptError); ++ ++ // Repeat the other ways to access variables with TicketSalt. ++ queryParams->Set("filter", "*&TicketSalt == {{{Test2}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // Repeat the other ways to access variables with TicketSalt. ++ queryParams->Set("filter", "(&TicketSalt).get() == {{{Test2}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ ++ // Repeat the other ways to access variables with TicketSalt. ++ queryParams->Set("filter", "globals[{{{TicketSalt}}}] == {{{Test2}}}"); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, allPermissionsUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, permissionFilterUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++ BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, noVariablePermUser)); ++ BOOST_CHECK_EQUAL(objs.size(), 0); ++} ++ ++BOOST_AUTO_TEST_SUITE_END() diff -Nru icinga2-2.14.6/debian/patches/0001-Add-tests-for-JsonDecode-depth-limit-in-combination-.patch icinga2-2.14.6/debian/patches/0001-Add-tests-for-JsonDecode-depth-limit-in-combination-.patch --- icinga2-2.14.6/debian/patches/0001-Add-tests-for-JsonDecode-depth-limit-in-combination-.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Add-tests-for-JsonDecode-depth-limit-in-combination-.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,63 @@ +From 69093a8ae0f09bbef8f589cbc67f131f167e5aa3 Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Tue, 9 Jun 2026 15:09:13 +0200 +Subject: Add tests for JsonDecode depth limit in combination with coroutine + stacks + +(cherry picked from commit 0481a66b01f0a8c02d70bbe0ff40417addfeff05) +--- + test/CMakeLists.txt | 1 + + test/base-json.cpp | 28 ++++++++++++++++++++++++++++ + 2 files changed, 29 insertions(+) + +--- a/test/CMakeLists.txt ++++ b/test/CMakeLists.txt +@@ -87,6 +87,7 @@ add_boost_test(base + base_json/decode + base_json/invalid1 + base_json/decode_depth_limit ++ base_json/coroutine_stack_size + base_object_packer/pack_null + base_object_packer/pack_false + base_object_packer/pack_true +--- a/test/base-json.cpp ++++ b/test/base-json.cpp +@@ -1,5 +1,6 @@ + /* Icinga 2 | (c) 2012 Icinga GmbH | GPLv2+ */ + ++#include "base/convert.hpp" + #include "base/dictionary.hpp" + #include "base/function.hpp" + #include "base/namespace.hpp" +@@ -247,4 +248,31 @@ BOOST_AUTO_TEST_CASE(decode_depth_limit) + BOOST_CHECK_NO_THROW(JsonDecode(deeperMixedNesting, 10)); + } + ++/* This test case decodes JSON nested much deeper (see safetyFactor) than the default depth limit and performs some ++ * operations on the resulting values. This is done within a coroutine with its limited stack size on order to verify ++ * that the default limit is low enough to be safe. Note that this isn't an exact science unfortunately: other ++ * recursive operations may have larger stack frames and these operations aren't the only thing on a stack (could be ++ * done inside an HTTP or JSON-RPC connection for example). Therefor, aim for a sufficiently large safety factor. ++ */ ++BOOST_AUTO_TEST_CASE(coroutine_stack_size) ++{ ++ auto future = SpawnSynchronizedCoroutine([](boost::asio::yield_context) { ++ constexpr size_t safetyFactor = 10; ++ constexpr size_t depth = JsonDecodeDefaultDepthLimit * safetyFactor; ++ ++ Value val; ++ ++ BOOST_REQUIRE_NO_THROW(val = JsonDecode(MakeNestedJsonArray(depth), depth)); ++ BOOST_REQUIRE_NO_THROW(val.Clone()); ++ BOOST_REQUIRE_NO_THROW(Convert::ToString(val)); ++ BOOST_REQUIRE_NO_THROW(JsonDecode(JsonEncode(val), depth)); ++ ++ BOOST_REQUIRE_NO_THROW(val = JsonDecode(MakeNestedJsonObject(depth), depth)); ++ BOOST_REQUIRE_NO_THROW(val.Clone()); ++ BOOST_REQUIRE_NO_THROW(Convert::ToString(val)); ++ BOOST_REQUIRE_NO_THROW(JsonDecode(JsonEncode(val), depth)); ++ }); ++ future.get(); ++} ++ + BOOST_AUTO_TEST_SUITE_END() diff -Nru icinga2-2.14.6/debian/patches/0001-Add-upgrading-docs-for-v2.14.7.patch icinga2-2.14.6/debian/patches/0001-Add-upgrading-docs-for-v2.14.7.patch --- icinga2-2.14.6/debian/patches/0001-Add-upgrading-docs-for-v2.14.7.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Add-upgrading-docs-for-v2.14.7.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,29 @@ +From 99e12317a270c7f328bd9b7d471bc7c655afec4a Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Mon, 13 Oct 2025 12:43:26 +0200 +Subject: Add upgrading docs for v2.14.7 + +--- + doc/16-upgrading-icinga-2.md | 11 +++++++++++ + 1 file changed, 11 insertions(+) + +--- a/doc/16-upgrading-icinga-2.md ++++ b/doc/16-upgrading-icinga-2.md +@@ -8,6 +8,17 @@ Specific version upgrades are described + updates are incremental. An upgrade from v2.6 to v2.8 requires to + follow the instructions for v2.7 too. + ++## Upgrading to v2.14.7 ++ ++This version includes a fix to the logrotate configuration in `/etc/logrotate.d/icinga2`. As this file is tracked as a ++configuration file by package managers, it may not be updated automatically if it was modified locally. After upgrading, ++make sure to check if there are any files with an extension like `.dpkg-dist` or `.rpmnew` next to it. If so, you need ++to incorporate the changes into your configuration manually. ++ ++To verify that the fix was applied correctly, check the contents of `/etc/logrotate.d/icinga2`: If the file uses the ++command `"$DAEMON" internal signal --sig SIGHUP --pid "$pid"` (instead of `kill -HUP "$pid"`), it was upgraded ++correctly. ++ + ## Upgrading to v2.14 + + ### Dependencies and Redundancy Groups diff -Nru icinga2-2.14.6/debian/patches/0001-Also-execute-JsonDecode-stack-size-test-in-pthread-i.patch icinga2-2.14.6/debian/patches/0001-Also-execute-JsonDecode-stack-size-test-in-pthread-i.patch --- icinga2-2.14.6/debian/patches/0001-Also-execute-JsonDecode-stack-size-test-in-pthread-i.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Also-execute-JsonDecode-stack-size-test-in-pthread-i.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,133 @@ +From e23a3eb42d791f27c1073b56769800fe12156425 Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Thu, 18 Jun 2026 17:31:01 +0200 +Subject: Also execute JsonDecode stack size test in pthread if available + +Depending on the Boost version, the existing test case based on Boost.Asio +coroutines might use a stack allocated without a guard page and might not +reliably detect an overflow. This commit additionally runs the same test +function within a pthread thread started with the same stack size as used by +our coroutines. + +(cherry picked from commit a155da01b247c7439e90979fb1105ddcdf5ef94e) +--- + CMakeLists.txt | 1 + + config.h.cmake | 1 + + test/CMakeLists.txt | 3 ++- + test/base-json.cpp | 65 +++++++++++++++++++++++++++++++++------------ + 4 files changed, 52 insertions(+), 18 deletions(-) + +--- a/CMakeLists.txt ++++ b/CMakeLists.txt +@@ -366,6 +366,7 @@ check_function_exists(backtrace_symbols + check_function_exists(pipe2 HAVE_PIPE2) + check_function_exists(nice HAVE_NICE) + check_function_exists(malloc_info HAVE_MALLOC_INFO) ++check_function_exists(pthread_create HAVE_PTHREAD_CREATE) + check_library_exists(dl dladdr "dlfcn.h" HAVE_DLADDR) + check_library_exists(execinfo backtrace_symbols "" HAVE_LIBEXECINFO) + check_include_file_cxx(cxxabi.h HAVE_CXXABI_H) +--- a/config.h.cmake ++++ b/config.h.cmake +@@ -9,6 +9,7 @@ + #cmakedefine HAVE_CXXABI_H + #cmakedefine HAVE_NICE + #cmakedefine HAVE_MALLOC_INFO ++#cmakedefine HAVE_PTHREAD_CREATE + #cmakedefine HAVE_EDITLINE + #cmakedefine HAVE_SYSTEMD + +--- a/test/CMakeLists.txt ++++ b/test/CMakeLists.txt +@@ -87,7 +87,8 @@ add_boost_test(base + base_json/decode + base_json/invalid1 + base_json/decode_depth_limit +- base_json/coroutine_stack_size ++ base_json/stack_size_coroutine ++ base_json/stack_size_pthread + base_object_packer/pack_null + base_object_packer/pack_false + base_object_packer/pack_true +--- a/test/base-json.cpp ++++ b/test/base-json.cpp +@@ -249,30 +249,61 @@ BOOST_AUTO_TEST_CASE(decode_depth_limit) + } + + /* This test case decodes JSON nested much deeper (see safetyFactor) than the default depth limit and performs some +- * operations on the resulting values. This is done within a coroutine with its limited stack size on order to verify +- * that the default limit is low enough to be safe. Note that this isn't an exact science unfortunately: other +- * recursive operations may have larger stack frames and these operations aren't the only thing on a stack (could be +- * done inside an HTTP or JSON-RPC connection for example). Therefor, aim for a sufficiently large safety factor. ++ * operations on the resulting values. This is done with its limited stack size on order to verify that the default ++ * limit is low enough to be safe. Note that this isn't an exact science unfortunately: other recursive operations may ++ * have larger stack frames and these operations aren't the only thing on a stack (could be done inside an HTTP or ++ * JSON-RPC connection for example). Therefor, aim for a sufficiently large safety factor. The test function may be ++ * executed twice, once in a coroutine (which may not have a guard page and reliably detect an overflow depending on ++ * the Boost version), once in a pthread thread (which may not be available everywhere). + */ +-BOOST_AUTO_TEST_CASE(coroutine_stack_size) ++static void TestJsonStackSize() + { +- auto future = SpawnSynchronizedCoroutine([](boost::asio::yield_context) { +- constexpr size_t safetyFactor = 10; +- constexpr size_t depth = JsonDecodeDefaultDepthLimit * safetyFactor; ++ constexpr size_t safetyFactor = 10; ++ constexpr size_t depth = JsonDecodeDefaultDepthLimit * safetyFactor; ++ ++ Value val; ++ ++ BOOST_REQUIRE_NO_THROW(val = JsonDecode(MakeNestedJsonArray(depth), depth)); ++ BOOST_REQUIRE_NO_THROW(val.Clone()); ++ BOOST_REQUIRE_NO_THROW(Convert::ToString(val)); ++ BOOST_REQUIRE_NO_THROW(JsonDecode(JsonEncode(val), depth)); + +- Value val; ++ BOOST_REQUIRE_NO_THROW(val = JsonDecode(MakeNestedJsonObject(depth), depth)); ++ BOOST_REQUIRE_NO_THROW(val.Clone()); ++ BOOST_REQUIRE_NO_THROW(Convert::ToString(val)); ++ BOOST_REQUIRE_NO_THROW(JsonDecode(JsonEncode(val), depth)); ++} + +- BOOST_REQUIRE_NO_THROW(val = JsonDecode(MakeNestedJsonArray(depth), depth)); +- BOOST_REQUIRE_NO_THROW(val.Clone()); +- BOOST_REQUIRE_NO_THROW(Convert::ToString(val)); +- BOOST_REQUIRE_NO_THROW(JsonDecode(JsonEncode(val), depth)); +- +- BOOST_REQUIRE_NO_THROW(val = JsonDecode(MakeNestedJsonObject(depth), depth)); +- BOOST_REQUIRE_NO_THROW(val.Clone()); +- BOOST_REQUIRE_NO_THROW(Convert::ToString(val)); +- BOOST_REQUIRE_NO_THROW(JsonDecode(JsonEncode(val), depth)); ++BOOST_AUTO_TEST_CASE(stack_size_coroutine) ++{ ++ auto future = SpawnSynchronizedCoroutine([](boost::asio::yield_context) { ++ TestJsonStackSize(); + }); + future.get(); + } + ++BOOST_AUTO_TEST_CASE(stack_size_pthread) ++{ ++#ifdef HAVE_PTHREAD_CREATE ++ auto pagesize = sysconf(_SC_PAGESIZE); ++ BOOST_REQUIRE_GT(pagesize, 0); ++ ++ pthread_attr_t attr; ++ BOOST_REQUIRE_EQUAL(0, pthread_attr_init(&attr)); ++ BOOST_REQUIRE_EQUAL(0, pthread_attr_setstacksize(&attr, IoEngine::GetCoroutineStackSize())); ++ BOOST_REQUIRE_EQUAL(0, pthread_attr_setguardsize(&attr, pagesize)); ++ ++ pthread_t thread; ++ BOOST_REQUIRE_EQUAL(0, pthread_create(&thread, &attr, [](void*) -> void* { ++ TestJsonStackSize(); ++ return nullptr; ++ }, nullptr)); ++ ++ BOOST_REQUIRE_EQUAL(0, pthread_join(thread, nullptr)); ++ BOOST_REQUIRE_EQUAL(0, pthread_attr_destroy(&attr)); ++#else /* HAVE_PTHREAD_CREATE */ ++ BOOST_TEST_MESSAGE("pthread not available"); ++#endif /* HAVE_PTHREAD_CREATE */ ++} ++ + BOOST_AUTO_TEST_SUITE_END() diff -Nru icinga2-2.14.6/debian/patches/0001-Avoid-multiple-if-in-a-single-function-call-expressi.patch icinga2-2.14.6/debian/patches/0001-Avoid-multiple-if-in-a-single-function-call-expressi.patch --- icinga2-2.14.6/debian/patches/0001-Avoid-multiple-if-in-a-single-function-call-expressi.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Avoid-multiple-if-in-a-single-function-call-expressi.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,70 @@ +From 30e42f9e29687c2b16384278c31f7e857b3a55fc Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Fri, 11 Apr 2025 13:05:21 +0200 +Subject: Avoid multiple #if in a single function call expression + +Simply giving two entire call expressions for either Boost version greatly +improves readability in my opinion. + +(cherry picked from commit d1d399f8b329dbe3f6f1f172ece165332fb9512f) +--- + lib/base/io-engine.hpp | 41 +++++++++++++++++++---------------------- + 1 file changed, 19 insertions(+), 22 deletions(-) + +--- a/lib/base/io-engine.hpp ++++ b/lib/base/io-engine.hpp +@@ -105,35 +105,32 @@ public: + + template + static void SpawnCoroutine(Handler& h, Function f) { +- +- boost::asio::spawn(h, +-#if BOOST_VERSION >= 108700 +- std::allocator_arg, +- boost::context::fixedsize_stack(GetCoroutineStackSize()), +-#endif // BOOST_VERSION >= 108700 +- [f](boost::asio::yield_context yc) { +- ++ auto wrapper = [f](boost::asio::yield_context yc) { ++ try { ++ f(yc); ++ } catch (const std::exception& ex) { ++ Log(LogCritical, "IoEngine") << "Exception in coroutine: " << DiagnosticInformation(ex); ++ } catch (...) { + try { +- f(yc); +- } catch (const std::exception& ex) { +- Log(LogCritical, "IoEngine") << "Exception in coroutine: " << DiagnosticInformation(ex); ++ Log(LogCritical, "IoEngine", "Exception in coroutine!"); + } catch (...) { +- try { +- Log(LogCritical, "IoEngine", "Exception in coroutine!"); +- } catch (...) { +- } +- +- // Required for proper stack unwinding when coroutines are destroyed. +- // https://github.com/boostorg/coroutine/issues/39 +- throw; + } +- }, ++ ++ // Required for proper stack unwinding when coroutines are destroyed. ++ // https://github.com/boostorg/coroutine/issues/39 ++ throw; ++ } ++ }; ++ + #if BOOST_VERSION >= 108700 ++ boost::asio::spawn(h, ++ std::allocator_arg, boost::context::fixedsize_stack(GetCoroutineStackSize()), ++ std::move(wrapper), + boost::asio::detached ++ ); + #else // BOOST_VERSION >= 108700 +- boost::coroutines::attributes(GetCoroutineStackSize()) // Set a pre-defined stack size. ++ boost::asio::spawn(h, std::move(wrapper), boost::coroutines::attributes(GetCoroutineStackSize())); + #endif // BOOST_VERSION >= 108700 +- ); + } + + static inline diff -Nru icinga2-2.14.6/debian/patches/0001-Check-for-permission-in-get_object.patch icinga2-2.14.6/debian/patches/0001-Check-for-permission-in-get_object.patch --- icinga2-2.14.6/debian/patches/0001-Check-for-permission-in-get_object.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Check-for-permission-in-get_object.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,28 @@ +From 2261fe087b99ebb0c08c5223533aed3812a257e5 Mon Sep 17 00:00:00 2001 +From: Johannes Schmidt +Date: Mon, 22 Sep 2025 12:26:03 +0200 +Subject: Check for permission in get_object() + +--- + lib/base/scriptutils.cpp | 10 +++++++++- + 1 file changed, 9 insertions(+), 1 deletion(-) + +--- a/lib/base/scriptutils.cpp ++++ b/lib/base/scriptutils.cpp +@@ -475,7 +475,15 @@ ConfigObject::Ptr ScriptUtils::GetObject + if (!ctype) + return nullptr; + +- return ctype->GetObject(name); ++ auto cfgObj = ctype->GetObject(name); ++ if (cfgObj) { ++ auto* frame = ScriptFrame::GetCurrentFrame(); ++ if (frame->PermChecker->CanAccessConfigObject(cfgObj)) { ++ return cfgObj; ++ } ++ } ++ ++ return nullptr; + } + + Array::Ptr ScriptUtils::GetObjects(const Type::Ptr& type) diff -Nru icinga2-2.14.6/debian/patches/0001-Check-if-client-is-a-valid-endpoint-before-updating-.patch icinga2-2.14.6/debian/patches/0001-Check-if-client-is-a-valid-endpoint-before-updating-.patch --- icinga2-2.14.6/debian/patches/0001-Check-if-client-is-a-valid-endpoint-before-updating-.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Check-if-client-is-a-valid-endpoint-before-updating-.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,21 @@ +From 4b7fb3405f4616a24b2b55e20f603a56b7dd6ad0 Mon Sep 17 00:00:00 2001 +From: Johannes Schmidt +Date: Wed, 15 Apr 2026 07:51:00 +0200 +Subject: Check if client is a valid endpoint before updating CA-certificate + +(cherry picked from commit 6c2e0db3819f859910a4ae265461cb51b1d2039c) +--- + lib/remote/jsonrpcconnection-pki.cpp | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +--- a/lib/remote/jsonrpcconnection-pki.cpp ++++ b/lib/remote/jsonrpcconnection-pki.cpp +@@ -343,7 +343,7 @@ void JsonRpcConnection::SendCertificateR + + Value UpdateCertificateHandler(const MessageOrigin::Ptr& origin, const Dictionary::Ptr& params) + { +- if (origin->FromZone && !Zone::GetLocalZone()->IsChildOf(origin->FromZone)) { ++ if (!origin->FromClient->GetEndpoint() || (origin->FromZone && !Zone::GetLocalZone()->IsChildOf(origin->FromZone))) { + Log(LogWarning, "ClusterEvents") + << "Discarding 'update certificate' message from '" << origin->FromClient->GetIdentity() << "': Invalid endpoint origin (client not allowed)."; + diff -Nru icinga2-2.14.6/debian/patches/0001-ConfigWriter-EmitScope-Escape-import.patch icinga2-2.14.6/debian/patches/0001-ConfigWriter-EmitScope-Escape-import.patch --- icinga2-2.14.6/debian/patches/0001-ConfigWriter-EmitScope-Escape-import.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-ConfigWriter-EmitScope-Escape-import.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,53 @@ +From eec0d90e8303376fe772b3e4a04e3b064a44cf30 Mon Sep 17 00:00:00 2001 +From: Alvar Penning +Date: Fri, 10 Apr 2026 16:35:37 +0200 +Subject: ConfigWriter::EmitScope: Escape import + +Escape all user-supplied template imports when creating an Icinga 2 DSL +configuration object. Without the escape, a `"` within the template name +would allow escaping the created object and create other Icinga 2 DSL +objects, exceeding potential user privileges. + +The same bug was present in ConfigWriter::EmitComment, but as this +method is dead code, it could just be removed. + +(cherry picked from commit faf0450962ad678397991cfdf041810feafa71e7) +--- + lib/base/configwriter.cpp | 8 ++------ + lib/base/configwriter.hpp | 1 - + 2 files changed, 2 insertions(+), 7 deletions(-) + +--- a/lib/base/configwriter.cpp ++++ b/lib/base/configwriter.cpp +@@ -63,7 +63,8 @@ void ConfigWriter::EmitScope(std::ostrea + for (const Value& import : imports) { + fp << "\n"; + EmitIndent(fp, indentLevel); +- fp << "import \"" << import << "\""; ++ fp << "import "; ++ EmitString(fp, import.Get()); + } + + fp << "\n"; +@@ -174,11 +175,6 @@ void ConfigWriter::EmitConfigItem(std::o + EmitScope(fp, 1, attrs, imports, true); + } + +-void ConfigWriter::EmitComment(std::ostream& fp, const String& text) +-{ +- fp << "/* " << text << " */\n"; +-} +- + void ConfigWriter::EmitFunctionCall(std::ostream& fp, const String& name, const Array::Ptr& arguments) + { + EmitIdentifier(fp, name, false); +--- a/lib/base/configwriter.hpp ++++ b/lib/base/configwriter.hpp +@@ -53,7 +53,6 @@ public: + static void EmitConfigItem(std::ostream& fp, const String& type, const String& name, bool isTemplate, + bool ignoreOnError, const Array::Ptr& imports, const Dictionary::Ptr& attrs); + +- static void EmitComment(std::ostream& fp, const String& text); + static void EmitFunctionCall(std::ostream& fp, const String& name, const Array::Ptr& arguments); + + static const std::vector& GetKeywords(); diff -Nru icinga2-2.14.6/debian/patches/0001-Declare-functions-as-unsafe-that-aren-t-useful-in-fi.patch icinga2-2.14.6/debian/patches/0001-Declare-functions-as-unsafe-that-aren-t-useful-in-fi.patch --- icinga2-2.14.6/debian/patches/0001-Declare-functions-as-unsafe-that-aren-t-useful-in-fi.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Declare-functions-as-unsafe-that-aren-t-useful-in-fi.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,43 @@ +From a391f6ba1ca7dd0578397b9bf5b02656960ad75f Mon Sep 17 00:00:00 2001 +From: Johannes Schmidt +Date: Wed, 3 Sep 2025 11:35:23 +0200 +Subject: Declare functions as unsafe that aren't useful in filter expressions + ++ get_objects(): Has no use because in sandboxed contexts the result + can't be filtered or iterated over. ++ get_template(): Currently this is not dangerous because the returned + dictionary object does not hold any interesting + information. However, someone could add more details + in the future and forget to add a permission check. ++ get_templates(): Combines the reasons for get_objects() and + get_template() ++ get_env(): There is no point of ever using this in a filter expression. +--- + lib/base/scriptutils.cpp | 8 ++++---- + 1 file changed, 4 insertions(+), 4 deletions(-) + +--- a/lib/base/scriptutils.cpp ++++ b/lib/base/scriptutils.cpp +@@ -36,10 +36,10 @@ REGISTER_FUNCTION(System, exit, &Applica + REGISTER_SAFE_FUNCTION(System, typeof, &ScriptUtils::TypeOf, "value"); + REGISTER_SAFE_FUNCTION(System, keys, &ScriptUtils::Keys, "value"); + REGISTER_SAFE_FUNCTION(System, random, &Utility::Random, ""); +-REGISTER_SAFE_FUNCTION(System, get_template, &ScriptUtils::GetTemplate, "type:name"); +-REGISTER_SAFE_FUNCTION(System, get_templates, &ScriptUtils::GetTemplates, "type"); ++REGISTER_FUNCTION(System, get_template, &ScriptUtils::GetTemplate, "type:name"); ++REGISTER_FUNCTION(System, get_templates, &ScriptUtils::GetTemplates, "type"); + REGISTER_SAFE_FUNCTION(System, get_object, &ScriptUtils::GetObject, "type:name"); +-REGISTER_SAFE_FUNCTION(System, get_objects, &ScriptUtils::GetObjects, "type"); ++REGISTER_FUNCTION(System, get_objects, &ScriptUtils::GetObjects, "type"); + REGISTER_FUNCTION(System, assert, &ScriptUtils::Assert, "value"); + REGISTER_SAFE_FUNCTION(System, string, &ScriptUtils::CastString, "value"); + REGISTER_SAFE_FUNCTION(System, number, &ScriptUtils::CastNumber, "value"); +@@ -47,7 +47,7 @@ REGISTER_SAFE_FUNCTION(System, bool, &Sc + REGISTER_SAFE_FUNCTION(System, get_time, &Utility::GetTime, ""); + REGISTER_SAFE_FUNCTION(System, basename, &Utility::BaseName, "path"); + REGISTER_SAFE_FUNCTION(System, dirname, &Utility::DirName, "path"); +-REGISTER_SAFE_FUNCTION(System, getenv, &ScriptUtils::GetEnv, "value"); ++REGISTER_FUNCTION(System, getenv, &ScriptUtils::GetEnv, "value"); + REGISTER_SAFE_FUNCTION(System, msi_get_component_path, &ScriptUtils::MsiGetComponentPathShim, "component"); + REGISTER_SAFE_FUNCTION(System, track_parents, &ScriptUtils::TrackParents, "child"); + REGISTER_SAFE_FUNCTION(System, escape_shell_cmd, &Utility::EscapeShellCmd, "cmd"); diff -Nru icinga2-2.14.6/debian/patches/0001-DerefExpression-Add-missing-nullptr-check.patch icinga2-2.14.6/debian/patches/0001-DerefExpression-Add-missing-nullptr-check.patch --- icinga2-2.14.6/debian/patches/0001-DerefExpression-Add-missing-nullptr-check.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-DerefExpression-Add-missing-nullptr-check.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,42 @@ +From 0d737e263a2244be07da85e5c5d6d914888255d4 Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Wed, 8 Oct 2025 10:04:52 +0200 +Subject: DerefExpression: Add missing nullptr check + +Due to this missing check, evaluating a DSL expression can result in a null +dereference, crashing the Icinga 2 process. Given that API users can also +provide DSL expression as filters, this can be triggered over the network as +well. + +This issue was assigned CVE-2025-61908. +--- + lib/config/expression.cpp | 4 ++++ + test/config-ops.cpp | 4 ++++ + 2 files changed, 8 insertions(+) + +--- a/lib/config/expression.cpp ++++ b/lib/config/expression.cpp +@@ -187,6 +187,10 @@ bool DerefExpression::GetReference(Scrip + + Reference::Ptr ref = operand.GetValue(); + ++ if (!ref) { ++ BOOST_THROW_EXCEPTION(ScriptError("Invalid reference specified.", GetDebugInfo())); ++ } ++ + *parent = ref->GetParent(); + *index = ref->GetIndex(); + return true; +--- a/test/config-ops.cpp ++++ b/test/config-ops.cpp +@@ -242,6 +242,10 @@ BOOST_AUTO_TEST_CASE(advanced) + expr = ConfigCompiler::CompileText("", "{{ 3 }}"); + func = expr->Evaluate(frame).GetValue(); + BOOST_CHECK(func->Invoke() == 3); ++ ++ // Regression test for CVE-2025-61908 ++ expr = ConfigCompiler::CompileText("", "&*null"); ++ BOOST_CHECK_THROW(expr->Evaluate(frame).GetValue(), ScriptError); + } + + BOOST_AUTO_TEST_CASE(sandboxed_ticket_salt) diff -Nru icinga2-2.14.6/debian/patches/0001-Don-t-shut-down-JSON-RPC-connection-if-a-message-fai.patch icinga2-2.14.6/debian/patches/0001-Don-t-shut-down-JSON-RPC-connection-if-a-message-fai.patch --- icinga2-2.14.6/debian/patches/0001-Don-t-shut-down-JSON-RPC-connection-if-a-message-fai.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Don-t-shut-down-JSON-RPC-connection-if-a-message-fai.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,46 @@ +From 5bf588369d90d72184da526528d8783ea6bf8e08 Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Wed, 27 May 2026 15:35:24 +0200 +Subject: Don't shut down JSON-RPC connection if a message fails to parse + +With the limit from the previous commit, if a JSON-RPC now message fails to +parse due to being nested to deep, it would have torn down the whole +connection. It is still possible to trigger that scenario from DSL config (for +example by returning nested structures from a lambda that is used in a check +with command_endpoint). In order to fail more gracefully, only discard the +single message and don't kill the whole connection. + +(cherry picked from commit e7d656cf37683f36fb36b30806e55fc2dc53caf4) +--- + lib/remote/jsonrpcconnection.cpp | 20 +++++++++++++++++++- + 1 file changed, 19 insertions(+), 1 deletion(-) + +--- a/lib/remote/jsonrpcconnection.cpp ++++ b/lib/remote/jsonrpcconnection.cpp +@@ -96,7 +96,25 @@ void JsonRpcConnection::HandleIncomingMe + // Cache the elapsed time to acquire a CPU semaphore used to detect extremely heavy workloads. + cpuBoundDuration = ch::steady_clock::now() - start; + +- Dictionary::Ptr message = JsonRpc::DecodeMessage(jsonString); ++ Dictionary::Ptr message; ++ try { ++ message = JsonRpc::DecodeMessage(jsonString); ++ } catch (const std::exception& ex) { ++ if (m_Authenticated) { ++ Log (LogWarning, "JsonRpcConnection") ++ << "Ignoring JSON-RPC message for identity '" << m_Identity ++ << "' that could not be parsed: " << DiagnosticInformation(ex); ++ ++ // If only the JSON message is broken but the netstring format is intact, we can continue with the ++ // next message as we know the message boundaries. This is done as a defensive approach so that if ++ // we missed a case that triggers the JSON decoding depth limit, this doesn't break the connection ++ // and worst case takes down the whole cluster communication with it. ++ continue; ++ } ++ ++ // Unauthenticated clients proceed to the outer error handling that terminated the connection. ++ throw; ++ } + if (String method = message->Get("method"); !method.IsEmpty()) { + rpcMethod = std::move(method); + } diff -Nru icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-io_context-strand-method-remove.patch icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-io_context-strand-method-remove.patch --- icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-io_context-strand-method-remove.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-io_context-strand-method-remove.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,32 @@ +From 3894f22180206ee68c4233e7c5b7998231e4f497 Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Alexander=20Aleksandrovi=C4=8D=20Klimov?= + +Date: Tue, 7 Jan 2025 17:53:42 +0100 +Subject: Don't use boost::asio::io_context::strand method removed in Boost + 1.87 + +(cherry picked from commit 011c67964ee2ab4c9a21f8aab322663e3bf5c317) +--- + lib/remote/jsonrpcconnection.cpp | 4 ++-- + 1 file changed, 2 insertions(+), 2 deletions(-) + +--- a/lib/remote/jsonrpcconnection.cpp ++++ b/lib/remote/jsonrpcconnection.cpp +@@ -212,7 +212,7 @@ void JsonRpcConnection::SendMessage(cons + + Ptr keepAlive (this); + +- m_IoStrand.post([this, keepAlive, message]() { SendMessageInternal(message); }); ++ boost::asio::post(m_IoStrand, [this, keepAlive, message] { SendMessageInternal(message); }); + } + + void JsonRpcConnection::SendRawMessage(const String& message) +@@ -223,7 +223,7 @@ void JsonRpcConnection::SendRawMessage(c + + Ptr keepAlive (this); + +- m_IoStrand.post([this, keepAlive, message]() { ++ boost::asio::post(m_IoStrand, [this, keepAlive, message] { + if (m_ShuttingDown) { + return; + } diff -Nru icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-ip-tcp-resolver-query.patch icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-ip-tcp-resolver-query.patch --- icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-ip-tcp-resolver-query.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Don-t-use-boost-asio-ip-tcp-resolver-query.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,49 @@ +From 9ffa25cc31b3dfab3aee7c6269c6f5f686eb853d Mon Sep 17 00:00:00 2001 +From: =?UTF-8?q?Alexander=20Aleksandrovi=C4=8D=20Klimov?= + +Date: Tue, 7 Jan 2025 15:53:20 +0100 +Subject: Don't use boost::asio::ip::tcp::resolver::query + +It was removed in Boost 1.87. + +(cherry picked from commit 7bd35d8c6b7658170835de0fa623e3a4099569fb) +--- + lib/base/tcpsocket.hpp | 6 ++---- + lib/remote/apilistener.cpp | 4 +--- + 2 files changed, 3 insertions(+), 7 deletions(-) + +--- a/lib/base/tcpsocket.hpp ++++ b/lib/base/tcpsocket.hpp +@@ -41,8 +41,7 @@ void Connect(Socket& socket, const Strin + using boost::asio::ip::tcp; + + tcp::resolver resolver (IoEngine::Get().GetIoContext()); +- tcp::resolver::query query (node, service); +- auto result (resolver.resolve(query)); ++ auto result (resolver.resolve(node.CStr(), service.CStr())); + auto current (result.begin()); + + for (;;) { +@@ -72,8 +71,7 @@ void Connect(Socket& socket, const Strin + using boost::asio::ip::tcp; + + tcp::resolver resolver (IoEngine::Get().GetIoContext()); +- tcp::resolver::query query (node, service); +- auto result (resolver.async_resolve(query, yc)); ++ auto result (resolver.async_resolve(node.CStr(), service.CStr(), yc)); + auto current (result.begin()); + + for (;;) { +--- a/lib/remote/apilistener.cpp ++++ b/lib/remote/apilistener.cpp +@@ -439,9 +439,7 @@ bool ApiListener::AddListener(const Stri + + try { + tcp::resolver resolver (io); +- tcp::resolver::query query (node, service, tcp::resolver::query::passive); +- +- auto result (resolver.resolve(query)); ++ auto result (resolver.resolve(node.CStr(), service.CStr(), tcp::resolver::passive)); + auto current (result.begin()); + + for (;;) { diff -Nru icinga2-2.14.6/debian/patches/0001-Don-t-use-removed-boost-asio-spawn-overload-if-Boost.patch icinga2-2.14.6/debian/patches/0001-Don-t-use-removed-boost-asio-spawn-overload-if-Boost.patch --- icinga2-2.14.6/debian/patches/0001-Don-t-use-removed-boost-asio-spawn-overload-if-Boost.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Don-t-use-removed-boost-asio-spawn-overload-if-Boost.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,100 @@ +From 28b9c57d1e5464c05815fc624e96918082a237f9 Mon Sep 17 00:00:00 2001 +From: "Alexander A. Klimov" +Date: Fri, 7 Mar 2025 15:47:37 +0100 +Subject: Don't use removed boost::asio::spawn() overload if Boost >= v1.87 + +(cherry picked from commit fb2b2e2d5b32a02309aa1c50ae5e9fb873922988) +--- + lib/base/io-engine.hpp | 13 +++++++++++++ + test/base-io-engine.cpp | 10 +++++----- + 2 files changed, 18 insertions(+), 5 deletions(-) + +--- a/lib/base/io-engine.hpp ++++ b/lib/base/io-engine.hpp +@@ -16,11 +16,16 @@ + #include + #include + #include ++#include + #include + #include + #include + #include + ++#if BOOST_VERSION >= 108700 ++# include ++#endif // BOOST_VERSION >= 108700 ++ + namespace icinga + { + +@@ -102,6 +107,10 @@ public: + static void SpawnCoroutine(Handler& h, Function f) { + + boost::asio::spawn(h, ++#if BOOST_VERSION >= 108700 ++ std::allocator_arg, ++ boost::context::fixedsize_stack(GetCoroutineStackSize()), ++#endif // BOOST_VERSION >= 108700 + [f](boost::asio::yield_context yc) { + + try { +@@ -119,7 +128,11 @@ public: + throw; + } + }, ++#if BOOST_VERSION >= 108700 ++ boost::asio::detached ++#else // BOOST_VERSION >= 108700 + boost::coroutines::attributes(GetCoroutineStackSize()) // Set a pre-defined stack size. ++#endif // BOOST_VERSION >= 108700 + ); + } + +--- a/test/base-io-engine.cpp ++++ b/test/base-io-engine.cpp +@@ -17,7 +17,7 @@ BOOST_AUTO_TEST_CASE(timeout_run) + boost::asio::io_context::strand strand (io); + int called = 0; + +- boost::asio::spawn(strand, [&](boost::asio::yield_context yc) { ++ IoEngine::SpawnCoroutine(strand, [&](boost::asio::yield_context yc) { + boost::asio::deadline_timer timer (io); + + Timeout timeout (strand, boost::posix_time::millisec(300), [&called] { ++called; }); +@@ -44,7 +44,7 @@ BOOST_AUTO_TEST_CASE(timeout_cancelled) + boost::asio::io_context::strand strand (io); + int called = 0; + +- boost::asio::spawn(strand, [&](boost::asio::yield_context yc) { ++ IoEngine::SpawnCoroutine(strand, [&](boost::asio::yield_context yc) { + boost::asio::deadline_timer timer (io); + Timeout timeout (strand, boost::posix_time::millisec(300), [&called] { ++called; }); + +@@ -71,7 +71,7 @@ BOOST_AUTO_TEST_CASE(timeout_scope) + boost::asio::io_context::strand strand (io); + int called = 0; + +- boost::asio::spawn(strand, [&](boost::asio::yield_context yc) { ++ IoEngine::SpawnCoroutine(strand, [&](boost::asio::yield_context yc) { + boost::asio::deadline_timer timer (io); + + { +@@ -100,7 +100,7 @@ BOOST_AUTO_TEST_CASE(timeout_due_cancell + boost::asio::io_context::strand strand (io); + int called = 0; + +- boost::asio::spawn(strand, [&](boost::asio::yield_context yc) { ++ IoEngine::SpawnCoroutine(strand, [&](boost::asio::yield_context yc) { + boost::asio::deadline_timer timer (io); + Timeout timeout (strand, boost::posix_time::millisec(300), [&called] { ++called; }); + +@@ -131,7 +131,7 @@ BOOST_AUTO_TEST_CASE(timeout_due_scope) + boost::asio::io_context::strand strand (io); + int called = 0; + +- boost::asio::spawn(strand, [&](boost::asio::yield_context yc) { ++ IoEngine::SpawnCoroutine(strand, [&](boost::asio::yield_context yc) { + boost::asio::deadline_timer timer (io); + + { diff -Nru icinga2-2.14.6/debian/patches/0001-Filter-global-variables-when-Sandboxed.patch icinga2-2.14.6/debian/patches/0001-Filter-global-variables-when-Sandboxed.patch --- icinga2-2.14.6/debian/patches/0001-Filter-global-variables-when-Sandboxed.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Filter-global-variables-when-Sandboxed.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,173 @@ +From 389448267d10142b568139864f2f9d56e775a70b Mon Sep 17 00:00:00 2001 +From: Johannes Schmidt +Date: Mon, 22 Sep 2025 12:31:02 +0200 +Subject: Filter global variables when Sandboxed + +--- + lib/base/scriptframe.cpp | 50 +++++++++++++++++++++++++++++++++++++-- + lib/base/scriptframe.hpp | 24 +++++++++++++++++++ + lib/config/expression.cpp | 12 ++++++---- + 3 files changed, 79 insertions(+), 7 deletions(-) + +--- a/lib/base/scriptframe.cpp ++++ b/lib/base/scriptframe.cpp +@@ -44,14 +44,24 @@ INITIALIZE_ONCE_WITH_PRIORITY([]() { + l_StatsNS->Freeze(); + }, InitializePriority::FreezeNamespaces); + ++/** ++ * Construct a @c ScriptFrame that has `Self` assigned to the global namespace. ++ * ++ * Prefer the other constructor if possible since if misused this may leak global variables ++ * without permissions or senstive variables like TicketSalt in a sandboxed context. ++ * ++ * @todo Remove this constructor and call the other with the global namespace in places where it's actually necessary. ++ */ + ScriptFrame::ScriptFrame(bool allocLocals) +- : Locals(allocLocals ? new Dictionary() : nullptr), PermChecker(new ScriptPermissionChecker), Self(ScriptGlobal::GetGlobals()), Sandboxed(false), Depth(0) ++ : Locals(allocLocals ? new Dictionary() : nullptr), PermChecker(new ScriptPermissionChecker), ++ Self(ScriptGlobal::GetGlobals()), Sandboxed(false), Depth(0), Globals(nullptr) + { + InitializeFrame(); + } + + ScriptFrame::ScriptFrame(bool allocLocals, Value self) +- : Locals(allocLocals ? new Dictionary() : nullptr), PermChecker(new ScriptPermissionChecker), Self(std::move(self)), Sandboxed(false), Depth(0) ++ : Locals(allocLocals ? new Dictionary() : nullptr), PermChecker(new ScriptPermissionChecker), Self(std::move(self)), ++ Sandboxed(false), Depth(0), Globals(nullptr) + { + InitializeFrame(); + } +@@ -63,6 +73,7 @@ void ScriptFrame::InitializeFrame() + if (frames && !frames->empty()) { + ScriptFrame *frame = frames->top(); + ++ // See the documentation of `ScriptFrame::Globals` for why these two are inherited and Globals isn't. + PermChecker = frame->PermChecker; + Sandboxed = frame->Sandboxed; + } +@@ -80,6 +91,41 @@ ScriptFrame::~ScriptFrame() + #endif /* I2_DEBUG */ + } + ++/** ++ * Returns a sanitized copy of the global variables namespace when sandboxed. ++ * ++ * This filters out the TicketSalt variable specifically and any variable for which the ++ * PermChecker does not return 'true'. ++ * ++ * However it specifically keeps the Types, System, and Icinga sub-namespaces, because they're ++ * accessed through globals in ScopeExpression and the user should have access to all Values ++ * contained in these namespaces. ++ * ++ * @return a sanitized copy of the global namespace if sandboxed, a pointer to the global namespace otherwise. ++ */ ++Namespace::Ptr ScriptFrame::GetGlobals() ++{ ++ if (Sandboxed) { ++ if (!Globals) { ++ Globals = new Namespace; ++ auto globals = ScriptGlobal::GetGlobals(); ++ ObjectLock lock{globals}; ++ for (auto& [key, val] : globals) { ++ if (key == "TicketSalt") { ++ continue; ++ } ++ ++ if (key == "Types" || key == "System" || key == "Icinga" || PermChecker->CanAccessGlobalVariable(key)) { ++ Globals->Set(key, val.Val, val.Const); ++ } ++ } ++ } ++ return Globals; ++ } ++ ++ return ScriptGlobal::GetGlobals(); ++} ++ + void ScriptFrame::IncreaseStackDepth() + { + if (Depth + 1 > 300) +--- a/lib/base/scriptframe.hpp ++++ b/lib/base/scriptframe.hpp +@@ -5,6 +5,7 @@ + + #include "base/i2-base.hpp" + #include "base/dictionary.hpp" ++#include "base/namespace.hpp" + #include "base/scriptpermission.hpp" + #include + #include +@@ -12,6 +13,16 @@ + namespace icinga + { + ++/** ++ * A frame describing the context a section of script code is executed in. ++ * ++ * This is implemented by each new object that is constructed getting pushed on a thread_local ++ * global stack that is accessible from anywhere during script evaluation. ++ * ++ * Most properties in this frame, like local variables do not carry over to successive frames, ++ * except the `PermChecker` and `Sandboxed` members, which get propagated to enforce access ++ * control and availability of unsafe functions. ++ */ + struct ScriptFrame + { + Dictionary::Ptr Locals; +@@ -29,7 +40,20 @@ struct ScriptFrame + + static ScriptFrame *GetCurrentFrame(); + ++ Namespace::Ptr GetGlobals(); ++ + private: ++ /** ++ * Caches a sanitized version of the global namespace for the current `ScriptFrame`. ++ * ++ * This is a value that is dependent on a ScriptFrame's `Sandboxed` and `CheckPerms` ++ * members. These are both independent of each other and while they are inherited by ++ * subsequent frames themselves, their values can be changed for new frames easily. ++ * Therefore Globals can hold a different value for each ScriptFrame and is not ++ * inherited. ++ */ ++ Namespace::Ptr Globals; ++ + static boost::thread_specific_ptr > m_ScriptFrames; + + static void PushFrame(ScriptFrame *frame); +--- a/lib/config/expression.cpp ++++ b/lib/config/expression.cpp +@@ -118,8 +118,10 @@ ExpressionResult VariableExpression::DoE + return value; + else if (VMOps::FindVarImport(frame, m_Imports, m_Variable, &value, m_DebugInfo)) + return value; +- else +- return ScriptGlobal::Get(m_Variable); ++ else if (frame.GetGlobals()->Get(m_Variable, &value)) ++ return value; ++ ++ BOOST_THROW_EXCEPTION(ScriptError{"Tried to access undefined script variable '" + m_Variable + "'"}); + } + + bool VariableExpression::GetReference(ScriptFrame& frame, bool init_dict, Value *parent, String *index, DebugHint **dhint) const +@@ -138,8 +140,8 @@ bool VariableExpression::GetReference(Sc + *dhint = new DebugHint((*dhint)->GetChild(m_Variable)); + } else if (VMOps::FindVarImportRef(frame, m_Imports, m_Variable, parent, m_DebugInfo)) { + return true; +- } else if (ScriptGlobal::Exists(m_Variable)) { +- *parent = ScriptGlobal::GetGlobals(); ++ } else if (frame.GetGlobals()->Contains(m_Variable)) { ++ *parent = frame.GetGlobals(); + + if (dhint) + *dhint = nullptr; +@@ -546,7 +548,7 @@ ExpressionResult GetScopeExpression::DoE + else if (m_ScopeSpec == ScopeThis) + return frame.Self; + else if (m_ScopeSpec == ScopeGlobal) +- return ScriptGlobal::GetGlobals(); ++ return frame.GetGlobals(); + else + VERIFY(!"Invalid scope."); + } diff -Nru icinga2-2.14.6/debian/patches/0001-In-a-coroutine-re-throw-everything-ex.-std-exception.patch icinga2-2.14.6/debian/patches/0001-In-a-coroutine-re-throw-everything-ex.-std-exception.patch --- icinga2-2.14.6/debian/patches/0001-In-a-coroutine-re-throw-everything-ex.-std-exception.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-In-a-coroutine-re-throw-everything-ex.-std-exception.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,185 @@ +From b187c8e9036fe7e43979ded25981d510bf0ab97f Mon Sep 17 00:00:00 2001 +From: "Alexander A. Klimov" +Date: Fri, 7 Mar 2025 16:22:50 +0100 +Subject: In a coroutine, re-throw everything ex. std::exception (and + inheritors) + +not just boost::coroutines::detail::forced_unwind. + +This is needed because as of Boost 1.87, boost::asio::spawn() uses Fiber, not Coroutine v1. +https://github.com/boostorg/asio/commit/df973a85ed69f021 + +This is safe because every actual exception shall inherit from std::exception. Except forced_unwind and its Fiber equivalent, so that `catch(const std::exception&)` doesn't catch them and only them. + +(cherry picked from commit 0662f2b7193693ba1129690ac004af7f866470bb) +--- + lib/base/io-engine.hpp | 13 ++++++---- + lib/icingadb/redisconnection.cpp | 41 ++++---------------------------- + lib/icingadb/redisconnection.hpp | 8 ++----- + 3 files changed, 14 insertions(+), 48 deletions(-) + +--- a/lib/base/io-engine.hpp ++++ b/lib/base/io-engine.hpp +@@ -106,14 +106,17 @@ public: + + try { + f(yc); +- } catch (const boost::coroutines::detail::forced_unwind &) { +- // Required for proper stack unwinding when coroutines are destroyed. +- // https://github.com/boostorg/coroutine/issues/39 +- throw; + } catch (const std::exception& ex) { + Log(LogCritical, "IoEngine") << "Exception in coroutine: " << DiagnosticInformation(ex); + } catch (...) { +- Log(LogCritical, "IoEngine", "Exception in coroutine!"); ++ try { ++ Log(LogCritical, "IoEngine", "Exception in coroutine!"); ++ } catch (...) { ++ } ++ ++ // Required for proper stack unwinding when coroutines are destroyed. ++ // https://github.com/boostorg/coroutine/issues/39 ++ throw; + } + }, + boost::coroutines::attributes(GetCoroutineStackSize()) // Set a pre-defined stack size. +--- a/lib/icingadb/redisconnection.cpp ++++ b/lib/icingadb/redisconnection.cpp +@@ -377,8 +377,6 @@ void RedisConnection::Connect(asio::yiel + } + + break; +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; + } catch (const std::exception& ex) { + Log(LogCritical, "IcingaDB") + << "Cannot connect to " << m_Host << ":" << m_Port << ": " << ex.what(); +@@ -408,18 +406,11 @@ void RedisConnection::ReadLoop(asio::yie + for (auto i (item.Amount); i; --i) { + ReadOne(yc); + } +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; + } catch (const std::exception& ex) { + Log(LogCritical, "IcingaDB") + << "Error during receiving the response to a query which has been fired and forgotten: " << ex.what(); + + continue; +- } catch (...) { +- Log(LogCritical, "IcingaDB") +- << "Error during receiving the response to a query which has been fired and forgotten"; +- +- continue; + } + + break; +@@ -432,9 +423,7 @@ void RedisConnection::ReadLoop(asio::yie + + try { + reply = ReadOne(yc); +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; +- } catch (...) { ++ } catch (const std::exception&) { + promise.set_exception(std::current_exception()); + + continue; +@@ -455,9 +444,7 @@ void RedisConnection::ReadLoop(asio::yie + for (auto i (item.Amount); i; --i) { + try { + replies.emplace_back(ReadOne(yc)); +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; +- } catch (...) { ++ } catch (const std::exception&) { + promise.set_exception(std::current_exception()); + break; + } +@@ -551,20 +538,12 @@ void RedisConnection::WriteItem(boost::a + + try { + WriteOne(item, yc); +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; + } catch (const std::exception& ex) { + Log msg (LogCritical, "IcingaDB", "Error during sending query"); + LogQuery(item, msg); + msg << " which has been fired and forgotten: " << ex.what(); + + return; +- } catch (...) { +- Log msg (LogCritical, "IcingaDB", "Error during sending query"); +- LogQuery(item, msg); +- msg << " which has been fired and forgotten"; +- +- return; + } + + if (m_Queues.FutureResponseActions.empty() || m_Queues.FutureResponseActions.back().Action != ResponseAction::Ignore) { +@@ -587,20 +566,12 @@ void RedisConnection::WriteItem(boost::a + WriteOne(query, yc); + ++i; + } +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; + } catch (const std::exception& ex) { + Log msg (LogCritical, "IcingaDB", "Error during sending query"); + LogQuery(item[i], msg); + msg << " which has been fired and forgotten: " << ex.what(); + + return; +- } catch (...) { +- Log msg (LogCritical, "IcingaDB", "Error during sending query"); +- LogQuery(item[i], msg); +- msg << " which has been fired and forgotten"; +- +- return; + } + + if (m_Queues.FutureResponseActions.empty() || m_Queues.FutureResponseActions.back().Action != ResponseAction::Ignore) { +@@ -618,9 +589,7 @@ void RedisConnection::WriteItem(boost::a + + try { + WriteOne(item.first, yc); +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; +- } catch (...) { ++ } catch (const std::exception&) { + item.second.set_exception(std::current_exception()); + + return; +@@ -645,9 +614,7 @@ void RedisConnection::WriteItem(boost::a + for (auto& query : item.first) { + WriteOne(query, yc); + } +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; +- } catch (...) { ++ } catch (const std::exception&) { + item.second.set_exception(std::current_exception()); + + return; +--- a/lib/icingadb/redisconnection.hpp ++++ b/lib/icingadb/redisconnection.hpp +@@ -388,9 +388,7 @@ RedisConnection::Reply RedisConnection:: + + try { + return ReadRESP(*strm, yc); +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; +- } catch (...) { ++ } catch (const std::exception&) { + if (m_Connecting.exchange(false)) { + m_Connected.store(false); + stream = nullptr; +@@ -426,9 +424,7 @@ void RedisConnection::WriteOne(StreamPtr + try { + WriteRESP(*strm, query, yc); + strm->async_flush(yc); +- } catch (const boost::coroutines::detail::forced_unwind&) { +- throw; +- } catch (...) { ++ } catch (const std::exception&) { + if (m_Connecting.exchange(false)) { + m_Connected.store(false); + stream = nullptr; diff -Nru icinga2-2.14.6/debian/patches/0001-JsonDecode-add-depth-limit.patch icinga2-2.14.6/debian/patches/0001-JsonDecode-add-depth-limit.patch --- icinga2-2.14.6/debian/patches/0001-JsonDecode-add-depth-limit.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-JsonDecode-add-depth-limit.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,247 @@ +From 391504eaa9ed9aee81f84008c57ec821974b74ee Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Wed, 27 May 2026 15:35:21 +0200 +Subject: JsonDecode: add depth limit + +Data structures parsed from JSON may be accessed recursively, so deeply nested +structures may wreak havoc by overflowing the stack. Thus, enforce a general +nesting depth limit of 24 by default (which should be more than enough for +reasonable use), with the ability to pass a different limit to JsonDecode() if +needed. + +(cherry picked from commit 4964d2444ca5c4fae9036054554e1c6e0025fb89) +--- + lib/base/configobject.cpp | 2 +- + lib/base/json.cpp | 27 +++++++++- + lib/base/json.hpp | 4 +- + lib/cli/objectlistutility.cpp | 2 +- + lib/remote/jsonrpc.cpp | 4 +- + test/CMakeLists.txt | 1 + + test/base-json.cpp | 92 +++++++++++++++++++++++++++++++++++ + 7 files changed, 126 insertions(+), 6 deletions(-) + +--- a/lib/base/configobject.cpp ++++ b/lib/base/configobject.cpp +@@ -502,7 +502,7 @@ void ConfigObject::DumpObjects(const Str + + void ConfigObject::RestoreObject(const String& message, int attributeTypes) + { +- Dictionary::Ptr persistentObject = JsonDecode(message); ++ Dictionary::Ptr persistentObject = JsonDecode(message, std::numeric_limits::max()); + + String type = persistentObject->Get("type"); + String name = persistentObject->Get("name"); +--- a/lib/base/json.cpp ++++ b/lib/base/json.cpp +@@ -21,6 +21,8 @@ using namespace icinga; + class JsonSax : public nlohmann::json_sax + { + public: ++ explicit JsonSax(std::size_t depthLimit) : m_DepthLimit(depthLimit) {} ++ + bool null() override; + bool boolean(bool val) override; + bool number_integer(number_integer_t val) override; +@@ -41,6 +43,7 @@ private: + Value m_Root; + std::stack> m_CurrentSubtree; + String m_CurrentKey; ++ std::size_t m_DepthLimit; + + void FillCurrentTarget(Value value); + }; +@@ -206,11 +209,23 @@ String icinga::JsonEncode(const Value& v + } + } + +-Value icinga::JsonDecode(const String& data) ++/** ++ * Parses a JSON string into the Icinga Value type. ++ * ++ * A depth limit can be provided. Both JSON arrays (mapped to icinga::Array) and JSON objects ++ * (mapped to icinga::Dictionary) count towards that limit. A limit of 1 means, that the outer object can be an array ++ * or object, but it can only contain scalar values. ++ * ++ * @param data The JSON to be parsed (throws if the JSON is invalid). ++ * @param depthLimit The maximum depth of the returned data structure, ++ * defaults to 24 (throws if the JSON is nested too deep). ++ * @return The parsed value. ++ */ ++Value icinga::JsonDecode(const String& data, size_t depthLimit) + { + String sanitized (Utility::ValidateUTF8(data)); + +- JsonSax stateMachine; ++ JsonSax stateMachine{depthLimit}; + + nlohmann::json::sax_parse(sanitized.Begin(), sanitized.End(), &stateMachine); + +@@ -280,6 +295,10 @@ bool JsonSax::start_object(std::size_t) + + FillCurrentTarget(object); + ++ if (m_CurrentSubtree.size() >= m_DepthLimit) { ++ BOOST_THROW_EXCEPTION(std::runtime_error("JSON decoding recursion limit reached")); ++ } ++ + m_CurrentSubtree.push({object, nullptr}); + + return true; +@@ -309,6 +328,10 @@ bool JsonSax::start_array(std::size_t) + + FillCurrentTarget(array); + ++ if (m_CurrentSubtree.size() >= m_DepthLimit) { ++ BOOST_THROW_EXCEPTION(std::runtime_error("JSON decoding recursion limit reached")); ++ } ++ + m_CurrentSubtree.push({nullptr, array}); + + return true; +--- a/lib/base/json.hpp ++++ b/lib/base/json.hpp +@@ -11,8 +11,10 @@ namespace icinga + class String; + class Value; + ++static constexpr size_t JsonDecodeDefaultDepthLimit = 24; ++ + String JsonEncode(const Value& value, bool pretty_print = false); +-Value JsonDecode(const String& data); ++Value JsonDecode(const String& data, size_t depthLimit = JsonDecodeDefaultDepthLimit); + + } + +--- a/lib/cli/objectlistutility.cpp ++++ b/lib/cli/objectlistutility.cpp +@@ -13,7 +13,7 @@ using namespace icinga; + + bool ObjectListUtility::PrintObject(std::ostream& fp, bool& first, const String& message, std::map& type_count, const String& name_filter, const String& type_filter) + { +- Dictionary::Ptr object = JsonDecode(message); ++ Dictionary::Ptr object = JsonDecode(message, std::numeric_limits::max()); + + Dictionary::Ptr properties = object->Get("properties"); + +--- a/lib/remote/jsonrpc.cpp ++++ b/lib/remote/jsonrpc.cpp +@@ -146,7 +146,9 @@ String JsonRpc::ReadMessage(const Shared + */ + Dictionary::Ptr JsonRpc::DecodeMessage(const String& message) + { +- Value value = JsonDecode(message); ++ // Use something a bit higher than the default limit to accommodate for data that was accepted by Icinga 2 elsewhere ++ // and gained some additional nesting levels when being wrapped in a JSON-RPC message. ++ Value value = JsonDecode(message, JsonDecodeDefaultDepthLimit + 8); + + if (!value.IsObjectType()) { + BOOST_THROW_EXCEPTION(std::invalid_argument("JSON-RPC" +--- a/test/CMakeLists.txt ++++ b/test/CMakeLists.txt +@@ -86,6 +86,7 @@ add_boost_test(base + base_json/encode + base_json/decode + base_json/invalid1 ++ base_json/decode_depth_limit + base_object_packer/pack_null + base_object_packer/pack_false + base_object_packer/pack_true +--- a/test/base-json.cpp ++++ b/test/base-json.cpp +@@ -107,4 +107,96 @@ BOOST_AUTO_TEST_CASE(invalid1) + BOOST_CHECK_THROW(JsonDecode("{\"test\": \"test\""), std::exception); + } + ++static std::string MakeNestedJsonArray(size_t depth) ++{ ++ return std::string(depth, '[') + std::string(depth, ']'); ++} ++ ++static std::string MakeNestedJsonObject(size_t depth) ++{ ++ std::ostringstream buf; ++ for (size_t i = 0; i < depth; ++i) { ++ buf << "{\"" << i << "\":"; ++ } ++ buf << "null"; ++ for (size_t i = 0; i < depth; ++i) { ++ buf << "}"; ++ } ++ return buf.str(); ++} ++ ++BOOST_AUTO_TEST_CASE(decode_depth_limit) ++{ ++ auto isDepthLimit = [](const std::exception& ex) { ++ return std::string_view(ex.what()) == "JSON decoding recursion limit reached"; ++ }; ++ ++ // Scalars parse even with depth limit 0. ++ BOOST_CHECK_EQUAL(JsonDecode("42", 0), Value(42)); ++ BOOST_CHECK_EQUAL(JsonDecode("true", 0), Value(true)); ++ BOOST_CHECK_EQUAL(JsonDecode("\"test\"", 0), Value("test")); ++ ++ // Arrays and objects require at least depth limit 1. ++ BOOST_CHECK_EXCEPTION(JsonDecode("[]", 0), std::exception, isDepthLimit); ++ BOOST_CHECK_EXCEPTION(JsonDecode("{}", 0), std::exception, isDepthLimit); ++ BOOST_CHECK_EXCEPTION(JsonDecode("[42]", 0), std::exception, isDepthLimit); ++ BOOST_CHECK_EXCEPTION(JsonDecode("{\"foo\": 23}", 0), std::exception, isDepthLimit); ++ ++ // Array in array and object in object require at least depth limit 2. ++ BOOST_CHECK_EXCEPTION(JsonDecode("[[]]", 1), std::exception, isDepthLimit); ++ BOOST_CHECK_NO_THROW(JsonDecode("[[]]", 2)); ++ BOOST_CHECK_EXCEPTION(JsonDecode(R"({"a":{}})", 1), std::exception, isDepthLimit); ++ BOOST_CHECK_NO_THROW(JsonDecode(R"({"a":{}})", 2)); ++ ++ // Mixed nesting of arrays and objects is both counted towards the same limit. ++ BOOST_CHECK_EXCEPTION(JsonDecode("[{}]", 1), std::exception, isDepthLimit); ++ BOOST_CHECK_NO_THROW(JsonDecode("[{}]", 2)); ++ BOOST_CHECK_EXCEPTION(JsonDecode(R"({"a":[]})", 1), std::exception, isDepthLimit); ++ BOOST_CHECK_NO_THROW(JsonDecode(R"({"a":[]})", 2)); ++ ++ // Siblings are not added up for the depth check. ++ BOOST_CHECK_NO_THROW(JsonDecode("[[], [], [], {}, [], [], {}, {}, [], {}, {}, {}]", 2)); ++ BOOST_CHECK_NO_THROW(JsonDecode(R"({"a": [], "b": {}, "c": {}, "d": [], "e": [], "f": {}})", 2)); ++ ++ // Some deeper nested array. ++ std::string arrayWithNesting42 = MakeNestedJsonArray(42); ++ BOOST_CHECK_EXCEPTION(JsonDecode(arrayWithNesting42, 41), std::exception, isDepthLimit); ++ BOOST_CHECK_NO_THROW(JsonDecode(arrayWithNesting42, 42)); ++ ++ // Some deeper nested object. ++ std::string objectWithNesting42 = MakeNestedJsonObject(42); ++ BOOST_CHECK_EXCEPTION(JsonDecode(objectWithNesting42, 41), std::exception, isDepthLimit); ++ BOOST_CHECK_NO_THROW(JsonDecode(objectWithNesting42, 42)); ++ ++ // And some deeper nested mixed containers. ++ std::string deeperMixedNesting = R"({ ++ "1st-level": [ ++ true, ++ "2nd-level", ++ { ++ "dummy": 42, ++ "3rd-level": { ++ "4th-level": [ ++ "5th-level", ++ { ++ "6th-level": { ++ "7th-level": { ++ "8th-level": [ ++ "9th-level", ++ [ ++ "10th-level" ++ ] ++ ] ++ } ++ } ++ } ++ ] ++ } ++ } ++ ] ++ })"; ++ BOOST_CHECK_EXCEPTION(JsonDecode(deeperMixedNesting, 9), std::exception, isDepthLimit); ++ BOOST_CHECK_NO_THROW(JsonDecode(deeperMixedNesting, 10)); ++} ++ + BOOST_AUTO_TEST_SUITE_END() diff -Nru icinga2-2.14.6/debian/patches/0001-JsonDecode-include-path-in-JSON-depth-error.patch icinga2-2.14.6/debian/patches/0001-JsonDecode-include-path-in-JSON-depth-error.patch --- icinga2-2.14.6/debian/patches/0001-JsonDecode-include-path-in-JSON-depth-error.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-JsonDecode-include-path-in-JSON-depth-error.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,238 @@ +From 6382675448bd28ca798e5d55d9a2306ad55ae509 Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Fri, 29 May 2026 17:14:20 +0200 +Subject: JsonDecode: include path in JSON depth error + +If parsing JSON is rejected due to the depth limit introduced in the last +commit, also include the path (like root["object"]["children"]...) that exceeds +the allowed nesting depth. + +(cherry picked from commit 14d6ee9cdf9ebe1286a72ba516d957fd163259e9) +--- + lib/base/json.cpp | 68 +++++++++++++++++++++++++++++----------------- + test/base-json.cpp | 52 ++++++++++++++++++++++++++--------- + 2 files changed, 82 insertions(+), 38 deletions(-) + +--- a/lib/base/json.cpp ++++ b/lib/base/json.cpp +@@ -40,12 +40,22 @@ public: + Value GetResult(); + + private: ++ struct Node ++ { ++ Dictionary::Ptr m_Dictionary{}; ++ Array::Ptr m_Array{}; ++ String m_CurrentKey{}; ++ ++ explicit Node(Dictionary::Ptr dict) : m_Dictionary(std::move(dict)) {} ++ explicit Node(Array::Ptr array) : m_Array(std::move(array)) {} ++ }; ++ + Value m_Root; +- std::stack> m_CurrentSubtree; +- String m_CurrentKey; ++ std::vector m_Stack; + std::size_t m_DepthLimit; + + void FillCurrentTarget(Value value); ++ void CheckDepthLimit() const; + }; + + const char l_Null[] = "null"; +@@ -292,14 +302,9 @@ inline + bool JsonSax::start_object(std::size_t) + { + auto object (new Dictionary()); +- + FillCurrentTarget(object); +- +- if (m_CurrentSubtree.size() >= m_DepthLimit) { +- BOOST_THROW_EXCEPTION(std::runtime_error("JSON decoding recursion limit reached")); +- } +- +- m_CurrentSubtree.push({object, nullptr}); ++ CheckDepthLimit(); ++ m_Stack.push_back(Node{object}); + + return true; + } +@@ -307,7 +312,7 @@ bool JsonSax::start_object(std::size_t) + inline + bool JsonSax::key(JsonSax::string_t& val) + { +- m_CurrentKey = String(std::move(val)); ++ m_Stack.back().m_CurrentKey = String(std::move(val)); + + return true; + } +@@ -315,8 +320,7 @@ bool JsonSax::key(JsonSax::string_t& val + inline + bool JsonSax::end_object() + { +- m_CurrentSubtree.pop(); +- m_CurrentKey = String(); ++ m_Stack.pop_back(); + + return true; + } +@@ -325,14 +329,9 @@ inline + bool JsonSax::start_array(std::size_t) + { + auto array (new Array()); +- + FillCurrentTarget(array); +- +- if (m_CurrentSubtree.size() >= m_DepthLimit) { +- BOOST_THROW_EXCEPTION(std::runtime_error("JSON decoding recursion limit reached")); +- } +- +- m_CurrentSubtree.push({nullptr, array}); ++ CheckDepthLimit(); ++ m_Stack.push_back(Node{array}); + + return true; + } +@@ -340,7 +339,7 @@ bool JsonSax::start_array(std::size_t) + inline + bool JsonSax::end_array() + { +- m_CurrentSubtree.pop(); ++ m_Stack.pop_back(); + + return true; + } +@@ -360,16 +359,35 @@ Value JsonSax::GetResult() + inline + void JsonSax::FillCurrentTarget(Value value) + { +- if (m_CurrentSubtree.empty()) { ++ if (m_Stack.empty()) { + m_Root = value; + } else { +- auto& node (m_CurrentSubtree.top()); ++ auto& node (m_Stack.back()); + +- if (node.first) { +- node.first->Set(m_CurrentKey, value); ++ if (node.m_Dictionary) { ++ node.m_Dictionary->Set(node.m_CurrentKey, value); + } else { +- node.second->Add(value); ++ node.m_Array->Add(value); ++ } ++ } ++} ++ ++void JsonSax::CheckDepthLimit() const ++{ ++ if (m_Stack.size() >= m_DepthLimit) { ++ std::ostringstream buf; ++ buf << "JSON decoding recursion limit reached (path: root"; ++ for (const auto& node : m_Stack) { ++ buf << "["; ++ if (node.m_Dictionary) { ++ buf << JsonEncode(node.m_CurrentKey); ++ } else { ++ buf << node.m_Array->GetLength() - 1; ++ } ++ buf << "]"; + } ++ buf << ")"; ++ BOOST_THROW_EXCEPTION(std::runtime_error(buf.str())); + } + } + +--- a/test/base-json.cpp ++++ b/test/base-json.cpp +@@ -127,8 +127,23 @@ static std::string MakeNestedJsonObject( + + BOOST_AUTO_TEST_CASE(decode_depth_limit) + { +- auto isDepthLimit = [](const std::exception& ex) { +- return std::string_view(ex.what()) == "JSON decoding recursion limit reached"; ++ auto ExpectLimitExceeded = [](std::string_view input, size_t limit, std::string_view path) { ++ try { ++ JsonDecode(std::string(input), limit); ++ ++ boost::test_tools::assertion_result result{false}; ++ result.message() << "Decoding '" << input << "' with limit " << limit << " did not throw an exception"; ++ return result; ++ } catch (const std::exception& ex) { ++ std::ostringstream expected; ++ expected << "JSON decoding recursion limit reached (path: " << path << ")"; ++ ++ boost::test_tools::assertion_result result{ex.what() == expected.str()}; ++ result.message() << "Decoding '" << input << "' with limit " << limit << ":\n" ++ << " got exception: " << ex.what() << "\n" ++ << " expected exception: " << expected.str() << "\n"; ++ return result; ++ } + }; + + // Scalars parse even with depth limit 0. +@@ -137,21 +152,21 @@ BOOST_AUTO_TEST_CASE(decode_depth_limit) + BOOST_CHECK_EQUAL(JsonDecode("\"test\"", 0), Value("test")); + + // Arrays and objects require at least depth limit 1. +- BOOST_CHECK_EXCEPTION(JsonDecode("[]", 0), std::exception, isDepthLimit); +- BOOST_CHECK_EXCEPTION(JsonDecode("{}", 0), std::exception, isDepthLimit); +- BOOST_CHECK_EXCEPTION(JsonDecode("[42]", 0), std::exception, isDepthLimit); +- BOOST_CHECK_EXCEPTION(JsonDecode("{\"foo\": 23}", 0), std::exception, isDepthLimit); ++ BOOST_CHECK(ExpectLimitExceeded("[]", 0, "root")); ++ BOOST_CHECK(ExpectLimitExceeded("{}", 0, "root")); ++ BOOST_CHECK(ExpectLimitExceeded("[42]", 0, "root")); ++ BOOST_CHECK(ExpectLimitExceeded("{\"foo\": 23}", 0, "root")); + + // Array in array and object in object require at least depth limit 2. +- BOOST_CHECK_EXCEPTION(JsonDecode("[[]]", 1), std::exception, isDepthLimit); ++ BOOST_CHECK(ExpectLimitExceeded("[[]]", 1, "root[0]")); + BOOST_CHECK_NO_THROW(JsonDecode("[[]]", 2)); +- BOOST_CHECK_EXCEPTION(JsonDecode(R"({"a":{}})", 1), std::exception, isDepthLimit); ++ BOOST_CHECK(ExpectLimitExceeded(R"({"a":{}})", 1, R"(root["a"])")); + BOOST_CHECK_NO_THROW(JsonDecode(R"({"a":{}})", 2)); + + // Mixed nesting of arrays and objects is both counted towards the same limit. +- BOOST_CHECK_EXCEPTION(JsonDecode("[{}]", 1), std::exception, isDepthLimit); ++ BOOST_CHECK(ExpectLimitExceeded("[{}]", 1, "root[0]")); + BOOST_CHECK_NO_THROW(JsonDecode("[{}]", 2)); +- BOOST_CHECK_EXCEPTION(JsonDecode(R"({"a":[]})", 1), std::exception, isDepthLimit); ++ BOOST_CHECK(ExpectLimitExceeded(R"({"a":[]})", 1, R"(root["a"])")); + BOOST_CHECK_NO_THROW(JsonDecode(R"({"a":[]})", 2)); + + // Siblings are not added up for the depth check. +@@ -160,12 +175,22 @@ BOOST_AUTO_TEST_CASE(decode_depth_limit) + + // Some deeper nested array. + std::string arrayWithNesting42 = MakeNestedJsonArray(42); +- BOOST_CHECK_EXCEPTION(JsonDecode(arrayWithNesting42, 41), std::exception, isDepthLimit); ++ std::ostringstream arrayPathWithNesting41; ++ arrayPathWithNesting41 << "root"; ++ for (size_t i = 0; i < 41; ++i) { ++ arrayPathWithNesting41 << "[0]"; ++ } ++ BOOST_CHECK(ExpectLimitExceeded(arrayWithNesting42, 41, arrayPathWithNesting41.str())); + BOOST_CHECK_NO_THROW(JsonDecode(arrayWithNesting42, 42)); + + // Some deeper nested object. + std::string objectWithNesting42 = MakeNestedJsonObject(42); +- BOOST_CHECK_EXCEPTION(JsonDecode(objectWithNesting42, 41), std::exception, isDepthLimit); ++ std::ostringstream objectPathWithNesting41; ++ objectPathWithNesting41 << "root"; ++ for (size_t i = 0; i < 41; ++i) { ++ objectPathWithNesting41 << "[\"" << i << "\"]"; ++ } ++ BOOST_CHECK(ExpectLimitExceeded(objectWithNesting42, 41, objectPathWithNesting41.str())); + BOOST_CHECK_NO_THROW(JsonDecode(objectWithNesting42, 42)); + + // And some deeper nested mixed containers. +@@ -195,7 +220,8 @@ BOOST_AUTO_TEST_CASE(decode_depth_limit) + } + ] + })"; +- BOOST_CHECK_EXCEPTION(JsonDecode(deeperMixedNesting, 9), std::exception, isDepthLimit); ++ auto deeperPath = R"(root["1st-level"][2]["3rd-level"]["4th-level"][1]["6th-level"]["7th-level"]["8th-level"][1])"; ++ BOOST_CHECK(ExpectLimitExceeded(deeperMixedNesting, 9, deeperPath)); + BOOST_CHECK_NO_THROW(JsonDecode(deeperMixedNesting, 10)); + } + diff -Nru icinga2-2.14.6/debian/patches/0001-Prefer-icinga-String-GetData-over-icinga-String-CStr.patch icinga2-2.14.6/debian/patches/0001-Prefer-icinga-String-GetData-over-icinga-String-CStr.patch --- icinga2-2.14.6/debian/patches/0001-Prefer-icinga-String-GetData-over-icinga-String-CStr.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Prefer-icinga-String-GetData-over-icinga-String-CStr.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,45 @@ +From 18d0f9f7ade52ce5de9b06da98a2f7f85c5448bc Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Fri, 11 Apr 2025 12:27:25 +0200 +Subject: Prefer icinga::String::GetData() over icinga::String::CStr() + +Creating the string_view from the std::string (as returned by GetData()) uses +the stored length instead of having to detect it by finding '\0'. + +(cherry picked from commit ccfc72267f5c4bddad6779bb30c93a504f286500) +--- + lib/base/tcpsocket.hpp | 4 ++-- + lib/remote/apilistener.cpp | 2 +- + 2 files changed, 3 insertions(+), 3 deletions(-) + +--- a/lib/base/tcpsocket.hpp ++++ b/lib/base/tcpsocket.hpp +@@ -41,7 +41,7 @@ void Connect(Socket& socket, const Strin + using boost::asio::ip::tcp; + + tcp::resolver resolver (IoEngine::Get().GetIoContext()); +- auto result (resolver.resolve(node.CStr(), service.CStr())); ++ auto result (resolver.resolve(node.GetData(), service.GetData())); + auto current (result.begin()); + + for (;;) { +@@ -71,7 +71,7 @@ void Connect(Socket& socket, const Strin + using boost::asio::ip::tcp; + + tcp::resolver resolver (IoEngine::Get().GetIoContext()); +- auto result (resolver.async_resolve(node.CStr(), service.CStr(), yc)); ++ auto result (resolver.async_resolve(node.GetData(), service.GetData(), yc)); + auto current (result.begin()); + + for (;;) { +--- a/lib/remote/apilistener.cpp ++++ b/lib/remote/apilistener.cpp +@@ -439,7 +439,7 @@ bool ApiListener::AddListener(const Stri + + try { + tcp::resolver resolver (io); +- auto result (resolver.resolve(node.CStr(), service.CStr(), tcp::resolver::passive)); ++ auto result (resolver.resolve(node.GetData(), service.GetData(), tcp::resolver::passive)); + auto current (result.begin()); + + for (;;) { diff -Nru icinga2-2.14.6/debian/patches/0001-Prevent-HTTP-requests-from-creating-deeply-nested-da.patch icinga2-2.14.6/debian/patches/0001-Prevent-HTTP-requests-from-creating-deeply-nested-da.patch --- icinga2-2.14.6/debian/patches/0001-Prevent-HTTP-requests-from-creating-deeply-nested-da.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Prevent-HTTP-requests-from-creating-deeply-nested-da.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,59 @@ +From 011c831632ed34c1f4ae33c3c9a72945b5e340c4 Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Fri, 12 Jun 2026 15:28:45 +0200 +Subject: Prevent HTTP requests from creating deeply nested data structures + +Add validation checks to code paths reachable from the HTTP API (except full +config file deployments via /v1/config) that prevent creating deeply nested +data structures that could later cause a stack overflow. + +(cherry picked from commit 4b7ef02bd25e131a6dfcb0d2c3d45e816ecef365) +--- + lib/base/configobject.cpp | 7 +++++++ + lib/base/configobject.hpp | 2 ++ + lib/base/configwriter.cpp | 7 +++++++ + 3 files changed, 16 insertions(+) + +--- a/lib/base/configobject.cpp ++++ b/lib/base/configobject.cpp +@@ -97,6 +97,13 @@ void ConfigObject::ModifyAttribute(const + + std::vector tokens = attr.Split("."); + ++ // This is reachable from ModifyObjectHandler. This check prevents API clients from creating deeply nested data ++ // structures that could overflow the stack later on. ++ if (tokens.size() > VarDepthLimit) { ++ BOOST_THROW_EXCEPTION(std::invalid_argument("Attribute '" + attr + "' exceeds maximum nesting level of " + ++ std::to_string(VarDepthLimit) + ".")); ++ } ++ + String fieldName = tokens[0]; + + int fid = type->GetFieldId(fieldName); +--- a/lib/base/configobject.hpp ++++ b/lib/base/configobject.hpp +@@ -25,6 +25,8 @@ class ConfigObject : public ObjectImpl OnStateChanged; + + bool IsActive() const; +--- a/lib/base/configwriter.cpp ++++ b/lib/base/configwriter.cpp +@@ -79,6 +79,13 @@ void ConfigWriter::EmitScope(std::ostrea + if (splitDot) { + std::vector tokens = kv.first.Split("."); + ++ // This is reachable from CreateObjectHandler. This check prevents API clients from creating deeply ++ // nested data structures that could overflow the stack later on. ++ if (tokens.size() > ConfigObject::VarDepthLimit) { ++ BOOST_THROW_EXCEPTION(std::invalid_argument("Attribute '" + kv.first + ++ "' exceeds maximum nesting level of " + std::to_string(ConfigObject::VarDepthLimit) + ".")); ++ } ++ + EmitIdentifier(fp, tokens[0], true); + + for (std::vector::size_type i = 1; i < tokens.size(); i++) { diff -Nru icinga2-2.14.6/debian/patches/0001-Remove-TicketSalt-in-VariableQueryHandler-as-early-a.patch icinga2-2.14.6/debian/patches/0001-Remove-TicketSalt-in-VariableQueryHandler-as-early-a.patch --- icinga2-2.14.6/debian/patches/0001-Remove-TicketSalt-in-VariableQueryHandler-as-early-a.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Remove-TicketSalt-in-VariableQueryHandler-as-early-a.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,61 @@ +From c508d972450c876e0f4c4947306f2835614a5574 Mon Sep 17 00:00:00 2001 +From: Johannes Schmidt +Date: Wed, 1 Oct 2025 09:37:13 +0200 +Subject: Remove TicketSalt in VariableQueryHandler as early as possible + +This is to avoid another kind of exploit found by where TicketSalt +can be accessed when the object filter is evaluated by checking +its name via the local `variable` reference and then `throw`ing +it to print it in the error message. + +Reported-by: julian.brost@icinga.com +--- + lib/remote/variablequeryhandler.cpp | 24 ++++++++++++++++-------- + 1 file changed, 16 insertions(+), 8 deletions(-) + +--- a/lib/remote/variablequeryhandler.cpp ++++ b/lib/remote/variablequeryhandler.cpp +@@ -22,17 +22,28 @@ public: + void FindTargets(const String& type, + const std::function& addTarget) const override + { +- { +- Namespace::Ptr globals = ScriptGlobal::GetGlobals(); +- ObjectLock olock(globals); +- for (const Namespace::Pair& kv : globals) { +- addTarget(FilterUtility::GetTargetForVar(kv.first, kv.second.Val)); ++ Namespace::Ptr globals = ScriptGlobal::GetGlobals(); ++ ObjectLock olock(globals); ++ for (auto& [key, value] : globals) { ++ /* We want wo avoid leaking the TicketSalt over the API, so we remove it here, ++ * as early as possible, so it isn't possible to abuse the fact that all of the ++ * global variables we return here later get checked against a user-provided ++ * filter expression that can cause its content to be printed in an error message ++ * or potentially access them otherwise. ++ */ ++ if (key == "TicketSalt") { ++ continue; + } ++ ++ addTarget(FilterUtility::GetTargetForVar(key, value.Val)); + } + } + + Value GetTargetByName(const String& type, const String& name) const override + { ++ if (name == "TicketSalt") { ++ BOOST_THROW_EXCEPTION(std::invalid_argument{"Access to TicketSalt via /v1/variables is not permitted."}); ++ } + return FilterUtility::GetTargetForVar(name, ScriptGlobal::Get(name)); + } + +@@ -90,9 +101,6 @@ bool VariableQueryHandler::HandleRequest + ArrayData results; + + for (const Dictionary::Ptr& var : objs) { +- if (var->Get("name") == "TicketSalt") +- continue; +- + results.emplace_back(new Dictionary({ + { "name", var->Get("name") }, + { "type", var->Get("type") }, diff -Nru icinga2-2.14.6/debian/patches/0001-Restore-single-argument-Json.decode-in-the-DSL.patch icinga2-2.14.6/debian/patches/0001-Restore-single-argument-Json.decode-in-the-DSL.patch --- icinga2-2.14.6/debian/patches/0001-Restore-single-argument-Json.decode-in-the-DSL.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Restore-single-argument-Json.decode-in-the-DSL.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,92 @@ +From 30c2fa004c1d14a2fedd97c3a91a5a988985d871 Mon Sep 17 00:00:00 2001 +From: Dominik Bay +Date: Mon, 29 Jun 2026 18:34:11 +0200 +Subject: Restore single-argument Json.decode() in the DSL + +The recursion depth limit added to JsonDecode() in 2.16.2 gave the C++ +function a second parameter with a default value. Function pointers do not +carry default arguments, so the DSL function binding deduced an arity of 2 +via boost::function_types::function_arity and required two arguments. As a +result `Json.decode("...")` failed with "Too few arguments for function", +an undocumented breaking change in a patch release. + +Wrap JsonDecode() in a single-argument shim (mirroring the existing +JsonEncodeShim) so the registered function keeps its one-parameter contract +while still applying the default depth limit internally. + +refs #10913 + +(cherry picked from commit d29ac491f231d6831e98cc614902faed78718598) +--- + lib/base/json-script.cpp | 12 +++++++++++- + test/CMakeLists.txt | 1 + + test/base-json.cpp | 15 +++++++++++++++ + 3 files changed, 27 insertions(+), 1 deletion(-) + +--- a/lib/base/json-script.cpp ++++ b/lib/base/json-script.cpp +@@ -14,12 +14,22 @@ static String JsonEncodeShim(const Value + return JsonEncode(value); + } + ++static Value JsonDecodeShim(const String& data) ++{ ++ /* Wrap JsonDecode() so that the DSL function keeps its single-argument ++ * signature. JsonDecode()'s depthLimit parameter has a default value, but ++ * function pointers don't carry defaults, so binding it directly would make ++ * depthLimit a required argument and break Json.decode("..."). See #10913. ++ */ ++ return JsonDecode(data); ++} ++ + INITIALIZE_ONCE([]() { + Namespace::Ptr jsonNS = new Namespace(true); + + /* Methods */ + jsonNS->Set("encode", new Function("Json#encode", JsonEncodeShim, { "value" }, true)); +- jsonNS->Set("decode", new Function("Json#decode", JsonDecode, { "value" }, true)); ++ jsonNS->Set("decode", new Function("Json#decode", JsonDecodeShim, { "value" }, true)); + + jsonNS->Freeze(); + +--- a/test/CMakeLists.txt ++++ b/test/CMakeLists.txt +@@ -85,6 +85,7 @@ add_boost_test(base + base_io_engine/timeout_due_scope + base_json/encode + base_json/decode ++ base_json/decode_dsl_single_argument + base_json/invalid1 + base_json/decode_depth_limit + base_json/stack_size_coroutine +--- a/test/base-json.cpp ++++ b/test/base-json.cpp +@@ -8,6 +8,7 @@ + #include "base/io-engine.hpp" + #include "base/objectlock.hpp" + #include "base/json.hpp" ++#include "base/scriptglobal.hpp" + #include + #include + #include +@@ -123,6 +124,20 @@ BOOST_AUTO_TEST_CASE(decode) + BOOST_CHECK(uint.IsNumber() && uint.Get() == 23.0); + } + ++BOOST_AUTO_TEST_CASE(decode_dsl_single_argument) ++{ ++ /* Regression test for #10913: the Json.decode() DSL function must accept a ++ * single argument. JsonDecode()'s optional depthLimit parameter must not ++ * leak into the function binding as a required argument. ++ */ ++ Namespace::Ptr systemNS = ScriptGlobal::Get("System"); ++ Namespace::Ptr jsonNS = systemNS->Get("Json"); ++ Function::Ptr decode = jsonNS->Get("decode"); ++ ++ BOOST_REQUIRE(decode); ++ BOOST_CHECK_EQUAL(decode->Invoke({ "2" }), Value(2)); ++} ++ + BOOST_AUTO_TEST_CASE(invalid1) + { + BOOST_CHECK_THROW(JsonDecode("\"1.7"), std::exception); diff -Nru icinga2-2.14.6/debian/patches/0001-Send-signals-as-Icinga-user-in-safe-reload-and-logro.patch icinga2-2.14.6/debian/patches/0001-Send-signals-as-Icinga-user-in-safe-reload-and-logro.patch --- icinga2-2.14.6/debian/patches/0001-Send-signals-as-Icinga-user-in-safe-reload-and-logro.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-Send-signals-as-Icinga-user-in-safe-reload-and-logro.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,51 @@ +From f36302d41d89edb193eece7817806b1feb7c547f Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Fri, 1 Aug 2025 11:27:48 +0200 +Subject: Send signals as Icinga user in safe-reload and logrotate + +In contrast to the regular `kill` binary, `icinga2 internal signal` drops +permissions before sending the signal. This is important as the PID file can be +written by the Icinga user, dropping the permissions prevents that user from +using this to send signals to processes it is not supposed to signal. + +SIGUSR1 wasn't among the list of signals supported by `icinga2 internal +signal`, so it is added there. +--- + etc/initsystem/safe-reload.cmake | 2 +- + etc/logrotate.d/icinga2.cmake | 2 +- + lib/cli/internalsignalcommand.cpp | 2 ++ + 3 files changed, 4 insertions(+), 2 deletions(-) + +--- a/etc/initsystem/safe-reload.cmake ++++ b/etc/initsystem/safe-reload.cmake +@@ -43,7 +43,7 @@ if [ ! -e "$ICINGA2_PID_FILE" ]; then + fi + + pid=`cat "$ICINGA2_PID_FILE"` +-if ! kill -HUP "$pid" >/dev/null 2>&1; then ++if ! "$DAEMON" internal signal --sig SIGHUP --pid "$pid" >/dev/null 2>&1; then + echo "Error: Icinga not running" + exit 7 + fi +--- a/etc/logrotate.d/icinga2.cmake ++++ b/etc/logrotate.d/icinga2.cmake +@@ -6,7 +6,7 @@ + missingok + notifempty@LOGROTATE_CREATE@ + postrotate +- /bin/kill -USR1 $(cat @ICINGA2_INITRUNDIR@/icinga2.pid 2> /dev/null) 2> /dev/null || true ++ @CMAKE_INSTALL_FULL_SBINDIR@/icinga2 internal signal --sig SIGUSR1 --pid "$(cat @ICINGA2_INITRUNDIR@/icinga2.pid 2> /dev/null)" 2> /dev/null || true + endscript + } + +--- a/lib/cli/internalsignalcommand.cpp ++++ b/lib/cli/internalsignalcommand.cpp +@@ -57,6 +57,8 @@ int InternalSignalCommand::Run(const boo + return kill(vm["pid"].as(), SIGCHLD); + if (signal == "SIGHUP") + return kill(vm["pid"].as(), SIGHUP); ++ if (signal == "SIGUSR1") ++ return kill(vm["pid"].as(), SIGUSR1); + + Log(LogCritical, "cli") << "Unsupported signal \"" << signal << "\""; + #else diff -Nru icinga2-2.14.6/debian/patches/0001-SpawnCoroutine-move-callback-into-wrapper-lambda.patch icinga2-2.14.6/debian/patches/0001-SpawnCoroutine-move-callback-into-wrapper-lambda.patch --- icinga2-2.14.6/debian/patches/0001-SpawnCoroutine-move-callback-into-wrapper-lambda.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-SpawnCoroutine-move-callback-into-wrapper-lambda.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,27 @@ +From 927f4b5dabff87cfc5b26e67616fc6a2da7f2519 Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Tue, 15 Apr 2025 15:10:12 +0200 +Subject: SpawnCoroutine: move callback into wrapper lambda +MIME-Version: 1.0 +Content-Type: text/plain; charset=UTF-8 +Content-Transfer-Encoding: 8bit + +f isn't used otherwise in the function, so if possible, it can just be moved into the lambda, avoiding a copy. + +Co-authored-by: Alexander Aleksandrovič Klimov +(cherry picked from commit d3fae440d4b229581c39bc972ea2f3edf0d23efe) +--- + lib/base/io-engine.hpp | 2 +- + 1 file changed, 1 insertion(+), 1 deletion(-) + +--- a/lib/base/io-engine.hpp ++++ b/lib/base/io-engine.hpp +@@ -105,7 +105,7 @@ public: + + template + static void SpawnCoroutine(Handler& h, Function f) { +- auto wrapper = [f](boost::asio::yield_context yc) { ++ auto wrapper = [f = std::move(f)](boost::asio::yield_context yc) { + try { + f(yc); + } catch (const std::exception& ex) { diff -Nru icinga2-2.14.6/debian/patches/0001-tests-add-SpawnSynchronizedCoroutine-to-base-json.cp.patch icinga2-2.14.6/debian/patches/0001-tests-add-SpawnSynchronizedCoroutine-to-base-json.cp.patch --- icinga2-2.14.6/debian/patches/0001-tests-add-SpawnSynchronizedCoroutine-to-base-json.cp.patch 1970-01-01 00:00:00.000000000 +0000 +++ icinga2-2.14.6/debian/patches/0001-tests-add-SpawnSynchronizedCoroutine-to-base-json.cp.patch 2026-07-07 04:21:07.000000000 +0000 @@ -0,0 +1,50 @@ +From 2d3277799cf16b7546156f1b8e8db0df2604a14f Mon Sep 17 00:00:00 2001 +From: Julian Brost +Date: Tue, 9 Jun 2026 14:46:26 +0200 +Subject: tests: add SpawnSynchronizedCoroutine() to base-json.cpp + +Backports the helper from 2.16's remote-httpmessage.cpp. + +(cherry picked from commit ed202ed74571cc6d258ed08249a3f4814c760783) +--- + test/base-json.cpp | 22 ++++++++++++++++++++++ + 1 file changed, 22 insertions(+) + +--- a/test/base-json.cpp ++++ b/test/base-json.cpp +@@ -4,13 +4,35 @@ + #include "base/function.hpp" + #include "base/namespace.hpp" + #include "base/array.hpp" ++#include "base/io-engine.hpp" + #include "base/objectlock.hpp" + #include "base/json.hpp" ++#include ++#include + #include + #include + + using namespace icinga; + ++static std::future SpawnSynchronizedCoroutine(std::function fn) ++{ ++ using namespace icinga; ++ ++ auto promise = std::make_unique>(); ++ auto future = promise->get_future(); ++ auto& io = IoEngine::Get().GetIoContext(); ++ IoEngine::SpawnCoroutine(io, [promise = std::move(promise), fn = std::move(fn)](boost::asio::yield_context yc) { ++ try { ++ fn(std::move(yc)); ++ } catch (const std::exception&) { ++ promise->set_exception(std::current_exception()); ++ return; ++ } ++ promise->set_value(); ++ }); ++ return future; ++} ++ + BOOST_AUTO_TEST_SUITE(base_json) + + BOOST_AUTO_TEST_CASE(encode) diff -Nru icinga2-2.14.6/debian/patches/series icinga2-2.14.6/debian/patches/series --- icinga2-2.14.6/debian/patches/series 2024-11-15 16:29:56.000000000 +0000 +++ icinga2-2.14.6/debian/patches/series 2026-07-07 04:21:07.000000000 +0000 @@ -1,3 +1,30 @@ 21_config_changes postgres-checkcommand.patch systemd-checkcommand.patch +0001-Send-signals-as-Icinga-user-in-safe-reload-and-logro.patch +0001-Add-upgrading-docs-for-v2.14.7.patch +0001-Add-permission-checking-to-script-frames-and-filter-.patch +0001-Check-for-permission-in-get_object.patch +0001-Declare-functions-as-unsafe-that-aren-t-useful-in-fi.patch +0001-Filter-global-variables-when-Sandboxed.patch +0001-Add-test-cases-for-checking-permissions-in-filter-ex.patch +0001-Remove-TicketSalt-in-VariableQueryHandler-as-early-a.patch +0001-DerefExpression-Add-missing-nullptr-check.patch +0001-Don-t-use-boost-asio-ip-tcp-resolver-query.patch +0001-Don-t-use-boost-asio-io_context-strand-method-remove.patch +0001-In-a-coroutine-re-throw-everything-ex.-std-exception.patch +0001-Don-t-use-removed-boost-asio-spawn-overload-if-Boost.patch +0001-Prefer-icinga-String-GetData-over-icinga-String-CStr.patch +0001-Avoid-multiple-if-in-a-single-function-call-expressi.patch +0001-SpawnCoroutine-move-callback-into-wrapper-lambda.patch +0001-Check-if-client-is-a-valid-endpoint-before-updating-.patch +0001-ConfigWriter-EmitScope-Escape-import.patch +0001-Add-filter-expression-permission.patch +0001-JsonDecode-add-depth-limit.patch +0001-JsonDecode-include-path-in-JSON-depth-error.patch +0001-Don-t-shut-down-JSON-RPC-connection-if-a-message-fai.patch +0001-Prevent-HTTP-requests-from-creating-deeply-nested-da.patch +0001-tests-add-SpawnSynchronizedCoroutine-to-base-json.cp.patch +0001-Add-tests-for-JsonDecode-depth-limit-in-combination-.patch +0001-Also-execute-JsonDecode-stack-size-test-in-pthread-i.patch +0001-Restore-single-argument-Json.decode-in-the-DSL.patch