Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
b99d99f
Initial plan
Copilot Jun 27, 2026
f34ee1f
Apply remaining changes
Copilot Jun 27, 2026
c316482
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Jun 29, 2026
27945f7
Merge main into copilot/enforce-configurable-max-transaction-size
Jul 3, 2026
d86be95
Fix CI failures: mark get_max_transaction_size [[nodiscard]], fix tra…
Jul 3, 2026
80bad36
Document ledger entry size parameter
Copilot Jul 4, 2026
c068e5f
Merge remote-tracking branch 'origin/main' into copilot/enforce-confi…
Copilot Jul 8, 2026
8f969ba
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Jul 9, 2026
82b243c
Address review feedback on max transaction size enforcement
Jul 9, 2026
6e8606b
Refine max transaction size: cap serialisation only, on whole-entry size
Jul 10, 2026
e20b462
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 12, 2026
35d80e6
Check the transaction size limit before applying the transaction
achamayou Aug 12, 2026
e1dc2e3
Avoid duplicate transaction serialisation
achamayou Aug 13, 2026
99351f5
Test encrypted transaction size boundary
achamayou Aug 13, 2026
d9856e9
Validate ledger entry and size string bounds
achamayou Aug 13, 2026
c41d26d
Use allocation-free transaction sizing
achamayou Aug 13, 2026
4c3632d
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 13, 2026
fc8cdc2
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 13, 2026
51b2a26
Update batched stress limit behavior
achamayou Aug 13, 2026
8643076
Merge remote updates
achamayou Aug 13, 2026
81f4f17
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 18, 2026
76aa8f4
Merge remote-tracking branch 'origin/main' into achamayou-max-ledger-…
achamayou Aug 18, 2026
11daadb
Use 32MB transaction limit and restore the 64MB max_msg_size default
achamayou Aug 18, 2026
7614748
Validate ledger transaction size under --check
achamayou Aug 18, 2026
3a9e18e
Describe the transaction size limit against the released behaviour
achamayou Aug 18, 2026
7eedbcb
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 19, 2026
e8a54de
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 20, 2026
3a6f90f
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 20, 2026
ea1277a
Merge branch 'main' into copilot/enforce-configurable-max-transaction…
achamayou Aug 20, 2026
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 @@ -16,6 +16,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.
### Added

- C++ endpoints can now use `ccf::endpoints::Endpoint::add_openapi_response<Out>()` to document additional HTTP responses in their generated OpenAPI schema without changing the endpoint's primary success response (#8115).
- New `ledger.max_transaction_size` node configuration option (default `32MB`), which caps the total serialised size of transactions written to the ledger. The limit covers the whole ledger entry: the fixed 8-byte ledger entry header, the ledger encryption header, public domain size field, public domain and encrypted private domain. It is checked before a transaction is applied, so an oversized transaction is now rejected with `413 Payload Too Large` and error code `TransactionTooLarge`, and subsequent transactions are unaffected, where previously an excessively large transaction could terminate the node. Reserved internal signature transactions are exempt because they must fill their reserved ledger version. The limit applies only to newly serialised non-reserved transactions; deserialising existing entries (including during recovery), historical queries and snapshots are unaffected, so entries written under a larger or unset limit remain readable. It must be smaller than `memory.max_msg_size` by at least the ring-buffer range response overhead, which is validated at node startup and by `--check` (#7992).

### Changed

Expand Down
5 changes: 5 additions & 0 deletions doc/host_config_schema/host_config.json
Original file line number Diff line number Diff line change
Expand Up @@ -477,6 +477,11 @@
"type": "string",
"default": "5MB",
"description": "Minimum size (size string) of the current ledger file after which a new ledger file (chunk) is created"
},
"max_transaction_size": {
"type": "string",
"default": "32MB",
"description": "Maximum total serialised size (size string) of a transaction written to the ledger. Reserved internal signature transactions are exempt. This covers the whole ledger entry: the fixed 8-byte ledger entry header, the ledger encryption header, public domain size field, public domain and encrypted private domain. Must be smaller than 'memory.max_msg_size', by at least the ring-buffer range response overhead, so that a single ledger entry can be read back from the host"
}
},
"description": "This section includes configuration for the ledger directories and files",
Expand Down
23 changes: 22 additions & 1 deletion include/ccf/ds/unit_strings.h
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@

#include <charconv>
#include <cmath>
#include <limits>
#include <nlohmann/json.hpp>
#include <string>

Expand Down Expand Up @@ -81,7 +82,27 @@ namespace ccf::ds

return UnitStringConverter::convert(
input, size_suffix_to_power, [](size_t value, size_t power) {
return value * std::pow(1024, power);
constexpr size_t base = 1024;
size_t factor = 1;
for (size_t i = 0; i < power; ++i)
{
if (factor > std::numeric_limits<size_t>::max() / base)
{
throw std::logic_error("Size string unit multiplier is too large");
}
factor *= base;
}

if (value > std::numeric_limits<size_t>::max() / factor)
{
throw std::logic_error(fmt::format(
"Size string value {} with multiplier {} exceeds the largest "
"representable size",
value,
factor));
}

return value * factor;
});
}

Expand Down
1 change: 1 addition & 0 deletions include/ccf/node/startup_config.h
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ namespace ccf
std::string directory = "ledger";
std::vector<std::string> read_only_directories;
ccf::ds::SizeString chunk_size = {"5MB"};
ccf::ds::SizeString max_transaction_size = {"32MB"};

bool operator==(const Ledger&) const = default;
};
Expand Down
1 change: 1 addition & 0 deletions include/ccf/odata_error.h
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ namespace ccf
ERROR(TransactionNotFound)
ERROR(TransactionCommitAttemptsExceedLimit)
ERROR(TransactionReplicationFailed)
ERROR(TransactionTooLarge)
ERROR(UnknownCertificate)
ERROR(VoteNotFound)
ERROR(VoteAlreadyExists)
Expand Down
6 changes: 5 additions & 1 deletion src/common/configuration.h
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ namespace ccf
DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::Ledger);
DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::Ledger);
DECLARE_JSON_OPTIONAL_FIELDS(
CCFConfig::Ledger, directory, read_only_directories, chunk_size);
CCFConfig::Ledger,
directory,
read_only_directories,
chunk_size,
max_transaction_size);

DECLARE_JSON_TYPE_WITH_OPTIONAL_FIELDS(CCFConfig::LedgerSignatures);
DECLARE_JSON_REQUIRED_FIELDS(CCFConfig::LedgerSignatures);
Expand Down
17 changes: 16 additions & 1 deletion src/consensus/aft/raft.h
Original file line number Diff line number Diff line change
Expand Up @@ -1307,7 +1307,22 @@ namespace aft
{
// If the current entry has already been deserialised, skip the
// payload for that entry
ledger->skip_entry(data, size);
try
{
ledger->skip_entry(data, size);
}
catch (const std::logic_error& e)
{
// This should only fail if there is malformed data.
RAFT_FAIL_FMT(
"Recv {} to {} from {} but the data is malformed: {}",
r.msg,
state->node_id,
from,
e.what());
send_append_entries_response_nack(from);
return;
}
continue;
}
}
Expand Down
37 changes: 37 additions & 0 deletions src/consensus/aft/test/enclave.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,43 @@ using namespace consensus;

using WFactory = ringbuffer::WriterFactory;

TEST_CASE("Enclave rejects malformed entries")
{
const auto check_rejected = [](const std::vector<uint8_t>& entry) {
{
const auto* data = entry.data();
auto size = entry.size();
REQUIRE_THROWS_AS(LedgerEnclave::get_entry(data, size), std::logic_error);
}

{
const auto* data = entry.data();
auto size = entry.size();
REQUIRE_THROWS_AS(
LedgerEnclave::skip_entry(data, size), std::logic_error);
}
};

SUBCASE("Truncated header")
{
check_rejected(
std::vector<uint8_t>(ccf::kv::serialised_entry_header_size - 1));
}

SUBCASE("Claimed body exceeds buffer")
{
ccf::kv::SerialisedEntryHeader header;
header.set_size(2);

std::vector<uint8_t> entry(ccf::kv::serialised_entry_header_size + 1);
auto* data = entry.data();
auto size = entry.size();
serialized::write(data, size, header);

check_rejected(entry);
}
}

TEST_CASE("Enclave put")
{
constexpr auto buffer_size = 1024;
Expand Down
47 changes: 41 additions & 6 deletions src/consensus/ledger_enclave.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,49 @@
#include "kv/kv_types.h"
#include "kv/serialised_entry_format.h"

#include <fmt/format.h>

namespace consensus
{
class LedgerEnclave
{
private:
static size_t get_entry_size(const uint8_t* data, size_t size)
{
if (size < ccf::kv::serialised_entry_header_size)
{
throw std::logic_error(fmt::format(
"Cannot read transaction header: buffer contains {} bytes, but the "
"fixed ledger entry header requires {} bytes",
size,
ccf::kv::serialised_entry_header_size));
}

const auto header =
serialized::peek<ccf::kv::SerialisedEntryHeader>(data, size);
const size_t body_size = header.size;
Comment thread
achamayou marked this conversation as resolved.
const auto available_body_size =
size - ccf::kv::serialised_entry_header_size;

// The size in the entry header is not trusted: check it against the
// buffer we were given before allocating. This is distinct from the
// configured max_transaction_size, which applies only when serialising
// new transactions, so that entries written under a larger or unset
// limit can always be read back.
if (body_size > available_body_size)
{
throw std::logic_error(fmt::format(
"Cannot read transaction with serialised body size {} bytes from "
"buffer containing {} bytes after the fixed {}-byte ledger entry "
"header",
body_size,
available_body_size,
ccf::kv::serialised_entry_header_size));
}

return ccf::kv::serialised_entry_header_size + body_size;
}

public:
/**
* Retrieve a single entry, advancing offset to the next entry.
Expand All @@ -23,9 +62,7 @@ namespace consensus
*/
static std::vector<uint8_t> get_entry(const uint8_t*& data, size_t& size)
{
auto header =
serialized::peek<ccf::kv::SerialisedEntryHeader>(data, size);
size_t entry_size = ccf::kv::serialised_entry_header_size + header.size;
const auto entry_size = get_entry_size(data, size);
std::vector<uint8_t> entry(data, data + entry_size);
serialized::skip(data, size, entry_size);
return entry;
Expand Down Expand Up @@ -89,9 +126,7 @@ namespace consensus
*/
static void skip_entry(const uint8_t*& data, size_t& size)
{
auto header =
serialized::read<ccf::kv::SerialisedEntryHeader>(data, size);
serialized::skip(data, size, header.size);
serialized::skip(data, size, get_entry_size(data, size));
}

/**
Expand Down
2 changes: 2 additions & 0 deletions src/consensus/ledger_enclave_types.h
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@

namespace consensus
{
static constexpr size_t ledger_range_response_metadata_size = 2048;

using Index = uint64_t;

enum LedgerRequestPurpose : uint8_t
Expand Down
14 changes: 14 additions & 0 deletions src/ds/test/unit_strings.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@

#include <cmath>
#include <doctest/doctest.h>
#include <limits>

using namespace ccf::ds;

Expand All @@ -29,6 +30,19 @@ TEST_CASE("Size strings" * doctest::test_suite("unit strings"))
REQUIRE(convert_size_string("3GB") == 3 * std::pow(1024, 3));
REQUIRE(convert_size_string("3TB") == 3 * std::pow(1024, 4));
REQUIRE(convert_size_string("3PB") == 3 * std::pow(1024, 5));

const auto max_size = std::numeric_limits<size_t>::max();
REQUIRE(convert_size_string(std::to_string(max_size) + "B") == max_size);

const auto max_kb_value = max_size / 1024;
REQUIRE(
convert_size_string(std::to_string(max_kb_value) + "KB") ==
max_kb_value * 1024);
REQUIRE_THROWS_AS(
convert_size_string(std::to_string(max_kb_value + 1) + "KB"),
std::logic_error);
REQUIRE_THROWS_AS(
convert_size_string(std::to_string(max_size) + "PB"), std::logic_error);
}

TEST_CASE("Time strings" * doctest::test_suite("unit strings"))
Expand Down
2 changes: 2 additions & 0 deletions src/enclave/enclave.h
Original file line number Diff line number Diff line change
Expand Up @@ -83,6 +83,7 @@ namespace ccf
size_t sig_tx_interval,
size_t sig_ms_interval,
size_t chunk_threshold,
size_t max_transaction_size,
const ccf::consensus::Configuration& consensus_config,
const ccf::crypto::CurveID& curve_id,
ccf::ds::WorkBeaconPtr work_beacon_,
Expand All @@ -101,6 +102,7 @@ namespace ccf

network.tables->set_chunker(
std::make_shared<ccf::kv::LedgerChunker>(chunk_threshold));
network.tables->set_max_transaction_size(max_transaction_size);

LOG_TRACE_FMT("Creating node");
node = std::make_unique<ccf::NodeState>(
Expand Down
1 change: 1 addition & 0 deletions src/enclave/main.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ namespace ccf
ccf_config.ledger_signatures.tx_count,
ccf_config.ledger_signatures.delay.count_ms(),
ccf_config.ledger.chunk_size,
ccf_config.ledger.max_transaction_size,
Comment thread
achamayou marked this conversation as resolved.
ccf_config.consensus,
ccf_config.node_certificate.curve_id,
work_beacon,
Expand Down
3 changes: 1 addition & 2 deletions src/host/ledger.h
Original file line number Diff line number Diff line change
Expand Up @@ -1885,9 +1885,8 @@ namespace asynchost

// Ledger entries response has metadata so cap total entries size
// accordingly
constexpr size_t write_ledger_range_response_metadata_size = 2048;
auto max_entries_size = to_enclave->get_max_message_size() -
write_ledger_range_response_metadata_size;
::consensus::ledger_range_response_metadata_size;

if (is_in_committed_file(to_idx))
{
Expand Down
36 changes: 36 additions & 0 deletions src/host/run.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@
#include "common/enclave_interface_types.h"
#include "config_schema.h"
#include "configuration.h"
#include "consensus/ledger_enclave_types.h"
#include "crypto/openssl/hash.h"
#include "ds/files.h"
#include "ds/internal_logger.h"
Expand Down Expand Up @@ -96,6 +97,28 @@ static constexpr size_t retry_interval_ms = 100;

namespace ccf
{
void validate_ledger_transaction_size(const host::HostConfig& config)
{
const auto max_message_size = config.memory.max_msg_size.count_bytes();
const auto max_transaction_size =
config.ledger.max_transaction_size.count_bytes();
const auto response_overhead =
::consensus::ledger_range_response_metadata_size;

if (
max_message_size <= response_overhead ||
max_transaction_size > max_message_size - response_overhead)
{
throw std::logic_error(fmt::format(
"ledger.max_transaction_size ({}) must be at least {} bytes smaller "
"than memory.max_msg_size ({}) so a single ledger entry fits in a "
"ring-buffer range response",
max_transaction_size,
response_overhead,
max_message_size));
}
}

void validate_and_adjust_recovery_threshold(host::HostConfig& config)
{
if (config.command.type != StartType::Start)
Expand Down Expand Up @@ -994,6 +1017,19 @@ namespace ccf
argv + argc, // NOLINT(cppcoreguidelines-pro-bounds-pointer-arithmetic)
"\" \""));

// Validated before the --check early return, so that operators verifying a
// configuration file are told about a ledger/ring-buffer size mismatch
// rather than discovering it when the node starts for real
try
{
validate_ledger_transaction_size(config);
}
catch (const std::logic_error& e)
{
LOG_FATAL_FMT("{}. Exiting.", e.what());
return static_cast<int>(CLI::ExitCodes::ValidationError);
}

if (check_config_only)
{
LOG_INFO_FMT("Configuration file successfully verified");
Expand Down
Loading