mirror of
https://github.com/Icinga/icinga2.git
synced 2026-07-16 04:03:00 -04:00
Merge pull request #10904 from Icinga/security-2.16.2
Some checks are pending
Linux / alpine:bash (push) Waiting to run
Linux / amazonlinux:2 (push) Waiting to run
Linux / amazonlinux:2023 (push) Waiting to run
Linux / debian:11 (linux/386) (push) Waiting to run
Linux / debian:11 (push) Waiting to run
Linux / debian:12 (linux/386) (push) Waiting to run
Linux / debian:12 (push) Waiting to run
Linux / debian:13 (push) Waiting to run
Linux / fedora:41 (push) Waiting to run
Linux / fedora:42 (push) Waiting to run
Linux / fedora:43 (push) Waiting to run
Linux / fedora:44 (push) Waiting to run
Linux / opensuse/leap:15.6 (push) Waiting to run
Linux / opensuse/leap:16.0 (push) Waiting to run
Linux / registry.suse.com/bci/bci-base:16.0 (push) Waiting to run
Linux / registry.suse.com/suse/sle15:15.6 (push) Waiting to run
Linux / registry.suse.com/suse/sle15:15.7 (push) Waiting to run
Linux / rockylinux/rockylinux:10 (push) Waiting to run
Linux / rockylinux:8 (push) Waiting to run
Linux / rockylinux:9 (push) Waiting to run
Linux / ubuntu:22.04 (push) Waiting to run
Linux / ubuntu:24.04 (push) Waiting to run
Linux / ubuntu:25.04 (push) Waiting to run
Linux / ubuntu:25.10 (push) Waiting to run
Linux / ubuntu:26.04 (push) Waiting to run
Windows / Windows (push) Waiting to run
Some checks are pending
Linux / alpine:bash (push) Waiting to run
Linux / amazonlinux:2 (push) Waiting to run
Linux / amazonlinux:2023 (push) Waiting to run
Linux / debian:11 (linux/386) (push) Waiting to run
Linux / debian:11 (push) Waiting to run
Linux / debian:12 (linux/386) (push) Waiting to run
Linux / debian:12 (push) Waiting to run
Linux / debian:13 (push) Waiting to run
Linux / fedora:41 (push) Waiting to run
Linux / fedora:42 (push) Waiting to run
Linux / fedora:43 (push) Waiting to run
Linux / fedora:44 (push) Waiting to run
Linux / opensuse/leap:15.6 (push) Waiting to run
Linux / opensuse/leap:16.0 (push) Waiting to run
Linux / registry.suse.com/bci/bci-base:16.0 (push) Waiting to run
Linux / registry.suse.com/suse/sle15:15.6 (push) Waiting to run
Linux / registry.suse.com/suse/sle15:15.7 (push) Waiting to run
Linux / rockylinux/rockylinux:10 (push) Waiting to run
Linux / rockylinux:8 (push) Waiting to run
Linux / rockylinux:9 (push) Waiting to run
Linux / ubuntu:22.04 (push) Waiting to run
Linux / ubuntu:24.04 (push) Waiting to run
Linux / ubuntu:25.04 (push) Waiting to run
Linux / ubuntu:25.10 (push) Waiting to run
Linux / ubuntu:26.04 (push) Waiting to run
Windows / Windows (push) Waiting to run
Merge changes from private security fork (v2.16.2)
This commit is contained in:
commit
00cd4fd853
38 changed files with 553 additions and 77 deletions
19
CHANGELOG.md
19
CHANGELOG.md
|
|
@ -7,6 +7,25 @@ documentation before upgrading to a new release.
|
|||
|
||||
Released closed milestones can be found on [GitHub](https://github.com/Icinga/icinga2/milestones?state=closed).
|
||||
|
||||
## 2.16.2 (2026-06-29)
|
||||
|
||||
This release fixes some critical security vulnerabilities in Icinga 2. Users are advised to upgrade immediately, as two
|
||||
of them allow an unauthenticated attacker to take over or crash the Icinga 2 process over the network. The other
|
||||
security fixes only affect authenticated API users.
|
||||
|
||||
In addition, a new permission named `filter-expression` is introduced, which allows specifying if individual API users
|
||||
are allowed to use DSL filter expressions in API queries. This allows further restricting some API users that don't need
|
||||
this capability, for example, those only submitting individual check results. Due to the incompatibility of this change,
|
||||
enforcement of this permission is opt-in until v2.17; see the
|
||||
[upgrading docs](https://icinga.com/docs/icinga-2/latest/doc/16-upgrading-icinga-2/#upgrading-to-2-16-2) for details.
|
||||
|
||||
* Verify that certificate update requests come from an authorized endpoint ([GHSA-vj39-ww8j-vvx5](https://github.com/Icinga/icinga2/security/advisories/GHSA-vj39-ww8j-vvx5))
|
||||
* Fix stack overflow due to deeply nested data structures ([GHSA-wh38-wg57-5w7g](https://github.com/Icinga/icinga2/security/advisories/GHSA-wh38-wg57-5w7g))
|
||||
* Prevent arbitrary config injection on object creation via the API ([GHSA-jgqj-x5j9-vgcm](https://github.com/Icinga/icinga2/security/advisories/GHSA-jgqj-x5j9-vgcm))
|
||||
* Fix that `/v1/config/files` could send uninitialized memory in case of file I/O errors (#10871)
|
||||
* Add `filter-expression` permission to make it possible to prevent API users from using DSL filter expressions
|
||||
* Windows: Update bundled OpenSSL to v3.5.7 (#10893)
|
||||
|
||||
## 2.16.1 (2026-05-21)
|
||||
|
||||
This is a small release mostly to fix the issues some users were encountering in connection with perfdata writers,
|
||||
|
|
|
|||
|
|
@ -390,6 +390,7 @@ check_function_exists(backtrace_symbols HAVE_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_function_exists(pthread_set_name_np HAVE_PTHREAD_SET_NAME_NP)
|
||||
check_function_exists(pthread_setname_np HAVE_PTHREAD_SETNAME_NP)
|
||||
check_library_exists(dl dladdr "dlfcn.h" HAVE_DLADDR)
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
Version: 2.16.1
|
||||
Version: 2.16.2
|
||||
Revision: 1
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@
|
|||
#cmakedefine HAVE_CXXABI_H
|
||||
#cmakedefine HAVE_NICE
|
||||
#cmakedefine HAVE_MALLOC_INFO
|
||||
#cmakedefine HAVE_PTHREAD_CREATE
|
||||
#cmakedefine HAVE_PTHREAD_SET_NAME_NP
|
||||
#cmakedefine HAVE_PTHREAD_SETNAME_NP
|
||||
#cmakedefine HAVE_EDITLINE
|
||||
|
|
|
|||
|
|
@ -363,6 +363,15 @@ Run the following command to:
|
|||
icinga2 api setup
|
||||
```
|
||||
|
||||
For new installations, it is recommended to set the following additional attribute inside the `ApiListener` object
|
||||
definition in `/etc/icinga2/features-enabled/api.conf`. This will already enforce stricter permissions as they will
|
||||
become the default with v2.17 (see the [upgrading documentation](16-upgrading-icinga-2.md#upgrading-to-2-16-2) for the
|
||||
version that introduced that setting for more details):
|
||||
|
||||
```
|
||||
enforce_filter_expression_permission = true
|
||||
```
|
||||
|
||||
Restart Icinga 2 for these changes to take effect.
|
||||
|
||||
```bash
|
||||
|
|
|
|||
|
|
@ -1088,28 +1088,29 @@ object ApiListener "api" {
|
|||
|
||||
Configuration Attributes:
|
||||
|
||||
Name | Type | Description
|
||||
--------------------------------------|-----------------------|----------------------------------
|
||||
cert\_path | String | **Deprecated.** Path to the public key.
|
||||
key\_path | String | **Deprecated.** Path to the private key.
|
||||
ca\_path | String | **Deprecated.** Path to the CA certificate file.
|
||||
ticket\_salt | String | **Optional.** Private key for [CSR auto-signing](06-distributed-monitoring.md#distributed-monitoring-setup-csr-auto-signing). **Required** for a signing master instance.
|
||||
crl\_path | String | **Optional.** Path to the CRL file.
|
||||
bind\_host | String | **Optional.** The IP address the api listener should be bound to. If not specified, the ApiListener is bound to `::` and listens for both IPv4 and IPv6 connections or to `0.0.0.0` if IPv6 is not supported by the operating system.
|
||||
bind\_port | Number | **Optional.** The port the api listener should be bound to. Defaults to `5665`.
|
||||
accept\_config | Boolean | **Optional.** Accept zone configuration. Defaults to `false`.
|
||||
accept\_commands | Boolean | **Optional.** Accept remote commands. Defaults to `false`.
|
||||
max\_anonymous\_clients | Number | **Optional.** Limit the number of anonymous client connections (not configured endpoints and signing requests).
|
||||
cipher\_list | String | **Optional.** Cipher list that is allowed. For a list of available ciphers run `openssl ciphers`. Defaults to `ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256`.
|
||||
tls\_protocolmin | String | **Optional.** Minimum TLS protocol version. Since v2.11, only `TLSv1.2` is supported. Defaults to `TLSv1.2`.
|
||||
tls\_handshake\_timeout | Number | **Deprecated.** TLS Handshake timeout. Defaults to `10s`.
|
||||
connect\_timeout | Number | **Optional.** Timeout for establishing new connections. Affects both incoming and outgoing connections. Within this time, the TCP and TLS handshakes must complete and either a HTTP request or an Icinga cluster connection must be initiated. Defaults to `15s`.
|
||||
access\_control\_allow\_origin | Array | **Optional.** Specifies an array of origin URLs that may access the API. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Origin)
|
||||
access\_control\_allow\_credentials | Boolean | **Deprecated.** Indicates whether or not the actual request can be made using credentials. Defaults to `true`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Credentials)
|
||||
access\_control\_allow\_headers | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP headers can be used when making the actual request. Defaults to `Authorization`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Headers)
|
||||
access\_control\_allow\_methods | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP methods can be used when making the actual request. Defaults to `GET, POST, PUT, DELETE`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Methods)
|
||||
environment | String | **Optional.** Used as suffix in TLS SNI extension name; default from constant `ApiEnvironment`, which is empty.
|
||||
http\_response\_headers | Dictionary | **Optional.** Additional headers to add to HTTP responses, for example `{"Strict-Transport-Security" = "max-age=31536000"}`. Defaults to none.
|
||||
Name | Type | Description
|
||||
----------------------------------------|------------|----------------------------------
|
||||
cert\_path | String | **Deprecated.** Path to the public key.
|
||||
key\_path | String | **Deprecated.** Path to the private key.
|
||||
ca\_path | String | **Deprecated.** Path to the CA certificate file.
|
||||
ticket\_salt | String | **Optional.** Private key for [CSR auto-signing](06-distributed-monitoring.md#distributed-monitoring-setup-csr-auto-signing). **Required** for a signing master instance.
|
||||
crl\_path | String | **Optional.** Path to the CRL file.
|
||||
bind\_host | String | **Optional.** The IP address the api listener should be bound to. If not specified, the ApiListener is bound to `::` and listens for both IPv4 and IPv6 connections or to `0.0.0.0` if IPv6 is not supported by the operating system.
|
||||
bind\_port | Number | **Optional.** The port the api listener should be bound to. Defaults to `5665`.
|
||||
accept\_config | Boolean | **Optional.** Accept zone configuration. Defaults to `false`.
|
||||
accept\_commands | Boolean | **Optional.** Accept remote commands. Defaults to `false`.
|
||||
max\_anonymous\_clients | Number | **Optional.** Limit the number of anonymous client connections (not configured endpoints and signing requests).
|
||||
cipher\_list | String | **Optional.** Cipher list that is allowed. For a list of available ciphers run `openssl ciphers`. Defaults to `ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384:DHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256`.
|
||||
tls\_protocolmin | String | **Optional.** Minimum TLS protocol version. Since v2.11, only `TLSv1.2` is supported. Defaults to `TLSv1.2`.
|
||||
tls\_handshake\_timeout | Number | **Deprecated.** TLS Handshake timeout. Defaults to `10s`.
|
||||
connect\_timeout | Number | **Optional.** Timeout for establishing new connections. Affects both incoming and outgoing connections. Within this time, the TCP and TLS handshakes must complete and either a HTTP request or an Icinga cluster connection must be initiated. Defaults to `15s`.
|
||||
access\_control\_allow\_origin | Array | **Optional.** Specifies an array of origin URLs that may access the API. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Origin)
|
||||
access\_control\_allow\_credentials | Boolean | **Deprecated.** Indicates whether or not the actual request can be made using credentials. Defaults to `true`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Credentials)
|
||||
access\_control\_allow\_headers | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP headers can be used when making the actual request. Defaults to `Authorization`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Headers)
|
||||
access\_control\_allow\_methods | String | **Deprecated.** Used in response to a preflight request to indicate which HTTP methods can be used when making the actual request. Defaults to `GET, POST, PUT, DELETE`. [(MDN docs)](https://developer.mozilla.org/en-US/docs/Web/HTTP/Access_control_CORS#Access-Control-Allow-Methods)
|
||||
environment | String | **Optional.** Used as suffix in TLS SNI extension name; default from constant `ApiEnvironment`, which is empty.
|
||||
http\_response\_headers | Dictionary | **Optional.** Additional headers to add to HTTP responses, for example `{"Strict-Transport-Security" = "max-age=31536000"}`. Defaults to none.
|
||||
enforce\_filter\_expression\_permission | Boolean | **Optional.** Enforce the `filter-expression` permission. Defaults to `false` until v2.17 for compatibility.
|
||||
|
||||
The attributes `access_control_allow_credentials`, `access_control_allow_headers` and `access_control_allow_methods`
|
||||
are controlled by Icinga 2 and are not changeable by config any more.
|
||||
|
|
|
|||
|
|
@ -299,6 +299,18 @@ Available permissions for specific URL endpoints:
|
|||
types | /v1/types | Yes | 1
|
||||
variables | /v1/variables | Yes | 1
|
||||
|
||||
Available permissions that are not bound to specific URL endpoints:
|
||||
|
||||
Permissions | Description
|
||||
------------------------------|------------
|
||||
filter-expression | Allows the user to provide their own [advanced filter expressions](12-icinga2-api.md#icinga2-api-advanced-filters).
|
||||
|
||||
!!! warning
|
||||
|
||||
The `filter-expression` permission was introduced in v2.16.2 and is only enforced if the
|
||||
[`enforce_filter_expression_permission` attribute of `ApiListener`](09-object-types.md#objecttype-apilistener)
|
||||
is set to `true`. For compatibility reasons, this will not be enforced by default until v2.17.
|
||||
|
||||
The required actions or types can be replaced by using a wildcard match ("\*").
|
||||
|
||||
|
||||
|
|
@ -435,6 +447,16 @@ The syntax for these filters is the same like for [apply rule expressions](03-mo
|
|||
The `filter` parameter can only be specified once, complex filters must
|
||||
be defined once in the provided string value.
|
||||
|
||||
!!! warning
|
||||
|
||||
In order to use these advanced filters, the `ApiUser` must be granted the `filter-expression` permission,
|
||||
which should only be done for trusted users. The evaluation happens in the main Icinga 2 worker process and may be
|
||||
abused for denial-of-service attacks, potentially crashing the Icinga 2 daemon.
|
||||
|
||||
Note that before v2.17, this permission is not enforced by default but only if the
|
||||
[`enforce_filter_expression_permission` attribute of `ApiListener`](09-object-types.md#objecttype-apilistener)
|
||||
is set accordingly.
|
||||
|
||||
> **Note**
|
||||
>
|
||||
> Filters used as URL parameter must be URL-encoded. The following examples
|
||||
|
|
|
|||
|
|
@ -8,6 +8,23 @@ Specific version upgrades are described below. Please note that version
|
|||
updates are incremental. An upgrade from v2.6 to v2.8 requires to
|
||||
follow the instructions for v2.7 too.
|
||||
|
||||
## Upgrading to v2.16.2, v2.15.4, or v2.14.9 <a id="upgrading-to-2-16-2"></a>
|
||||
|
||||
### New `filter-expression` permission
|
||||
|
||||
When using the Icinga 2 REST API, filter expressions are a powerful tool. However, that power can also be abused for
|
||||
denial-of-service attacks by authenticated users. Given that these filter expressions are Icinga 2 DSL expressions and
|
||||
their evaluation happens in the main Icinga 2 worker process, this may also crash the Icinga 2 daemon.
|
||||
|
||||
In order to allow mitigating such problems, a new permission named `filter-expression` was added. **Before v2.17, this
|
||||
permission will not be enforced by default** due to the impact on existing installations. Nonetheless, we recommend
|
||||
reviewing your `ApiUser` configuration, explicitly allowing the new permission where necessary and enabling the
|
||||
enforcement of the permission using the [`enforce_filter_expression_permission` attribute of
|
||||
`ApiListener`](09-object-types.md#objecttype-apilistener). If an `ApiUser` makes use of the permission while the
|
||||
permission is not enforced yet, it will be logged. Thus, it is also possible to upgrade, observe the logs, grant the
|
||||
permission as needed or adapting API clients to avoid using filters if possible, and enable the enforcement at a later
|
||||
time.
|
||||
|
||||
## Upgrading to v2.16 <a id="upgrading-to-2-16"></a>
|
||||
|
||||
### Migrating from ElasticsearchWriter to OTLPMetricsWriter
|
||||
|
|
|
|||
|
|
@ -98,6 +98,13 @@ void ConfigObject::ModifyAttribute(const String& attr, const Value& value, bool
|
|||
|
||||
std::vector<String> 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);
|
||||
|
|
@ -503,7 +510,7 @@ void ConfigObject::DumpObjects(const String& filename, int attributeTypes)
|
|||
|
||||
void ConfigObject::RestoreObject(const String& message, int attributeTypes)
|
||||
{
|
||||
Dictionary::Ptr persistentObject = JsonDecode(message);
|
||||
Dictionary::Ptr persistentObject = JsonDecode(message, std::numeric_limits<size_t>::max());
|
||||
|
||||
String type = persistentObject->Get("type");
|
||||
String name = persistentObject->Get("name");
|
||||
|
|
|
|||
|
|
@ -26,6 +26,8 @@ class ConfigObject : public ObjectImpl<ConfigObject>
|
|||
public:
|
||||
DECLARE_OBJECT(ConfigObject);
|
||||
|
||||
static constexpr size_t VarDepthLimit = 16;
|
||||
|
||||
static boost::signals2::signal<void (const ConfigObject::Ptr&)> OnStateChanged;
|
||||
|
||||
bool IsActive() const;
|
||||
|
|
|
|||
|
|
@ -64,7 +64,8 @@ void ConfigWriter::EmitScope(std::ostream& fp, int indentLevel, const Dictionary
|
|||
for (const Value& import : imports) {
|
||||
fp << "\n";
|
||||
EmitIndent(fp, indentLevel);
|
||||
fp << "import \"" << import << "\"";
|
||||
fp << "import ";
|
||||
EmitString(fp, import.Get<String>());
|
||||
}
|
||||
|
||||
fp << "\n";
|
||||
|
|
@ -79,6 +80,13 @@ void ConfigWriter::EmitScope(std::ostream& fp, int indentLevel, const Dictionary
|
|||
if (splitDot) {
|
||||
std::vector<String> 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<String>::size_type i = 1; i < tokens.size(); i++) {
|
||||
|
|
@ -175,11 +183,6 @@ void ConfigWriter::EmitConfigItem(std::ostream& fp, const String& type, const St
|
|||
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);
|
||||
|
|
|
|||
|
|
@ -54,7 +54,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<String>& GetKeywords();
|
||||
|
|
|
|||
|
|
@ -18,7 +18,7 @@
|
|||
#include <utility>
|
||||
#include <vector>
|
||||
#include <stdexcept>
|
||||
#include <boost/context/fixedsize_stack.hpp>
|
||||
#include <boost/context/protected_fixedsize_stack.hpp>
|
||||
#include <boost/exception/all.hpp>
|
||||
#include <boost/asio/deadline_timer.hpp>
|
||||
#include <boost/asio/io_context.hpp>
|
||||
|
|
@ -153,7 +153,7 @@ public:
|
|||
|
||||
#if BOOST_VERSION >= 108700
|
||||
boost::asio::spawn(h,
|
||||
std::allocator_arg, boost::context::fixedsize_stack(GetCoroutineStackSize()),
|
||||
std::allocator_arg, boost::context::protected_fixedsize_stack(GetCoroutineStackSize()),
|
||||
std::move(wrapper),
|
||||
boost::asio::detached
|
||||
);
|
||||
|
|
|
|||
|
|
@ -324,6 +324,8 @@ void JsonEncoder::Flusher::FlushIfSafe(boost::asio::yield_context* yc) const
|
|||
class JsonSax : public nlohmann::json_sax<nlohmann::json>
|
||||
{
|
||||
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;
|
||||
|
|
@ -341,11 +343,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<std::pair<Dictionary*, Array*>> m_CurrentSubtree;
|
||||
String m_CurrentKey;
|
||||
std::vector<Node> m_Stack;
|
||||
std::size_t m_DepthLimit;
|
||||
|
||||
void FillCurrentTarget(Value value);
|
||||
void CheckDepthLimit() const;
|
||||
};
|
||||
|
||||
String icinga::JsonEncode(const Value& value, bool prettify)
|
||||
|
|
@ -369,11 +382,23 @@ void icinga::JsonEncode(const Value& value, std::ostream& os, bool prettify)
|
|||
encoder.Encode(value);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
|
|
@ -440,10 +465,9 @@ inline
|
|||
bool JsonSax::start_object(std::size_t)
|
||||
{
|
||||
auto object (new Dictionary());
|
||||
|
||||
FillCurrentTarget(object);
|
||||
|
||||
m_CurrentSubtree.push({object, nullptr});
|
||||
CheckDepthLimit();
|
||||
m_Stack.push_back(Node{object});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -451,7 +475,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;
|
||||
}
|
||||
|
|
@ -459,8 +483,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;
|
||||
}
|
||||
|
|
@ -469,10 +492,9 @@ inline
|
|||
bool JsonSax::start_array(std::size_t)
|
||||
{
|
||||
auto array (new Array());
|
||||
|
||||
FillCurrentTarget(array);
|
||||
|
||||
m_CurrentSubtree.push({nullptr, array});
|
||||
CheckDepthLimit();
|
||||
m_Stack.push_back(Node{array});
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -480,7 +502,7 @@ bool JsonSax::start_array(std::size_t)
|
|||
inline
|
||||
bool JsonSax::end_array()
|
||||
{
|
||||
m_CurrentSubtree.pop();
|
||||
m_Stack.pop_back();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
|
@ -500,15 +522,34 @@ 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()));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -118,9 +118,11 @@ private:
|
|||
} m_Flusher;
|
||||
};
|
||||
|
||||
static constexpr size_t JsonDecodeDefaultDepthLimit = 24;
|
||||
|
||||
String JsonEncode(const Value& value, bool prettify = false);
|
||||
void JsonEncode(const Value& value, std::ostream& os, bool prettify = false);
|
||||
Value JsonDecode(const String& data);
|
||||
Value JsonDecode(const String& data, size_t depthLimit = JsonDecodeDefaultDepthLimit);
|
||||
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ using namespace icinga;
|
|||
|
||||
bool ObjectListUtility::PrintObject(std::ostream& fp, bool& first, const String& message, std::map<String, int>& type_count, const String& name_filter, const String& type_filter)
|
||||
{
|
||||
Dictionary::Ptr object = JsonDecode(message);
|
||||
Dictionary::Ptr object = JsonDecode(message, std::numeric_limits<size_t>::max());
|
||||
|
||||
Dictionary::Ptr properties = object->Get("properties");
|
||||
|
||||
|
|
|
|||
|
|
@ -54,6 +54,9 @@ bool ActionsHandler::HandleRequest(
|
|||
|
||||
try {
|
||||
objs = FilterUtility::GetFilterTargets(qd, params, user);
|
||||
} catch (const MissingPermissionError& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 403, ex.what());
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 404,
|
||||
"No objects found.",
|
||||
|
|
|
|||
|
|
@ -234,6 +234,18 @@ void ApiListener::OnAllConfigLoaded()
|
|||
|
||||
if (!m_LocalEndpoint)
|
||||
BOOST_THROW_EXCEPTION(ScriptError("Endpoint object for '" + GetIdentity() + "' is missing.", GetDebugInfo()));
|
||||
|
||||
if (!GetEnforceFilterExpressionPermission()) {
|
||||
Log(LogWarning, "ApiListener") << "Security notice:\n"
|
||||
" Currently, all ApiUsers are allowed to use Icinga 2 DSL filter expressions\n"
|
||||
" in API queries because enforce_filter_expression_permission is set to false.\n"
|
||||
" This can pose a security risk as filters are evaluated within the Icinga 2\n"
|
||||
" process and their complexity can be used for denial of service attacks. The\n"
|
||||
" new '" << ApiUser::FilterExpressionPerm << "' permission can be used to allow this for individual\n"
|
||||
" ApiUsers, which should only be granted to trusted users. It is recommended\n"
|
||||
" to set enforce_filter_expression_permission to true to enforce the\n"
|
||||
" permission. This will become the default in v2.17.";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ class ApiListener : ConfigObject
|
|||
[no_user_modify] String identity;
|
||||
|
||||
[state, no_user_modify] Dictionary::Ptr last_failed_zones_stage_validation;
|
||||
|
||||
[config, no_user_modify] bool enforce_filter_expression_permission {
|
||||
default {{{ return false; }}}
|
||||
};
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,8 @@ public:
|
|||
|
||||
static ApiUser::Ptr GetByClientCN(const String& cn);
|
||||
static ApiUser::Ptr GetByAuthHeader(const String& auth_header);
|
||||
|
||||
static inline const String FilterExpressionPerm = "filter-expression";
|
||||
};
|
||||
|
||||
}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ bool DeleteObjectHandler::HandleRequest(
|
|||
|
||||
try {
|
||||
objs = FilterUtility::GetFilterTargets(qd, params, user);
|
||||
} catch (const MissingPermissionError& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 403, ex.what());
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 404,
|
||||
"No objects found.",
|
||||
|
|
|
|||
|
|
@ -92,7 +92,18 @@ bool EventsHandler::HandleRequest(
|
|||
}
|
||||
}
|
||||
|
||||
EventsSubscriber subscriber (std::move(eventTypes), HttpUtility::GetLastParameter(params, "filter"), l_ApiQuery);
|
||||
String filter = "";
|
||||
if (params && params->Contains("filter")) {
|
||||
if (!FilterUtility::HasPermission(request.User(), ApiUser::FilterExpressionPerm, nullptr)) {
|
||||
HttpUtility::SendJsonError(response, params, 403,
|
||||
"Missing permission: " + ApiUser::FilterExpressionPerm);
|
||||
return true;
|
||||
}
|
||||
|
||||
filter = HttpUtility::GetLastParameter(params, "filter");
|
||||
}
|
||||
|
||||
EventsSubscriber subscriber (std::move(eventTypes), std::move(filter), l_ApiQuery);
|
||||
|
||||
response.result(http::status::ok);
|
||||
response.set(http::field::content_type, "application/json");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "remote/filterutility.hpp"
|
||||
#include "remote/apilistener.hpp"
|
||||
#include "remote/httputility.hpp"
|
||||
#include "config/applyrule.hpp"
|
||||
#include "config/configcompiler.hpp"
|
||||
|
|
@ -300,6 +301,22 @@ bool FilterUtility::HasPermission(const ApiUser::Ptr& user, const String& permis
|
|||
}
|
||||
}
|
||||
|
||||
// Requiring the "filter-expression" permission to use any filter expression is an incompatible change. Therefore,
|
||||
// there is a config option to configure whether that permission check is enforced or not. If it is not enforced,
|
||||
// a message is logged, allowing the admin to determine which ApiUsers require this permission. This allows granting
|
||||
// the permission and/or adapting clients as needed until this message does not show up anymore and then switching
|
||||
// to enforcing the permission check.
|
||||
if (!foundPermission && permission == ApiUser::FilterExpressionPerm) {
|
||||
ApiListener::Ptr listener = ApiListener::GetInstance();
|
||||
if (listener && !listener->GetEnforceFilterExpressionPermission()) {
|
||||
Log(LogWarning, "FilterUtility") << "ApiUser '" << user->GetName()
|
||||
<< "' was allowed to use a filter expression despite missing the '" << ApiUser::FilterExpressionPerm
|
||||
<< "' permission due to ApiListener.enforce_filter_expression_permission = false.";
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
if (!foundPermission) {
|
||||
Log(LogWarning, "FilterUtility")
|
||||
<< "Missing permission: " << requiredPermission;
|
||||
|
|
@ -311,7 +328,7 @@ bool FilterUtility::HasPermission(const ApiUser::Ptr& user, const String& permis
|
|||
void FilterUtility::CheckPermission(const ApiUser::Ptr& user, const String& permission, std::unique_ptr<Expression>* permissionFilter)
|
||||
{
|
||||
if (!HasPermission(user, permission, permissionFilter)) {
|
||||
BOOST_THROW_EXCEPTION(ScriptError("Missing permission: " + permission.ToLower()));
|
||||
BOOST_THROW_EXCEPTION(MissingPermissionError("Missing permission: " + permission.ToLower()));
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -386,6 +403,7 @@ std::vector<Value> FilterUtility::GetFilterTargets(const QueryDescription& qd, c
|
|||
frame.PermChecker = permissionChecker;
|
||||
|
||||
if (query->Contains("filter")) {
|
||||
CheckPermission(user, ApiUser::FilterExpressionPerm, nullptr);
|
||||
String filter = HttpUtility::GetLastParameter(query, "filter");
|
||||
std::unique_ptr<Expression> ufilter = ConfigCompiler::CompileText("<API query>", filter);
|
||||
Dictionary::Ptr filter_vars = query->Get("filter_vars");
|
||||
|
|
|
|||
|
|
@ -62,6 +62,19 @@ public:
|
|||
const Object::Ptr& target, const String& variableName = String());
|
||||
};
|
||||
|
||||
/**
|
||||
* Exception to report a missing permission to an API user.
|
||||
*
|
||||
* IMPORTANT: The what() message is reported back to the user and MUST NOT contain sensitive information like names of
|
||||
* objects they are not allowed to access. When using the exception, also pay attention that throwing the exception
|
||||
* does not introduce a sidechannel. For example, it should not be returned if a user-specified object exists but the
|
||||
* user is not allowed to access it, otherwise they would learn that the object exists.
|
||||
*/
|
||||
class MissingPermissionError : public ScriptError
|
||||
{
|
||||
using ScriptError::ScriptError;
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
#endif /* FILTERUTILITY_H */
|
||||
|
|
|
|||
|
|
@ -147,7 +147,9 @@ String JsonRpc::ReadMessage(const Shared<AsioTlsStream>::Ptr& stream, boost::asi
|
|||
*/
|
||||
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<Dictionary>()) {
|
||||
BOOST_THROW_EXCEPTION(std::invalid_argument("JSON-RPC"
|
||||
|
|
|
|||
|
|
@ -344,7 +344,7 @@ void JsonRpcConnection::SendCertificateRequest(const JsonRpcConnection::Ptr& acl
|
|||
|
||||
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).";
|
||||
|
||||
|
|
|
|||
|
|
@ -97,7 +97,25 @@ void JsonRpcConnection::HandleIncomingMessages(boost::asio::yield_context yc)
|
|||
// 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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -56,6 +56,9 @@ bool ModifyObjectHandler::HandleRequest(
|
|||
|
||||
try {
|
||||
objs = FilterUtility::GetFilterTargets(qd, params, user);
|
||||
} catch (const MissingPermissionError& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 403, ex.what());
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 404,
|
||||
"No objects found.",
|
||||
|
|
|
|||
|
|
@ -178,6 +178,9 @@ bool ObjectQueryHandler::HandleRequest(
|
|||
|
||||
try {
|
||||
objs = FilterUtility::GetFilterTargets(qd, params, user);
|
||||
} catch (const MissingPermissionError& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 403, ex.what());
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 404,
|
||||
"No objects found.",
|
||||
|
|
|
|||
|
|
@ -102,6 +102,9 @@ bool StatusHandler::HandleRequest(
|
|||
|
||||
try {
|
||||
objs = FilterUtility::GetFilterTargets(qd, params, user);
|
||||
} catch (const MissingPermissionError& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 403, ex.what());
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 404,
|
||||
"No objects found.",
|
||||
|
|
|
|||
|
|
@ -119,6 +119,9 @@ bool TemplateQueryHandler::HandleRequest(
|
|||
|
||||
try {
|
||||
objs = FilterUtility::GetFilterTargets(qd, params, user, "tmpl");
|
||||
} catch (const MissingPermissionError& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 403, ex.what());
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 404,
|
||||
"No templates found.",
|
||||
|
|
|
|||
|
|
@ -81,6 +81,9 @@ bool TypeQueryHandler::HandleRequest(
|
|||
|
||||
try {
|
||||
objs = FilterUtility::GetFilterTargets(qd, params, user);
|
||||
} catch (const MissingPermissionError& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 403, ex.what());
|
||||
return true;
|
||||
} catch (const std::exception& ex) {
|
||||
HttpUtility::SendJsonError(response, params, 404,
|
||||
"No objects found.",
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
// SPDX-FileCopyrightText: 2012 Icinga GmbH <https://icinga.com>
|
||||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "base/convert.hpp"
|
||||
#include "base/dictionary.hpp"
|
||||
#include "base/function.hpp"
|
||||
#include "base/namespace.hpp"
|
||||
#include "base/array.hpp"
|
||||
#include "base/generator.hpp"
|
||||
#include "base/io-engine.hpp"
|
||||
#include "base/objectlock.hpp"
|
||||
#include "base/json.hpp"
|
||||
#include "test/utils.hpp"
|
||||
#include <boost/algorithm/string/replace.hpp>
|
||||
#include <BoostTestTargetConfig.h>
|
||||
#include <limits>
|
||||
|
|
@ -151,4 +154,179 @@ 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 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.
|
||||
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(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(ExpectLimitExceeded("[[]]", 1, "root[0]"));
|
||||
BOOST_CHECK_NO_THROW(JsonDecode("[[]]", 2));
|
||||
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(ExpectLimitExceeded("[{}]", 1, "root[0]"));
|
||||
BOOST_CHECK_NO_THROW(JsonDecode("[{}]", 2));
|
||||
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.
|
||||
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);
|
||||
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);
|
||||
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.
|
||||
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"
|
||||
]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
})";
|
||||
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));
|
||||
}
|
||||
|
||||
/* 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 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).
|
||||
*/
|
||||
static void TestJsonStackSize()
|
||||
{
|
||||
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));
|
||||
}
|
||||
|
||||
BOOST_AUTO_TEST_CASE(stack_size_coroutine)
|
||||
{
|
||||
auto future = SpawnSynchronizedCoroutine([](boost::asio::yield_context) {
|
||||
TestJsonStackSize();
|
||||
});
|
||||
future.get();
|
||||
}
|
||||
|
||||
#ifdef HAVE_PTHREAD_CREATE
|
||||
BOOST_AUTO_TEST_CASE(stack_size_pthread)
|
||||
{
|
||||
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));
|
||||
|
||||
}
|
||||
#endif /* HAVE_PTHREAD_CREATE */
|
||||
|
||||
BOOST_AUTO_TEST_SUITE_END()
|
||||
|
|
|
|||
|
|
@ -15,6 +15,60 @@ BOOST_AUTO_TEST_SUITE(remote_filterutility,
|
|||
*boost::unit_test::label("config"))
|
||||
// clang-format on
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(filter_expression_permission, IcingaApplicationFixture)
|
||||
{
|
||||
auto createObjects = []() {
|
||||
String config = R"CONFIG({
|
||||
object CheckCommand "dummy" {
|
||||
command = "/bin/echo"
|
||||
}
|
||||
|
||||
object ApiUser "withFilterPermission" {
|
||||
permissions = [ "objects/query/*", "filter-expression" ]
|
||||
}
|
||||
|
||||
object ApiUser "withoutFilterPermission" {
|
||||
permissions = [ "objects/query/*" ]
|
||||
}
|
||||
|
||||
object Host "host1" {
|
||||
address = "host1"
|
||||
check_command = "dummy"
|
||||
}
|
||||
})CONFIG";
|
||||
std::unique_ptr<Expression> expr = ConfigCompiler::CompileText("<test>", config);
|
||||
expr->Evaluate(*ScriptFrame::GetCurrentFrame());
|
||||
};
|
||||
|
||||
ConfigItem::RunWithActivationContext(new Function("CreateTestObjects", createObjects));
|
||||
|
||||
auto userWithPerm = ApiUser::GetByName("withFilterPermission");
|
||||
auto userWithoutPerm = ApiUser::GetByName("withoutFilterPermission");
|
||||
|
||||
QueryDescription qd;
|
||||
qd.Types.insert("Host");
|
||||
qd.Permission = "objects/query/Host";
|
||||
|
||||
Dictionary::Ptr queryParams = new Dictionary();
|
||||
queryParams->Set("type", "Host");
|
||||
|
||||
// This is a filter that uses a get_object call on an object the permissionFilterUser
|
||||
// has access to. A second user is tested that has access to everything, to make sure
|
||||
// the filter evaluates properly in the first place.
|
||||
queryParams->Set("filter", "true");
|
||||
|
||||
std::vector<Value> objs;
|
||||
BOOST_REQUIRE_NO_THROW(objs = FilterUtility::GetFilterTargets(qd, queryParams, userWithPerm));
|
||||
BOOST_CHECK_EQUAL(objs.size(), 1);
|
||||
|
||||
BOOST_CHECK_EXCEPTION(FilterUtility::GetFilterTargets(qd, queryParams, userWithoutPerm), std::exception,
|
||||
[](const std::exception& ex) {
|
||||
boost::test_tools::assertion_result result{std::string_view(ex.what()) == "Missing permission: filter-expression"};
|
||||
result.message() << "got exception: " << ex.what();
|
||||
return result;
|
||||
});
|
||||
}
|
||||
|
||||
BOOST_FIXTURE_TEST_CASE(safe_function_permissions, IcingaApplicationFixture)
|
||||
{
|
||||
auto createObjects = []() {
|
||||
|
|
@ -29,6 +83,7 @@ object ApiUser "allPermissionsUser" {
|
|||
|
||||
object ApiUser "permissionFilterUser" {
|
||||
permissions = [
|
||||
"filter-expression",
|
||||
{
|
||||
permission = "objects/query/Host"
|
||||
filter = {{ host.name == {{{host1}}} }}
|
||||
|
|
@ -161,6 +216,7 @@ object ApiUser "allPermissionsUser" {
|
|||
|
||||
object ApiUser "permissionFilterUser" {
|
||||
permissions = [
|
||||
"filter-expression",
|
||||
"objects/query/Host",
|
||||
{
|
||||
permission = "variables"
|
||||
|
|
@ -170,7 +226,10 @@ object ApiUser "permissionFilterUser" {
|
|||
}
|
||||
|
||||
object ApiUser "noVariablePermUser" {
|
||||
permissions = [ "objects/query/Host" ]
|
||||
permissions = [
|
||||
"filter-expression",
|
||||
"objects/query/Host",
|
||||
]
|
||||
}
|
||||
|
||||
object Host "host1" {
|
||||
|
|
|
|||
|
|
@ -8,29 +8,13 @@
|
|||
#include "remote/httputility.hpp"
|
||||
#include "test/base-tlsstream-fixture.hpp"
|
||||
#include "test/test-ctest.hpp"
|
||||
#include "test/utils.hpp"
|
||||
#include <fstream>
|
||||
#include <utility>
|
||||
|
||||
using namespace icinga;
|
||||
using namespace boost::beast;
|
||||
|
||||
static std::future<void> SpawnSynchronizedCoroutine(std::function<void(boost::asio::yield_context)> fn)
|
||||
{
|
||||
auto promise = std::make_unique<std::promise<void>>();
|
||||
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_FIXTURE_TEST_SUITE(remote_httpmessage, TlsStreamFixture,
|
||||
*RequiresCertificate(TlsStreamFixture::RequiredCerts)
|
||||
*boost::unit_test::label("network")
|
||||
|
|
|
|||
|
|
@ -2,9 +2,12 @@
|
|||
// SPDX-License-Identifier: GPL-2.0-or-later
|
||||
|
||||
#include "utils.hpp"
|
||||
#include "base/io-engine.hpp"
|
||||
#include <cstring>
|
||||
#include <future>
|
||||
#include <iomanip>
|
||||
#include <sstream>
|
||||
#include <boost/asio/spawn.hpp>
|
||||
#include <boost/test/unit_test.hpp>
|
||||
|
||||
tm make_tm(std::string s)
|
||||
|
|
@ -66,3 +69,22 @@ GlobalTimezoneFixture::~GlobalTimezoneFixture()
|
|||
#endif
|
||||
tzset();
|
||||
}
|
||||
|
||||
std::future<void> SpawnSynchronizedCoroutine(std::function<void(boost::asio::yield_context)> fn)
|
||||
{
|
||||
using namespace icinga;
|
||||
|
||||
auto promise = std::make_unique<std::promise<void>>();
|
||||
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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,10 @@
|
|||
#pragma once
|
||||
|
||||
#include <ctime>
|
||||
#include <functional>
|
||||
#include <future>
|
||||
#include <string>
|
||||
#include <boost/asio/spawn.hpp>
|
||||
|
||||
tm make_tm(std::string s);
|
||||
|
||||
|
|
@ -24,3 +27,5 @@ struct GlobalTimezoneFixture
|
|||
|
||||
char *tz;
|
||||
};
|
||||
|
||||
std::future<void> SpawnSynchronizedCoroutine(std::function<void(boost::asio::yield_context)> fn);
|
||||
|
|
|
|||
Loading…
Reference in a new issue