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

Merge changes from private security fork (v2.16.2)
This commit is contained in:
Julian Brost 2026-06-29 14:42:47 +02:00 committed by GitHub
commit 00cd4fd853
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
38 changed files with 553 additions and 77 deletions

View file

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

View file

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

View file

@ -1,2 +1,2 @@
Version: 2.16.1
Version: 2.16.2
Revision: 1

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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).";

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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