Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.

- Upgraded QuickJS from 2024-01-13 to 2025-09-13 (#7849).
- On a joiner's first attempt, the primary now requires the joiner's startup seqno to be at least as recent as the primary's latest committed snapshot on disk, preventing snapshot-less joiners from replaying the entire ledger (#7844).
- JSON parsing now can reject inputs whose object/array nesting depth exceeds a certain value, defaulting to 64 levels and overridable per call site via `ccf::parse_json_safe`'s `max_depth` parameter (#7896).

### Fixed

Expand Down
5 changes: 5 additions & 0 deletions CMakeLists.txt
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,11 @@ if(BUILD_TESTS)
${CMAKE_CURRENT_SOURCE_DIR}/src/ds/test/json_schema.cpp
)

add_unit_test(
parse_json_safe_test
${CMAKE_CURRENT_SOURCE_DIR}/src/ds/test/parse_json_safe.cpp
)

add_unit_test(
logger_test
${CMAKE_CURRENT_SOURCE_DIR}/src/ds/test/logger.cpp
Expand Down
49 changes: 49 additions & 0 deletions include/ccf/ds/json.h
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,55 @@ namespace ccf
return fmt::format("At {}: {}", pointer(), what());
}
};

inline constexpr size_t MAX_JSON_NESTING_DEPTH = 64;

class JsonTooDeep : public ccf::JsonParseError
{
public:
explicit JsonTooDeep(size_t max_depth) :
ccf::JsonParseError(fmt::format(
"JSON object/array nesting exceeds maximum depth of {}", max_depth))
{}
};

inline nlohmann::json::parser_callback_t make_depth_limit_callback(
size_t max_depth)
{
return [max_depth](
int depth,
nlohmann::json::parse_event_t event,
nlohmann::json& /*parsed*/) {
using E = nlohmann::json::parse_event_t;
if (
(event == E::object_start || event == E::array_start) &&
static_cast<size_t>(depth) >= max_depth)
{
throw JsonTooDeep{max_depth};
}
return true;
};
}

// Depth-bounded alternative to nlohmann::json::parse, for inputs whose
// depth has not been validated upstream. max_depth defaults to
// MAX_JSON_NESTING_DEPTH but can be overridden at the call site for
// applications with stricter or more permissive requirements.
template <typename Bytes>
nlohmann::json parse_json_safe(
Bytes&& bytes, size_t max_depth = MAX_JSON_NESTING_DEPTH)
{
return nlohmann::json::parse(
std::forward<Bytes>(bytes), make_depth_limit_callback(max_depth));
}

template <typename Iter>
nlohmann::json parse_json_safe(
Iter first, Iter last, size_t max_depth = MAX_JSON_NESTING_DEPTH)
{
return nlohmann::json::parse(
first, last, make_depth_limit_callback(max_depth));
}
}

// NOLINTBEGIN(cert-dcl58-cpp)
Expand Down
2 changes: 1 addition & 1 deletion include/ccf/json_handler.h
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ namespace ccf
* nlohmann::json params;
* if (<content-type is JSON>)
* {
* params = nlohmann::json::parse(ctx.rpc_ctx->get_request_body());
* params = ccf::parse_json_safe(ctx.rpc_ctx->get_request_body());
* }
* else
* {
Expand Down
3 changes: 2 additions & 1 deletion include/ccf/kv/serialisers/json_serialiser.h
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache 2.0 License.
#pragma once

#include "ccf/ds/json.h"
#include "ccf/kv/serialisers/serialised_entry.h"

#include <nlohmann/json.hpp>
Expand Down Expand Up @@ -29,7 +30,7 @@ namespace ccf::kv::serialisers

static T from_serialised(const SerialisedEntry& rep)
{
const auto j = nlohmann::json::parse(rep.begin(), rep.end());
const auto j = ccf::parse_json_safe(rep.begin(), rep.end());
return j.get<T>();
}
};
Expand Down
2 changes: 1 addition & 1 deletion samples/apps/basic/basic.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ namespace basicapp

auto post = [](ccf::endpoints::EndpointContext& ctx) {
const nlohmann::json body =
nlohmann::json::parse(ctx.rpc_ctx->get_request_body());
ccf::parse_json_safe(ctx.rpc_ctx->get_request_body());

const auto records = body.get<std::map<std::string, std::string>>();

Expand Down
2 changes: 1 addition & 1 deletion samples/apps/logging/logging.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -1281,7 +1281,7 @@ namespace loggingapp
ctx.template get_caller<ccf::UserCertAuthnIdentity>();

const nlohmann::json body_j =
nlohmann::json::parse(ctx.rpc_ctx->get_request_body());
ccf::parse_json_safe(ctx.rpc_ctx->get_request_body());

const auto in = body_j.get<LoggingRecord::In>();
if (in.msg.empty())
Expand Down
6 changes: 3 additions & 3 deletions samples/apps/programmability/programmability.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -351,7 +351,7 @@ namespace programmabilityapp

auto post = [](ccf::endpoints::EndpointContext& ctx) {
const nlohmann::json body =
nlohmann::json::parse(ctx.rpc_ctx->get_request_body());
ccf::parse_json_safe(ctx.rpc_ctx->get_request_body());

const auto records = body.get<std::map<std::string, std::string>>();

Expand Down Expand Up @@ -439,7 +439,7 @@ namespace programmabilityapp

const auto [format, content, created_at] = get_action_content(ctx);
const auto parsed_content =
nlohmann::json::parse(content.begin(), content.end());
ccf::parse_json_safe(content.begin(), content.end());
const auto parsed_bundle = parsed_content.get<ccf::js::Bundle>();

// Make operation auditable
Expand Down Expand Up @@ -627,7 +627,7 @@ namespace programmabilityapp
const auto [format, content, created_at] = get_action_content(ctx);
// - Parse content as JSON options
const auto arg_content =
nlohmann::json::parse(content.begin(), content.end());
ccf::parse_json_safe(content.begin(), content.end());

// - Merge, to overwrite current options with anything from body. Note
// that nulls mean deletions, which results in resetting to a default
Expand Down
186 changes: 186 additions & 0 deletions src/ds/test/parse_json_safe.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,186 @@
// Copyright (c) Microsoft Corporation. All rights reserved.
// Licensed under the Apache 2.0 License.
//
// Tests for ccf::parse_json_safe - the depth-bounded JSON parse chokepoint.
// parse_json_safe wraps nlohmann::json::parse with the library's
// parser_callback_t and aborts the parse before any DOM node is materialised
// once an object_start or array_start is reached at the configured maximum
// depth (default: ccf::MAX_JSON_NESTING_DEPTH, overridable per call site).

#define DOCTEST_CONFIG_IMPLEMENT_WITH_MAIN
#include "ccf/ds/json.h"

#include <doctest/doctest.h>
#include <string>

namespace
{
// Builds {"a":{"a":...{"a":null}...}} of the requested nesting depth.
// Examples: nest_obj(1) -> {"a":null}
// nest_obj(3) -> {"a":{"a":{"a":null}}}
std::string nest_obj(size_t depth)
{
std::string s;
s.reserve(depth * 6 + 4);
for (size_t i = 0; i < depth; ++i)
{
s.append("{\"a\":");
}
s.append("null");
for (size_t i = 0; i < depth; ++i)
{
s.push_back('}');
}
return s;
}

// Builds [[...[null]...]] of the requested nesting depth.
// Examples: nest_arr(1) -> [null]
// nest_arr(3) -> [[[null]]]
std::string nest_arr(size_t depth)
{
std::string s;
s.reserve(depth * 2 + 4);
for (size_t i = 0; i < depth; ++i)
{
s.push_back('[');
}
s.append("null");
for (size_t i = 0; i < depth; ++i)
{
s.push_back(']');
}
return s;
}

// Far above any plausible legitimate payload.
constexpr size_t kAttackDepth = 200'000;
constexpr size_t kAtLimit = ccf::MAX_JSON_NESTING_DEPTH;
}

TEST_CASE("parse_json_safe rejects deeply nested objects far past the limit")
{
// kAttackDepth (200 000) is well past MAX_JSON_NESTING_DEPTH and so must
// be rejected long before any DOM is built.
const auto body = nest_obj(kAttackDepth);
CHECK_THROWS_AS(ccf::parse_json_safe(body), ccf::JsonTooDeep);
}

TEST_CASE("parse_json_safe rejects deeply nested arrays far past the limit")
{
const auto body = nest_arr(kAttackDepth);
CHECK_THROWS_AS(ccf::parse_json_safe(body), ccf::JsonTooDeep);
}

TEST_CASE("parse_json_safe rejects objects one level above the limit")
{
// kAtLimit + 1 is the minimal rejection case: proves the boundary is
// strictly "<= MAX_JSON_NESTING_DEPTH accepted, > rejected".
const auto body = nest_obj(kAtLimit + 1);
CHECK_THROWS_AS(ccf::parse_json_safe(body), ccf::JsonTooDeep);
}

TEST_CASE("parse_json_safe rejects arrays one level above the limit")
{
const auto body = nest_arr(kAtLimit + 1);
CHECK_THROWS_AS(ccf::parse_json_safe(body), ccf::JsonTooDeep);
}

TEST_CASE("parse_json_safe accepts objects exactly at the limit")
{
// Exactly MAX_JSON_NESTING_DEPTH must round-trip cleanly: the bound
// is inclusive so the maximum legitimate payload is not collateral damage.
const auto body = nest_obj(kAtLimit);
nlohmann::json j;
CHECK_NOTHROW(j = ccf::parse_json_safe(body));
CHECK(j.is_object());
}

TEST_CASE("parse_json_safe accepts arrays exactly at the limit")
{
const auto body = nest_arr(kAtLimit);
nlohmann::json j;
CHECK_NOTHROW(j = ccf::parse_json_safe(body));
CHECK(j.is_array());
}

TEST_CASE("parse_json_safe leaves shallow well-formed input unchanged")
{
const auto body = std::string(R"({"k":[1,2,3],"v":{"x":true}})");
nlohmann::json j;
CHECK_NOTHROW(j = ccf::parse_json_safe(body));
CHECK(j["k"].size() == 3);
CHECK(j["v"]["x"] == true);
}

TEST_CASE("parse_json_safe propagates ordinary syntax errors as parse_error")
{
// The callback only adds a depth check; ordinary parse errors still
// surface as nlohmann::json::parse_error, NOT as JsonTooDeep.
const auto body = std::string("{not json");
CHECK_THROWS_AS(ccf::parse_json_safe(body), nlohmann::json::parse_error);
}

TEST_CASE("parse_json_safe iterator overload enforces the same limit")
{
const auto body = nest_obj(kAttackDepth);
CHECK_THROWS_AS(
ccf::parse_json_safe(body.begin(), body.end()), ccf::JsonTooDeep);

const auto shallow = std::string(R"({"a":1})");
nlohmann::json j;
CHECK_NOTHROW(j = ccf::parse_json_safe(shallow.begin(), shallow.end()));
CHECK(j["a"] == 1);
}

TEST_CASE("JsonTooDeep is catchable as ccf::JsonParseError")
{
// Frontend top-level catch in src/node/rpc/frontend.h relies on this
// hierarchy to convert the failure into HTTP 400 InvalidInput.
const auto body = nest_obj(kAttackDepth);
CHECK_THROWS_AS(ccf::parse_json_safe(body), ccf::JsonParseError);
}

TEST_CASE("parse_json_safe honours a caller-supplied max_depth override")
{
// A caller-supplied limit must take precedence over the default, both for
// tightening (reject something the default would accept) and loosening
// (accept something the default would reject) the bound.
constexpr size_t kCustomLimit = 8;

// Tighten: depth above kCustomLimit but well within the default must now
// be rejected when the caller passes kCustomLimit.
const auto tight_reject = nest_obj(kCustomLimit + 1);
CHECK_THROWS_AS(
ccf::parse_json_safe(tight_reject, kCustomLimit), ccf::JsonTooDeep);

// Tighten boundary: exactly kCustomLimit must still be accepted when the
// caller passes kCustomLimit, mirroring the inclusive default boundary.
const auto tight_accept = nest_obj(kCustomLimit);
nlohmann::json j;
CHECK_NOTHROW(j = ccf::parse_json_safe(tight_accept, kCustomLimit));
CHECK(j.is_object());

// Loosen: depth above the default but within an explicit larger limit must
// be accepted, proving the override is not just an upper bound on the
// default.
constexpr size_t kLooseLimit = ccf::MAX_JSON_NESTING_DEPTH + 16;
const auto loose_accept = nest_obj(ccf::MAX_JSON_NESTING_DEPTH + 8);
CHECK_NOTHROW(j = ccf::parse_json_safe(loose_accept, kLooseLimit));
CHECK(j.is_object());
}

TEST_CASE("parse_json_safe iterator overload honours max_depth override")
{
constexpr size_t kCustomLimit = 4;
const auto body = nest_arr(kCustomLimit + 1);
CHECK_THROWS_AS(
ccf::parse_json_safe(body.begin(), body.end(), kCustomLimit),
ccf::JsonTooDeep);

const auto shallow = nest_arr(kCustomLimit);
nlohmann::json j;
CHECK_NOTHROW(
j = ccf::parse_json_safe(shallow.begin(), shallow.end(), kCustomLimit));
CHECK(j.is_array());
}
3 changes: 2 additions & 1 deletion src/endpoints/json_handler.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
// Licensed under the Apache 2.0 License.
#include "ccf/json_handler.h"

#include "ccf/ds/json.h"
#include "ccf/http_accept.h"
#include "ccf/http_consts.h"
#include "ccf/odata_error.h"
Expand All @@ -24,7 +25,7 @@ namespace ccf
// Body of GET is ignored
&& ctx->get_request_verb() != HTTP_GET)
{
params = nlohmann::json::parse(ctx->get_request_body());
params = ccf::parse_json_safe(ctx->get_request_body());
}
else
{
Expand Down
12 changes: 10 additions & 2 deletions src/http/http_jwt.h
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

#include "ccf/crypto/base64.h"
#include "ccf/crypto/verifier.h"
#include "ccf/ds/json.h"
#include "ccf/http_consts.h"
#include "http_parser.h"

Expand Down Expand Up @@ -152,8 +153,15 @@ namespace http
nlohmann::json payload;
try
{
header = nlohmann::json::parse(header_raw);
payload = nlohmann::json::parse(payload_raw);
header = ccf::parse_json_safe(header_raw);
payload = ccf::parse_json_safe(payload_raw);
}
catch (const ccf::JsonParseError& e)
{
error_reason = fmt::format(
"JWT header or payload exceeds permitted JSON nesting depth: {}",
e.what());
return std::nullopt;
}
catch (const nlohmann::json::parse_error& e)
{
Expand Down
3 changes: 2 additions & 1 deletion src/js/extensions/ccf/crypto.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
#include "ccf/crypto/rsa_key_pair.h"
#include "ccf/crypto/sha256.h"
#include "ccf/crypto/verifier.h"
#include "ccf/ds/json.h"
#include "ccf/js/core/context.h"
#include "ds/internal_logger.h"
#include "js/checks.h"
Expand Down Expand Up @@ -502,7 +503,7 @@ namespace ccf::js::extensions

try
{
T jwk = nlohmann::json::parse(jwk_str.value());
T jwk = ccf::parse_json_safe(jwk_str.value());

if constexpr (std::is_same_v<T, ccf::crypto::JsonWebKeyECPublic>)
{
Expand Down
Loading