From b99d99f875aa8d91a2aba4fa333ffb9342488c83 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 06:59:06 +0000 Subject: [PATCH 1/6] Initial plan From f34ee1f367f49842d2c1e15875e5d80a31e500a0 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 27 Jun 2026 07:30:17 +0000 Subject: [PATCH 2/6] Apply remaining changes Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- CHANGELOG.md | 1 + doc/host_config_schema/host_config.json | 5 ++ include/ccf/node/startup_config.h | 1 + src/common/configuration.h | 6 ++- src/consensus/ledger_enclave.h | 26 +++++++++- src/enclave/enclave.h | 2 + src/enclave/main.cpp | 1 + src/kv/committable_tx.h | 15 +++++- src/kv/generic_serialise_wrapper.h | 22 +++++++-- src/kv/kv_types.h | 12 ++++- src/kv/serialised_entry_format.h | 27 +++++++++-- src/kv/snapshot.h | 6 ++- src/kv/store.h | 41 ++++++++++++++-- src/kv/test/kv_serialisation.cpp | 64 +++++++++++++++++++++++++ src/node/node_state.h | 8 +++- src/node/rpc/frontend.h | 10 ++++ tests/config.jinja | 3 +- tests/infra/e2e_args.py | 6 +++ tests/infra/network.py | 1 + tests/limits.py | 36 ++++++++++++++ 20 files changed, 272 insertions(+), 21 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5e2ef85f32c8..5821c85c808d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1278,6 +1278,7 @@ For more information, see [our documentation](https://microsoft.github.io/CCF/ma #### Configuration - The `cchost` configuration file now includes an `idle_connection_timeout` option. This controls how long the node will keep idle connections (for user TLS sessions) before automatically closing them. This may be set to `null` to restore the previous behaviour, where idle connections are never closed. By default connections will be closed after 60s of idle time. +- The `ledger.max_transaction_size` configuration option now limits the serialized transaction body size stored in each ledger entry. The limit excludes the fixed 8-byte ledger entry header and includes the ledger encryption header, public domain size field, public domain and encrypted private domain. The default value is `100MB`. (#7488) - A soft size limit can now be set for the historical store cache in the node configuration: [`historical_cache_soft_limit`](https://microsoft.github.io/CCF/main/operations/generated_config.html#historical-cache-soft-limit). The default value is `512Mb`. - Path to the enclave file should now be passed as `--enclave-file` CLI argument to `cchost`, rather than `enclave.file` entry within configuration file. - SNP collateral must now be provided through the `snp_security_policy_file`, `snp_uvm_endorsements_file` and `snp_endorsements_servers` configuration values. See [documentation](https://microsoft.github.io/CCF/main/operations/platforms/snp.html) for details and platform-specific configuration samples. diff --git a/doc/host_config_schema/host_config.json b/doc/host_config_schema/host_config.json index 30a48cdeaf69..a70247a41e57 100644 --- a/doc/host_config_schema/host_config.json +++ b/doc/host_config_schema/host_config.json @@ -476,6 +476,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": "100MB", + "description": "Maximum serialised transaction body size (size string). This is compared with the size stored in the ledger entry header, so it excludes the fixed 8-byte ledger entry header and includes the ledger encryption header, public domain size field, public domain and encrypted private domain." } }, "description": "This section includes configuration for the ledger directories and files", diff --git a/include/ccf/node/startup_config.h b/include/ccf/node/startup_config.h index 457b418947eb..84c4382dc331 100644 --- a/include/ccf/node/startup_config.h +++ b/include/ccf/node/startup_config.h @@ -48,6 +48,7 @@ namespace ccf std::string directory = "ledger"; std::vector read_only_directories; ccf::ds::SizeString chunk_size = {"5MB"}; + ccf::ds::SizeString max_transaction_size = {"100MB"}; bool operator==(const Ledger&) const = default; }; diff --git a/src/common/configuration.h b/src/common/configuration.h index 29dcc2e08aca..f09e5eaf079d 100644 --- a/src/common/configuration.h +++ b/src/common/configuration.h @@ -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); diff --git a/src/consensus/ledger_enclave.h b/src/consensus/ledger_enclave.h index 15a1a6df6e0c..b7119739d273 100644 --- a/src/consensus/ledger_enclave.h +++ b/src/consensus/ledger_enclave.h @@ -8,6 +8,8 @@ #include "kv/kv_types.h" #include "kv/serialised_entry_format.h" +#include + namespace consensus { class LedgerEnclave @@ -21,11 +23,31 @@ namespace consensus * * @return Raw entry as a vector */ - static std::vector get_entry(const uint8_t*& data, size_t& size) + static std::vector get_entry( + const uint8_t*& data, + size_t& size, + size_t max_transaction_size = + ccf::kv::SerialisedEntryHeader::max_serialised_entry_body_size) { auto header = serialized::peek(data, size); - size_t entry_size = ccf::kv::serialised_entry_header_size + header.size; + const size_t body_size = header.size; + if (body_size > max_transaction_size) + { + throw std::logic_error(ccf::kv::describe_serialized_entry_size_error( + body_size, max_transaction_size, "extract from ledger")); + } + if (body_size + ccf::kv::serialised_entry_header_size > 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, + size - ccf::kv::serialised_entry_header_size, + ccf::kv::serialised_entry_header_size)); + } + size_t entry_size = ccf::kv::serialised_entry_header_size + body_size; std::vector entry(data, data + entry_size); serialized::skip(data, size, entry_size); return entry; diff --git a/src/enclave/enclave.h b/src/enclave/enclave.h index 074aa1eab0a3..c83a94df0d73 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -85,6 +85,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_, @@ -103,6 +104,7 @@ namespace ccf network.tables->set_chunker( std::make_shared(chunk_threshold)); + network.tables->set_max_transaction_size(max_transaction_size); LOG_TRACE_FMT("Creating node"); node = std::make_unique( diff --git a/src/enclave/main.cpp b/src/enclave/main.cpp index fe1b11530989..b829d915e005 100644 --- a/src/enclave/main.cpp +++ b/src/enclave/main.cpp @@ -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, ccf_config.consensus, ccf_config.node_certificate.curve_id, work_beacon, diff --git a/src/kv/committable_tx.h b/src/kv/committable_tx.h index 32ecb0d7893e..760383c98366 100644 --- a/src/kv/committable_tx.h +++ b/src/kv/committable_tx.h @@ -91,7 +91,9 @@ namespace ccf::kv entry_type, entry_flags, tx_commit_evidence_digest, - claims_digest_); + claims_digest_, + false /* historical_hint */, + pimpl->store->get_max_transaction_size()); // Process in security domain order for (auto domain : {SecurityDomain::PUBLIC, SecurityDomain::PRIVATE}) @@ -161,6 +163,11 @@ namespace ccf::kv ccf::kv::ConsensusHookPtrs hooks; std::optional new_maps_conflict_version = std::nullopt; + // If serialisation later rejects this transaction because it exceeds the + // configured size limit, roll the store back to the state from before + // apply_changes mutates maps and advances the version. + const auto [rollback_txid, rollback_term] = + pimpl->store->current_txid_and_commit_term(); bool track_deletes_on_missing_keys = false; auto c = apply_changes( @@ -246,6 +253,12 @@ namespace ccf::kv std::move(hooks)), false); } + catch (const MaxTransactionSizeExceeded&) + { + pimpl->store->rollback(rollback_txid, rollback_term); + committed = false; + throw; + } catch (const std::exception& e) { committed = false; diff --git a/src/kv/generic_serialise_wrapper.h b/src/kv/generic_serialise_wrapper.h index 160daa1c8dd8..9d028818fcbf 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -26,6 +26,7 @@ namespace ccf::kv TxID tx_id; EntryType entry_type; SerialisedEntryFlags header_flags; + size_t max_transaction_size; std::shared_ptr crypto_util; @@ -70,10 +71,13 @@ namespace ccf::kv // in regular transactions, but absent in snapshots. const ccf::crypto::Sha256Hash& commit_evidence_digest_ = {}, const ccf::ClaimsDigest& claims_digest_ = ccf::no_claims(), - bool historical_hint_ = false) : + bool historical_hint_ = false, + size_t max_transaction_size_ = + SerialisedEntryHeader::max_serialised_entry_body_size) : tx_id(tx_id_), entry_type(entry_type_), header_flags(header_flags_), + max_transaction_size(max_transaction_size_), crypto_util(std::move(e)), historical_hint(historical_hint_) { @@ -175,6 +179,11 @@ namespace ccf::kv size_ += crypto_util->get_header_length() + sizeof(size_t) + serialised_private_domain.size(); } + if (size_ > max_transaction_size) + { + throw MaxTransactionSizeExceeded(describe_serialized_entry_size_error( + size_, max_transaction_size, "serialise")); + } entry_header.set_size(size_); size_ += sizeof(SerialisedEntryHeader); @@ -304,7 +313,9 @@ namespace ccf::kv size_t size, ccf::kv::Term& term, EntryFlags& flags, - bool historical_hint = false) + bool historical_hint = false, + size_t max_transaction_size = + SerialisedEntryHeader::max_serialised_entry_body_size) { current_reader = &public_reader; const auto* data_ = data; @@ -313,6 +324,12 @@ namespace ccf::kv const auto tx_header = serialized::read(data_, size_); + if (tx_header.size > max_transaction_size) + { + throw MaxTransactionSizeExceeded(describe_serialized_entry_size_error( + tx_header.size, max_transaction_size, "deserialise")); + } + flags = static_cast(tx_header.flags); if (tx_header.size != size_) @@ -322,7 +339,6 @@ namespace ccf::kv tx_header.size, size_)); } - const auto* gcm_hdr_data = data_; switch (tx_header.version) diff --git a/src/kv/kv_types.h b/src/kv/kv_types.h index 18ed95c29afe..c0c5d3d1bcb3 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -28,6 +28,7 @@ #include #include #include +#include #include #include #include @@ -328,6 +329,13 @@ namespace ccf::kv } }; + class MaxTransactionSizeExceeded : public std::logic_error + { + public: + MaxTransactionSizeExceeded(const std::string& msg) : std::logic_error(msg) + {} + }; + class TxHistory { public: @@ -637,7 +645,8 @@ namespace ccf::kv virtual ~AbstractSnapshot() = default; [[nodiscard]] virtual Version get_version() const = 0; virtual std::vector serialise( - const std::shared_ptr& encryptor) = 0; + const std::shared_ptr& encryptor, + size_t max_transaction_size) = 0; }; virtual ~AbstractStore() = default; @@ -667,6 +676,7 @@ namespace ccf::kv virtual std::shared_ptr get_history() = 0; virtual std::shared_ptr get_chunker() = 0; virtual EncryptorPtr get_encryptor() = 0; + virtual size_t get_max_transaction_size() const = 0; virtual std::unique_ptr deserialize( const std::vector& data, bool public_only = false, diff --git a/src/kv/serialised_entry_format.h b/src/kv/serialised_entry_format.h index 46e0650fd9cf..a31b7a23bbd2 100644 --- a/src/kv/serialised_entry_format.h +++ b/src/kv/serialised_entry_format.h @@ -4,7 +4,10 @@ #include "ds/ccf_assert.h" +#include +#include #include +#include namespace ccf::kv { @@ -25,17 +28,17 @@ namespace ccf::kv static constexpr auto BITS_FOR_SIZE = (sizeof(uint64_t) - sizeof(uint8_t) - sizeof(SerialisedEntryFlags)) * CHAR_BIT; + static constexpr uint64_t max_serialised_entry_body_size = + (uint64_t{1} << BITS_FOR_SIZE) - 1; uint64_t size : BITS_FOR_SIZE = 0; void set_size(uint64_t size_) { - [[maybe_unused]] static constexpr size_t max_entry_size = 1UL - << BITS_FOR_SIZE; CCF_ASSERT_FMT( - size_ < max_entry_size, + size_ <= max_serialised_entry_body_size, "Cannot serialise entry of size {} (max allowed size is {})", size_, - max_entry_size); + max_serialised_entry_body_size); size = size_; } }; @@ -43,4 +46,20 @@ namespace ccf::kv static constexpr size_t serialised_entry_header_size = sizeof(SerialisedEntryHeader); + + static inline std::string describe_serialized_entry_size_error( + size_t body_size, size_t max_body_size, const char* operation) + { + return fmt::format( + "Cannot {} transaction with serialised body size {} bytes. The " + "configured maximum is {} bytes. The transaction size compared to this " + "limit is the size stored in the ledger entry header: the serialised " + "transaction body after the fixed {}-byte ledger entry header, " + "including any ledger encryption header, public domain size field, " + "public domain and encrypted private domain.", + operation, + body_size, + max_body_size, + serialised_entry_header_size); + } } \ No newline at end of file diff --git a/src/kv/snapshot.h b/src/kv/snapshot.h index ebe7f759de26..eb228983e5ba 100644 --- a/src/kv/snapshot.h +++ b/src/kv/snapshot.h @@ -40,7 +40,8 @@ namespace ccf::kv } std::vector serialise( - const std::shared_ptr& encryptor) override + const std::shared_ptr& encryptor, + size_t max_transaction_size) override { // Set the execution dependency for the snapshot to be the version // previous to said snapshot to ensure that the correct snapshot is @@ -58,7 +59,8 @@ namespace ccf::kv 0, {}, ccf::no_claims(), - true /* historical_hint */); + true /* historical_hint */, + max_transaction_size); if (hash_at_snapshot.has_value()) { diff --git a/src/kv/store.h b/src/kv/store.h index 3bfd840829de..eeeb2ba4f3a2 100644 --- a/src/kv/store.h +++ b/src/kv/store.h @@ -15,6 +15,7 @@ #include "kv_types.h" #define FMT_HEADER_ONLY +#include #include #include #include @@ -97,6 +98,8 @@ namespace ccf::kv std::shared_ptr chunker = nullptr; EncryptorPtr encryptor = nullptr; SnapshotterPtr snapshotter = nullptr; + size_t max_transaction_size = + SerialisedEntryHeader::max_serialised_entry_body_size; // Generally we will only accept deserialised views if they are contiguous - // at Version N we reject everything but N+1. The exception is when a Store @@ -223,6 +226,30 @@ namespace ccf::kv return encryptor; } + void set_max_transaction_size(size_t max_transaction_size_) + { + static const auto max_allocatable_body = + std::vector().max_size() - serialised_entry_header_size; + const auto effective_max = std::min( + static_cast( + SerialisedEntryHeader::max_serialised_entry_body_size), + max_allocatable_body); + if (max_transaction_size_ > effective_max) + { + throw std::logic_error(fmt::format( + "Configured maximum transaction size {} exceeds the largest " + "supported serialised transaction body size {}", + max_transaction_size_, + effective_max)); + } + max_transaction_size = max_transaction_size_; + } + + size_t get_max_transaction_size() const override + { + return max_transaction_size; + } + void set_snapshotter(const SnapshotterPtr& snapshotter_) { snapshotter = snapshotter_; @@ -387,7 +414,7 @@ namespace ccf::kv std::unique_ptr snapshot) override { auto e = get_encryptor(); - return snapshot->serialise(e); + return snapshot->serialise(e, max_transaction_size); } ApplyResult deserialise_snapshot( @@ -405,7 +432,8 @@ namespace ccf::kv ccf::kv::Term term = 0; ccf::kv::EntryFlags entry_flags = {}; - auto v_ = d.init(data, size, term, entry_flags, is_historical); + auto v_ = d.init( + data, size, term, entry_flags, is_historical, max_transaction_size); if (!v_.has_value()) { LOG_FAIL_FMT("Initialisation of deserialise object failed"); @@ -732,8 +760,13 @@ namespace ccf::kv public_only ? ccf::kv::SecurityDomain::PUBLIC : std::optional()); - auto v_ = - d.init(data.data(), data.size(), view, entry_flags, is_historical); + auto v_ = d.init( + data.data(), + data.size(), + view, + entry_flags, + is_historical, + max_transaction_size); if (!v_.has_value()) { LOG_FAIL_FMT("Initialisation of deserialise object failed"); diff --git a/src/kv/test/kv_serialisation.cpp b/src/kv/test/kv_serialisation.cpp index 92078bc24784..e5358e59ede9 100644 --- a/src/kv/test/kv_serialisation.cpp +++ b/src/kv/test/kv_serialisation.cpp @@ -174,6 +174,70 @@ TEST_CASE( } } +TEST_CASE( + "Reject transactions exceeding configured serialised size" * + doctest::test_suite("serialisation")) +{ + auto consensus = std::make_shared(); + auto encryptor = std::make_shared(); + + ccf::kv::Store kv_store; + kv_store.set_consensus(consensus); + kv_store.set_encryptor(encryptor); + kv_store.set_max_transaction_size(1024); + + MapTypes::StringString map("public:pub_map"); + + { + auto tx = kv_store.create_tx(); + auto handle = tx.rw(map); + handle->put("oversized", std::string(2048, 'A')); + REQUIRE_THROWS_AS(tx.commit(), ccf::kv::MaxTransactionSizeExceeded); + REQUIRE(kv_store.current_version() == 0); + REQUIRE(!consensus->get_latest_data().has_value()); + } + + { + auto tx = kv_store.create_tx(); + auto handle = tx.rw(map); + handle->put("small", "ok"); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + REQUIRE(kv_store.current_version() == 1); + } +} + +TEST_CASE( + "Reject deserialised transactions exceeding configured serialised size" * + doctest::test_suite("serialisation")) +{ + auto consensus = std::make_shared(); + auto encryptor = std::make_shared(); + + ccf::kv::Store kv_store; + kv_store.set_consensus(consensus); + kv_store.set_encryptor(encryptor); + + MapTypes::StringString map("public:pub_map"); + + { + auto tx = kv_store.create_tx(); + auto handle = tx.rw(map); + handle->put("small", "ok"); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + } + + const auto latest_data = consensus->get_latest_data(); + REQUIRE(latest_data.has_value()); + + ccf::kv::Store kv_store_target; + kv_store_target.set_encryptor(encryptor); + kv_store_target.set_max_transaction_size(1); + + REQUIRE_THROWS_AS( + kv_store_target.deserialize(latest_data.value())->apply(), + ccf::kv::MaxTransactionSizeExceeded); +} + TEST_CASE( "Serialise/deserialise private map and public maps" * doctest::test_suite("serialisation")) diff --git a/src/node/node_state.h b/src/node/node_state.h index 8e66c00b159b..aae66f6c623f 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -1474,7 +1474,8 @@ namespace ccf while (size > 0) { - auto entry = ::consensus::LedgerEnclave::get_entry(data, size); + auto entry = ::consensus::LedgerEnclave::get_entry( + data, size, network.tables->get_max_transaction_size()); LOG_INFO_FMT( "Deserialising public ledger entry #{} [{} bytes]", @@ -1763,7 +1764,8 @@ namespace ccf while (size > 0) { - auto entry = ::consensus::LedgerEnclave::get_entry(data, size); + auto entry = ::consensus::LedgerEnclave::get_entry( + data, size, network.tables->get_max_transaction_size()); LOG_INFO_FMT( "Deserialising private ledger entry {} [{}]", @@ -1978,6 +1980,8 @@ namespace ccf recovery_store = std::make_shared( true /* Check transactions in order */, true /* Make use of historical secrets */); + recovery_store->set_max_transaction_size( + network.tables->get_max_transaction_size()); auto recovery_history = std::make_shared( *recovery_store, self, diff --git a/src/node/rpc/frontend.h b/src/node/rpc/frontend.h index 96891f09e0af..975b93780277 100644 --- a/src/node/rpc/frontend.h +++ b/src/node/rpc/frontend.h @@ -949,6 +949,16 @@ namespace ccf return; } + catch (const ccf::kv::MaxTransactionSizeExceeded& e) + { + ctx->clear_response_headers(); + ctx->set_error( + HTTP_STATUS_PAYLOAD_TOO_LARGE, + ccf::errors::RequestBodyTooLarge, + e.what()); + + return; + } catch (const ccf::kv::KvSerialiserException& e) { // If serialising the committed transaction fails, there is no way diff --git a/tests/config.jinja b/tests/config.jinja index d4a232fe0cec..bfef039cfe27 100644 --- a/tests/config.jinja +++ b/tests/config.jinja @@ -57,7 +57,8 @@ { "directory": "{{ ledger_dir }}", "read_only_directories": {{ read_only_ledger_dirs|tojson }}, - "chunk_size": "{{ ledger_chunk_bytes }}" + "chunk_size": "{{ ledger_chunk_bytes }}", + "max_transaction_size": "{{ ledger_max_transaction_bytes }}" }, "snapshots": { diff --git a/tests/infra/e2e_args.py b/tests/infra/e2e_args.py index 9a135f836fe2..3f180ad78b14 100644 --- a/tests/infra/e2e_args.py +++ b/tests/infra/e2e_args.py @@ -252,6 +252,12 @@ def cli_args( type=str, default=ledger_chunk_bytes_override or "20KB", ) + parser.add_argument( + "--ledger-max-transaction-bytes", + help="Maximum serialised transaction body size (bytes)", + type=str, + default="100MB", + ) parser.add_argument( "--snapshot-tx-interval", help="Number of transactions between two snapshots", diff --git a/tests/infra/network.py b/tests/infra/network.py index 52839fc9c4f6..26fe91488322 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -192,6 +192,7 @@ class Network: "join_timer_s", "worker_threads", "ledger_chunk_bytes", + "ledger_max_transaction_bytes", "subject_alt_names", "snapshot_tx_interval", "snapshot_min_tx_interval", diff --git a/tests/limits.py b/tests/limits.py index 2c4202b9d0fa..45dfaa63d821 100644 --- a/tests/limits.py +++ b/tests/limits.py @@ -49,6 +49,21 @@ def test_forward_larger_than_default_requests(network, args): assert r.status_code == http.HTTPStatus.OK.value, r +def test_transaction_size_limit(network, args): + primary, _ = network.find_primary() + + with primary.client("user0") as c: + r = c.post("/app/log/private", {"id": 1, "msg": "small"}) + assert r.status_code == http.HTTPStatus.OK.value, r + + msg = "A" * 64 * 1024 + r = c.post("/app/log/private", {"id": 2, "msg": msg}) + assert r.status_code == http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE.value, r + + r = c.post("/app/log/private", {"id": 3, "msg": "still processing"}) + assert r.status_code == http.HTTPStatus.OK.value, r + + def run_parser_limits_checks(args): new_args = copy.copy(args) # Deliberately large because some builds take @@ -66,6 +81,20 @@ def run_parser_limits_checks(args): test_forward_larger_than_default_requests(network, new_args) +def run_transaction_size_limit_checks(args): + new_args = copy.copy(args) + new_args.ledger_max_transaction_bytes = "20KB" + with infra.network.network( + new_args.nodes, + new_args.binary_dir, + new_args.debug_nodes, + pdb=args.pdb, + ) as network: + network.start_and_open(new_args) + + test_transaction_size_limit(network, new_args) + + if __name__ == "__main__": cr = ConcurrentRunner() @@ -78,4 +107,11 @@ def run_parser_limits_checks(args): nodes=infra.e2e_args.max_nodes(cr.args, f=0), ) + cr.add( + "transaction_size_limit", + run_transaction_size_limit_checks, + package="samples/apps/logging/logging", + nodes=infra.e2e_args.max_nodes(cr.args, f=0), + ) + cr.run() From d86be953dbbaad6d53813615d97d6edba67146b0 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 3 Jul 2026 15:40:35 +0000 Subject: [PATCH 3/6] Fix CI failures: mark get_max_transaction_size [[nodiscard]], fix transaction_size_limit e2e test - clang-tidy (modernize-use-nodiscard) required get_max_transaction_size() to be marked [[nodiscard]], matching the convention already used for other const getters in AbstractStore. - The transaction_size_limit e2e test configured ledger.max_transaction_size to 20KB before starting the network, which is smaller than the constitution scripts written to the KV store during service creation, causing genesis to fail. Raise the configured limit to 512KB (comfortably above the genesis transaction size) and the oversized test payload to 1MB so the 413 path is still exercised. --- src/kv/kv_types.h | 2 +- src/kv/store.h | 2 +- tests/limits.py | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/src/kv/kv_types.h b/src/kv/kv_types.h index c0c5d3d1bcb3..41aa32dd9782 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -676,7 +676,7 @@ namespace ccf::kv virtual std::shared_ptr get_history() = 0; virtual std::shared_ptr get_chunker() = 0; virtual EncryptorPtr get_encryptor() = 0; - virtual size_t get_max_transaction_size() const = 0; + [[nodiscard]] virtual size_t get_max_transaction_size() const = 0; virtual std::unique_ptr deserialize( const std::vector& data, bool public_only = false, diff --git a/src/kv/store.h b/src/kv/store.h index eeeb2ba4f3a2..34d9a92a7f15 100644 --- a/src/kv/store.h +++ b/src/kv/store.h @@ -245,7 +245,7 @@ namespace ccf::kv max_transaction_size = max_transaction_size_; } - size_t get_max_transaction_size() const override + [[nodiscard]] size_t get_max_transaction_size() const override { return max_transaction_size; } diff --git a/tests/limits.py b/tests/limits.py index 45dfaa63d821..131005eb5f26 100644 --- a/tests/limits.py +++ b/tests/limits.py @@ -56,7 +56,7 @@ def test_transaction_size_limit(network, args): r = c.post("/app/log/private", {"id": 1, "msg": "small"}) assert r.status_code == http.HTTPStatus.OK.value, r - msg = "A" * 64 * 1024 + msg = "A" * 1024 * 1024 r = c.post("/app/log/private", {"id": 2, "msg": msg}) assert r.status_code == http.HTTPStatus.REQUEST_ENTITY_TOO_LARGE.value, r @@ -83,7 +83,10 @@ def run_parser_limits_checks(args): def run_transaction_size_limit_checks(args): new_args = copy.copy(args) - new_args.ledger_max_transaction_bytes = "20KB" + # Deliberately larger than the constitution scripts written to the KV + # store as part of service creation, but well under the oversized + # request used below, so only the latter is rejected. + new_args.ledger_max_transaction_bytes = "512KB" with infra.network.network( new_args.nodes, new_args.binary_dir, From 80bad361f5089d03e9c07104b0c79c4b6e76e9bf Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Sat, 4 Jul 2026 13:44:33 +0000 Subject: [PATCH 4/6] Document ledger entry size parameter Co-authored-by: achamayou <4016369+achamayou@users.noreply.github.com> --- src/consensus/ledger_enclave.h | 1 + 1 file changed, 1 insertion(+) diff --git a/src/consensus/ledger_enclave.h b/src/consensus/ledger_enclave.h index b7119739d273..c1136c5c4b79 100644 --- a/src/consensus/ledger_enclave.h +++ b/src/consensus/ledger_enclave.h @@ -20,6 +20,7 @@ namespace consensus * * @param data Serialised entries * @param size Size of overall serialised entries + * @param max_transaction_size Maximum allowed serialised entry body size * * @return Raw entry as a vector */ From 82b243c4ba117ab45162c21eddc20d6218c57c56 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Thu, 9 Jul 2026 15:38:33 +0000 Subject: [PATCH 5/6] Address review feedback on max transaction size enforcement - Exempt snapshots from the per-transaction limit (serialise and deserialise) and add a regression test; snapshots capture whole-store state and may legitimately exceed a single-transaction cap. - Move the CHANGELOG entry from the released 5.0.0 section to 7.0.7 (Changed), fix 'serialized' -> 'serialised', and reference PR #7992 instead of tracking issue #7488. - Rename describe_serialised_entry_size_error to British spelling to match the surrounding code. - Document that historical-query stores deliberately do not apply the limit (read-only reconstruction). - Add a Store::set_max_transaction_size out-of-range validation test. - Add missing trailing newline to serialised_entry_format.h. --- CHANGELOG.md | 2 +- src/consensus/ledger_enclave.h | 2 +- src/kv/generic_serialise_wrapper.h | 4 +-- src/kv/kv_types.h | 3 +- src/kv/serialised_entry_format.h | 4 +-- src/kv/snapshot.h | 6 ++-- src/kv/store.h | 12 +++++-- src/kv/test/kv_serialisation.cpp | 19 +++++++++++ src/kv/test/kv_snapshot.cpp | 54 ++++++++++++++++++++++++++++++ src/node/historical_queries.h | 7 ++++ 10 files changed, 98 insertions(+), 15 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index acc63e450124..7c94c1c60d28 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed +- The `ledger.max_transaction_size` configuration option now limits the serialised transaction body size stored in each ledger entry. The limit excludes the fixed 8-byte ledger entry header and includes the ledger encryption header, public domain size field, public domain and encrypted private domain. The default value is `100MB`. (#7992) - JWT/JWK auto-refresh outbound HTTP fetches (OpenID metadata and JWKS) now use the curl multi singleton client introduced in #7102, replacing the previous `RPCSessions::create_client()` path. Connection and TLS failures are now counted in refresh failure metrics via `send_refresh_jwt_keys_error()`, improving observability of network-level refresh errors (#7989). - JWT/JWK auto-refresh now supports configuring the maximum response body size for fetched OpenID metadata and JWKS via the `jwt.key_refresh_max_response_size` node startup config setting (#7989). - Fatal task worker stack traces now use libbacktrace for improved function and source-location resolution. Building CCF now requires the libbacktrace development package, and the RPM development package depends on `libbacktrace-static` (#7721). @@ -1294,7 +1295,6 @@ For more information, see [our documentation](https://microsoft.github.io/CCF/ma #### Configuration - The `cchost` configuration file now includes an `idle_connection_timeout` option. This controls how long the node will keep idle connections (for user TLS sessions) before automatically closing them. This may be set to `null` to restore the previous behaviour, where idle connections are never closed. By default connections will be closed after 60s of idle time. -- The `ledger.max_transaction_size` configuration option now limits the serialized transaction body size stored in each ledger entry. The limit excludes the fixed 8-byte ledger entry header and includes the ledger encryption header, public domain size field, public domain and encrypted private domain. The default value is `100MB`. (#7488) - A soft size limit can now be set for the historical store cache in the node configuration: [`historical_cache_soft_limit`](https://microsoft.github.io/CCF/main/operations/generated_config.html#historical-cache-soft-limit). The default value is `512Mb`. - Path to the enclave file should now be passed as `--enclave-file` CLI argument to `cchost`, rather than `enclave.file` entry within configuration file. - SNP collateral must now be provided through the `snp_security_policy_file`, `snp_uvm_endorsements_file` and `snp_endorsements_servers` configuration values. See [documentation](https://microsoft.github.io/CCF/main/operations/platforms/snp.html) for details and platform-specific configuration samples. diff --git a/src/consensus/ledger_enclave.h b/src/consensus/ledger_enclave.h index c1136c5c4b79..975c1871a10b 100644 --- a/src/consensus/ledger_enclave.h +++ b/src/consensus/ledger_enclave.h @@ -35,7 +35,7 @@ namespace consensus const size_t body_size = header.size; if (body_size > max_transaction_size) { - throw std::logic_error(ccf::kv::describe_serialized_entry_size_error( + throw std::logic_error(ccf::kv::describe_serialised_entry_size_error( body_size, max_transaction_size, "extract from ledger")); } if (body_size + ccf::kv::serialised_entry_header_size > size) diff --git a/src/kv/generic_serialise_wrapper.h b/src/kv/generic_serialise_wrapper.h index e315b5efbf98..006a2cb8fa61 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -178,7 +178,7 @@ namespace ccf::kv } if (size_ > max_transaction_size) { - throw MaxTransactionSizeExceeded(describe_serialized_entry_size_error( + throw MaxTransactionSizeExceeded(describe_serialised_entry_size_error( size_, max_transaction_size, "serialise")); } entry_header.set_size(size_); @@ -324,7 +324,7 @@ namespace ccf::kv if (tx_header.size > max_transaction_size) { - throw MaxTransactionSizeExceeded(describe_serialized_entry_size_error( + throw MaxTransactionSizeExceeded(describe_serialised_entry_size_error( tx_header.size, max_transaction_size, "deserialise")); } diff --git a/src/kv/kv_types.h b/src/kv/kv_types.h index dfc1a7d84987..ce53eb841c7b 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -702,8 +702,7 @@ namespace ccf::kv virtual ~AbstractSnapshot() = default; [[nodiscard]] virtual Version get_version() const = 0; virtual std::vector serialise( - const std::shared_ptr& encryptor, - size_t max_transaction_size) = 0; + const std::shared_ptr& encryptor) = 0; }; virtual ~AbstractStore() = default; diff --git a/src/kv/serialised_entry_format.h b/src/kv/serialised_entry_format.h index a31b7a23bbd2..2efb7a4a5f49 100644 --- a/src/kv/serialised_entry_format.h +++ b/src/kv/serialised_entry_format.h @@ -47,7 +47,7 @@ namespace ccf::kv static constexpr size_t serialised_entry_header_size = sizeof(SerialisedEntryHeader); - static inline std::string describe_serialized_entry_size_error( + static inline std::string describe_serialised_entry_size_error( size_t body_size, size_t max_body_size, const char* operation) { return fmt::format( @@ -62,4 +62,4 @@ namespace ccf::kv max_body_size, serialised_entry_header_size); } -} \ No newline at end of file +} diff --git a/src/kv/snapshot.h b/src/kv/snapshot.h index 79bfe82350a5..7176c1220572 100644 --- a/src/kv/snapshot.h +++ b/src/kv/snapshot.h @@ -41,8 +41,7 @@ namespace ccf::kv } std::vector serialise( - const std::shared_ptr& encryptor, - size_t max_transaction_size) override + const std::shared_ptr& encryptor) override { // Set the execution dependency for the snapshot to be the version // previous to said snapshot to ensure that the correct snapshot is @@ -60,8 +59,7 @@ namespace ccf::kv 0, {}, ccf::no_claims(), - true /* historical_hint */, - max_transaction_size); + true /* historical_hint */); if (hash_at_snapshot.has_value()) { diff --git a/src/kv/store.h b/src/kv/store.h index 5ee88884a5f6..0654ee02161d 100644 --- a/src/kv/store.h +++ b/src/kv/store.h @@ -414,7 +414,11 @@ namespace ccf::kv std::unique_ptr snapshot) override { auto e = get_encryptor(); - return snapshot->serialise(e, max_transaction_size); + // Snapshots capture the entire committed state and are legitimately much + // larger than any single transaction, so the configured + // max_transaction_size (a per-transaction write limit) is deliberately + // not applied here. + return snapshot->serialise(e); } ApplyResult deserialise_snapshot( @@ -432,8 +436,10 @@ namespace ccf::kv ccf::kv::Term term = 0; ccf::kv::EntryFlags entry_flags = {}; - auto v_ = d.init( - data, size, term, entry_flags, is_historical, max_transaction_size); + // Snapshots are not subject to the per-transaction max_transaction_size + // limit (see serialise_snapshot), so it is not applied when reading one + // back either. + auto v_ = d.init(data, size, term, entry_flags, is_historical); if (!v_.has_value()) { LOG_FAIL_FMT("Initialisation of deserialise object failed"); diff --git a/src/kv/test/kv_serialisation.cpp b/src/kv/test/kv_serialisation.cpp index e5358e59ede9..635a5a3b765a 100644 --- a/src/kv/test/kv_serialisation.cpp +++ b/src/kv/test/kv_serialisation.cpp @@ -8,6 +8,7 @@ #include #undef FAIL +#include #include #include @@ -238,6 +239,24 @@ TEST_CASE( ccf::kv::MaxTransactionSizeExceeded); } +TEST_CASE( + "Reject configuring a maximum transaction size beyond the serialisable " + "limit" * + doctest::test_suite("serialisation")) +{ + ccf::kv::Store kv_store; + + // The largest size the ledger entry header can represent is accepted. + REQUIRE_NOTHROW(kv_store.set_max_transaction_size( + ccf::kv::SerialisedEntryHeader::max_serialised_entry_body_size)); + + // A value larger than can ever be serialised is rejected at configuration + // time, rather than being deferred to a later serialisation failure. + REQUIRE_THROWS_AS( + kv_store.set_max_transaction_size(std::numeric_limits::max()), + std::logic_error); +} + TEST_CASE( "Serialise/deserialise private map and public maps" * doctest::test_suite("serialisation")) diff --git a/src/kv/test/kv_snapshot.cpp b/src/kv/test/kv_snapshot.cpp index 08ed6f959e98..7ba6dd7caae1 100644 --- a/src/kv/test/kv_snapshot.cpp +++ b/src/kv/test/kv_snapshot.cpp @@ -7,6 +7,7 @@ #include #undef FAIL +#include struct MapTypes { @@ -19,6 +20,59 @@ struct MapTypes MapTypes::StringString string_map("public:string_map"); MapTypes::NumNum num_map("public:num_map"); +TEST_CASE( + "Snapshots are not subject to the transaction size limit" * + doctest::test_suite("snapshot")) +{ + auto encryptor = std::make_shared(); + + ccf::kv::Store store; + store.set_encryptor(encryptor); + + // A deliberately small per-transaction limit. + constexpr size_t small_limit = 512; + store.set_max_transaction_size(small_limit); + + // Accumulate committed state larger than the limit across several + // transactions, each of which individually stays under the limit. + constexpr size_t num_entries = 32; + constexpr size_t value_size = 64; + ccf::kv::Version snapshot_version = ccf::kv::NoVersion; + for (size_t i = 0; i < num_entries; ++i) + { + auto tx = store.create_tx(); + auto handle = tx.rw(string_map); + handle->put("key_" + std::to_string(i), std::string(value_size, 'x')); + REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); + snapshot_version = tx.commit_version(); + } + REQUIRE(num_entries * value_size > small_limit); + + std::unique_ptr snapshot = nullptr; + { + ccf::kv::ScopedStoreMapsLock maps_lock(&store); + snapshot = store.snapshot_unsafe_maps(snapshot_version); + } + + // Serialising a snapshot larger than the per-transaction limit must not be + // rejected by that limit. + std::vector serialised_snapshot; + REQUIRE_NOTHROW( + serialised_snapshot = store.serialise_snapshot(std::move(snapshot))); + + // And it can be read back into a store configured with the same small limit. + ccf::kv::Store new_store; + new_store.set_encryptor(encryptor); + new_store.set_max_transaction_size(small_limit); + + ccf::kv::ConsensusHookPtrs hooks; + REQUIRE_EQ( + new_store.deserialise_snapshot( + serialised_snapshot.data(), serialised_snapshot.size(), hooks), + ccf::kv::ApplyResult::PASS); + REQUIRE_EQ(new_store.current_version(), snapshot_version); +} + TEST_CASE("Simple snapshot" * doctest::test_suite("snapshot")) { ccf::kv::Store store; diff --git a/src/node/historical_queries.h b/src/node/historical_queries.h index 55fd7b7c81f0..d12e4041f22c 100644 --- a/src/node/historical_queries.h +++ b/src/node/historical_queries.h @@ -1485,6 +1485,13 @@ namespace ccf::historical false /* Do not start from very first seqno */, true /* Make use of historical secrets */); + // The configured max_transaction_size limit is deliberately not set on + // this store. That limit is a write-time guard preventing oversized + // entries from being serialised into the ledger; historical queries only + // reconstruct already-committed state to serve reads, and must remain + // able to deserialise any entry that was validly written, including one + // written under a previously larger or unset limit. + // If this is older than the node's currently known ledger secrets, use // the historical encryptor (which should have older secrets) if (seqno < source_ledger_secrets->get_first().first) From 6e8606b39f50722bd49777c4e5882f9729b05d74 Mon Sep 17 00:00:00 2001 From: GitHub Copilot Date: Fri, 10 Jul 2026 08:49:22 +0000 Subject: [PATCH 6/6] Refine max transaction size: cap serialisation only, on whole-entry size - Exempt all deserialisation from the size cap; only serialisation of new transactions is capped. Remove the limit parameter and check from the deserialiser init, the abstract deserialiser interface, deserialise_views, get_entry, and both recovery loops; drop recovery_store's limit. Buffer- bounds safety checks (header size vs buffer, get_entry truncation) remain. - Apply the cap to the whole serialised ledger entry (fixed 8-byte header plus body) instead of just the body. - Update the error message, CHANGELOG, and host config schema description; remove a stale get_entry doc comment. - Replace the deserialise-rejection unit test with a deserialise-exemption test. --- CHANGELOG.md | 2 +- doc/host_config_schema/host_config.json | 217 +++++++++++++++++++----- src/consensus/ledger_enclave.h | 16 +- src/kv/generic_serialise_wrapper.h | 23 +-- src/kv/kv_types.h | 4 +- src/kv/serialised_entry_format.h | 18 +- src/kv/store.h | 9 +- src/kv/test/kv_serialisation.cpp | 13 +- src/node/node_state.h | 8 +- 9 files changed, 207 insertions(+), 103 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7c94c1c60d28..625c0a247cf9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,7 +11,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0. ### Changed -- The `ledger.max_transaction_size` configuration option now limits the serialised transaction body size stored in each ledger entry. The limit excludes the fixed 8-byte ledger entry header and includes the ledger encryption header, public domain size field, public domain and encrypted private domain. The default value is `100MB`. (#7992) +- The `ledger.max_transaction_size` configuration option now limits the total serialised size of a transaction when writing it 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 enforced only when serialising new transactions; deserialising existing entries (including during recovery) and snapshots are not affected. The default value is `100MB`. (#7992) - JWT/JWK auto-refresh outbound HTTP fetches (OpenID metadata and JWKS) now use the curl multi singleton client introduced in #7102, replacing the previous `RPCSessions::create_client()` path. Connection and TLS failures are now counted in refresh failure metrics via `send_refresh_jwt_keys_error()`, improving observability of network-level refresh errors (#7989). - JWT/JWK auto-refresh now supports configuring the maximum response body size for fetched OpenID metadata and JWKS via the `jwt.key_refresh_max_response_size` node startup config setting (#7989). - Fatal task worker stack traces now use libbacktrace for improved function and source-location resolution. Building CCF now requires the libbacktrace development package, and the RPM development package depends on `libbacktrace-static` (#7721). diff --git a/doc/host_config_schema/host_config.json b/doc/host_config_schema/host_config.json index 3e3a4c4e47e4..bf6c609ae996 100644 --- a/doc/host_config_schema/host_config.json +++ b/doc/host_config_schema/host_config.json @@ -21,7 +21,9 @@ } }, "description": "Addresses (host:port) to listen on for incoming node-to-node connections (e.g. internal consensus messages). IPv6 literals must be bracketed, e.g. ``[::1]:8081``", - "required": ["bind_address"], + "required": [ + "bind_address" + ], "additionalProperties": false }, "rpc_interfaces": { @@ -100,12 +102,19 @@ "properties": { "authority": { "type": "string", - "enum": ["Node", "Service", "ACME", "Unsecured"], + "enum": [ + "Node", + "Service", + "ACME", + "Unsecured" + ], "default": "Service", "description": "The type of endorsement for the TLS certificate used in client sessions. If the endorsement is not available, client sessions will be terminated, before the TLS handshake is complete. 'Node' means self-signed, 'Service' means service-endorsed, 'ACME' is deprecated, 'Unsecured' means unencrypted traffic and no endorsement authority" } }, - "required": ["authority"], + "required": [ + "authority" + ], "additionalProperties": false }, "accepted_endpoints": { @@ -138,19 +147,28 @@ "enabled_operator_features": { "type": "array", "items": { - "enum": ["SnapshotRead", "LedgerChunkRead", "SnapshotCreate"], + "enum": [ + "SnapshotRead", + "LedgerChunkRead", + "SnapshotCreate" + ], "type": "string" }, "description": "An array of features which should be enabled on this interface, providing access to endpoints with specific security or performance constraints." } }, - "required": ["bind_address"] + "required": [ + "bind_address" + ] }, "description": "Interfaces to listen on for incoming client TLS connections, as a dictionary from unique interface name to RPC interface information" } }, "description": "This section includes configuration for the interfaces a node listens on (for both client and node-to-node communications)", - "required": ["node_to_node_interface", "rpc_interfaces"], + "required": [ + "node_to_node_interface", + "rpc_interfaces" + ], "additionalProperties": false }, "command": { @@ -158,7 +176,11 @@ "properties": { "type": { "type": "string", - "enum": ["Start", "Join", "Recover"], + "enum": [ + "Start", + "Join", + "Recover" + ], "description": "Type of CCF node" }, "service_certificate_file": { @@ -222,20 +244,32 @@ "description": "Path to member x509 identity certificate (PEM)" }, "encryption_public_key_file": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to member encryption public key (PEM)" }, "data_json_file": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to member data file (JSON)" }, "recovery_role": { "type": "string", - "enum": ["NonParticipant", "Participant", "Owner"], + "enum": [ + "NonParticipant", + "Participant", + "Owner" + ], "description": "Whether the member acts as a recovery participant and gets assigned a share that can contribute towards a recovery threshold or as an owner and gets assigned a full recovery key" } }, - "required": ["certificate_file"], + "required": [ + "certificate_file" + ], "additionalProperties": false }, "description": "List of initial consortium members files, including identity certificates, public encryption keys and member data files" @@ -267,15 +301,22 @@ "minimum": 1 } }, - "required": ["recovery_threshold"], + "required": [ + "recovery_threshold" + ], "additionalProperties": false } }, - "required": ["constitution_files", "members"], + "required": [ + "constitution_files", + "members" + ], "additionalProperties": false } }, - "required": ["start"] + "required": [ + "start" + ] } }, { @@ -327,16 +368,23 @@ "description": "Maximum size of snapshot this node is willing to fetch" }, "host_data_transparent_statement_path": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": null, "description": "Path to a SCITT Transparent Statement over the attested host_data of the node" } }, - "required": ["target_rpc_address"], + "required": [ + "target_rpc_address" + ], "additionalProperties": false } }, - "required": ["join"] + "required": [ + "join" + ] } }, { @@ -363,7 +411,9 @@ "description": "Path to the previous service certificate (PEM) file" } }, - "required": ["previous_service_identity_file"], + "required": [ + "previous_service_identity_file" + ], "additionalProperties": false } } @@ -371,7 +421,9 @@ } ], "description": "This section includes configuration of how the node should start (either start, join or recover) and associated information", - "required": ["type"] + "required": [ + "type" + ] }, "node_certificate": { "type": "object", @@ -390,7 +442,10 @@ }, "curve_id": { "type": "string", - "enum": ["Secp384R1", "Secp256R1"], + "enum": [ + "Secp384R1", + "Secp256R1" + ], "default": "Secp384R1", "description": "Elliptic curve to use for node identity key" }, @@ -405,22 +460,34 @@ "additionalProperties": false }, "node_data_json_file": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to file (JSON) containing initial node data. It is intended to store correlation IDs describing the node's deployment, such as a VM name or Pod identifier" }, "attestation": { "type": "object", "properties": { "snp_security_policy_file": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to file containing the security policy (SEV-SNP only), can contain environment variables, such as $UVM_SECURITY_CONTEXT_DIR" }, "snp_uvm_endorsements_file": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to file containing UVM endorsements as a base64-encoded COSE Sign1 (SEV-SNP only). Can contain environment variables, such as $UVM_SECURITY_CONTEXT_DIR" }, "snp_endorsements_file": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to file containing AMD VCEK hardware endorsements (a PEM certificate chain), base-64 encoded. Can contain environment variables, such as $UVM_SECURITY_CONTEXT_DIR. Will be used in preference to snp_endorsements_servers if the tcbm in this file matches that of the attestation" }, "snp_endorsements_servers": { @@ -430,7 +497,11 @@ "properties": { "type": { "type": "string", - "enum": ["Azure", "AMD", "THIM"], + "enum": [ + "Azure", + "AMD", + "THIM" + ], "default": "Azure", "description": "Type of server used to retrieve attestation report endorsement certificates (SEV-SNP only)" }, @@ -444,7 +515,9 @@ "description": "Maximum number of retries to fetch endorsements from the server" } }, - "required": ["url"], + "required": [ + "url" + ], "additionalProperties": false }, "description": "List of servers used to retrieve attestation report endorsement certificates (SEV-SNP only). The first server in the list is always used and other servers are only specified as fallback. If set, attestation endorsements from ``--snp-security-context-dir-var`` are ignored, but uvm endorsements from that directory are still used." @@ -454,7 +527,10 @@ "additionalProperties": false }, "service_data_json_file": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to file (JSON) containing initial service data. It is used when the node starts in 'Start' or 'Recover' mode and is intended to store arbitrary information about the service" }, "ledger": { @@ -480,7 +556,7 @@ "max_transaction_size": { "type": "string", "default": "100MB", - "description": "Maximum serialised transaction body size (size string). This is compared with the size stored in the ledger entry header, so it excludes the fixed 8-byte ledger entry header and includes the ledger encryption header, public domain size field, public domain and encrypted private domain." + "description": "Maximum total serialised size of a transaction (size string). 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." } }, "description": "This section includes configuration for the ledger directories and files", @@ -511,7 +587,10 @@ "description": "Time interval after which a snapshot should be triggered, provided more than min_tx_count transactions have elapsed since the last snapshot. Set this to 0s to disable time-based snapshotting." }, "read_only_directory": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Path to read-only snapshots directory. Deprecated: this option is deprecated and will be removed in a future release. Use join.fetch_recent_snapshot and snapshots.backup_fetch to have joining and/or backup nodes automatically fetch snapshots from the primary node instead." }, "backup_fetch": { @@ -555,13 +634,19 @@ "type": "object", "properties": { "max_snapshots": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": null, "description": "Maximum number of committed snapshot files to retain. When the number of committed snapshots exceeds this value, the oldest snapshots are deleted. Must be at least 1 if set. If null or unset, no automated snapshot garbage collection is performed.", "minimum": 1 }, "max_committed_ledger_chunks": { - "type": ["integer", "null"], + "type": [ + "integer", + "null" + ], "default": null, "description": "Maximum number of committed ledger chunk files to retain in the main ledger directory. When the number of committed chunks exceeds this value, the oldest chunks are deleted, but only after verifying that an identical copy (by SHA-256 digest) exists in at least one read-only ledger directory. Chunks whose entries extend to or beyond the sequence number of the newest committed snapshot are never deleted, ensuring a complete ledger history from that snapshot for disaster recovery. Requires at least one ledger.read_only_directories entry; the node will refuse to start otherwise. If null or unset, no automated ledger chunk garbage collection is performed." }, @@ -597,13 +682,22 @@ "properties": { "host_level": { "type": "string", - "enum": ["Trace", "Debug", "Info", "Fail", "Fatal"], + "enum": [ + "Trace", + "Debug", + "Info", + "Fail", + "Fatal" + ], "default": "Info", "description": "Logging level for the untrusted host. DEPRECATED, use the --log-level CLI switch instead." }, "format": { "type": "string", - "enum": ["Text", "Json"], + "enum": [ + "Text", + "Json" + ], "default": "Text", "description": "If 'json', node logs will be formatted as JSON" } @@ -704,7 +798,10 @@ "description": "Maximum duration of I/O operations (ledger and snapshots) after which slow operations will be logged to node log" }, "node_client_interface": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "description": "Address to bind to for node-to-node client connections. If unspecified, this is automatically assigned by the OS. This option is particularly useful for testing purposes (e.g. establishing network partitions between nodes)" }, "client_connection_timeout": { @@ -713,7 +810,10 @@ "description": "Maximum duration after which unestablished client connections will be marked as timed out and either re-established or discarded" }, "idle_connection_timeout": { - "type": ["string", "null"], + "type": [ + "string", + "null" + ], "default": "60s", "description": "Timeout for idle connections. Null is a valid option, and means idle connections are retained indefinitely" }, @@ -774,7 +874,10 @@ "type": "string" } }, - "required": ["name", "address"], + "required": [ + "name", + "address" + ], "additionalProperties": false }, "recovery_decision_protocol": { @@ -793,7 +896,10 @@ "type": "string" } }, - "required": ["name", "address"], + "required": [ + "name", + "address" + ], "additionalProperties": false } }, @@ -807,26 +913,38 @@ "description": "Timeout duration before failover forcibly advances the recovery_decision_protocol, allowing recovery to proceed even in the presence of unresponsive nodes. Set to 0 to disable failover." } }, - "required": ["expected_locations"], + "required": [ + "expected_locations" + ], "additionalProperties": false } }, - "required": ["location"], + "required": [ + "location" + ], "additionalProperties": false } }, - "required": ["network", "command"], + "required": [ + "network", + "command" + ], "additionalProperties": false, "$defs": { "RedirectionResolver": { "type": "object", "properties": { "kind": { - "enum": ["NodeByRole", "StaticAddress"] + "enum": [ + "NodeByRole", + "StaticAddress" + ] }, "target": {} }, - "required": ["kind"], + "required": [ + "kind" + ], "allOf": [ { "if": { @@ -842,7 +960,10 @@ "type": "object", "properties": { "role": { - "enum": ["primary", "backup"], + "enum": [ + "primary", + "backup" + ], "default": "primary" } }, @@ -868,15 +989,19 @@ "type": "string" } }, - "required": ["address"], + "required": [ + "address" + ], "additionalProperties": false } }, - "required": ["target"] + "required": [ + "target" + ] } } ], "additionalProperties": false } } -} +} \ No newline at end of file diff --git a/src/consensus/ledger_enclave.h b/src/consensus/ledger_enclave.h index 975c1871a10b..52dae4f5d10d 100644 --- a/src/consensus/ledger_enclave.h +++ b/src/consensus/ledger_enclave.h @@ -20,24 +20,18 @@ namespace consensus * * @param data Serialised entries * @param size Size of overall serialised entries - * @param max_transaction_size Maximum allowed serialised entry body size * * @return Raw entry as a vector */ - static std::vector get_entry( - const uint8_t*& data, - size_t& size, - size_t max_transaction_size = - ccf::kv::SerialisedEntryHeader::max_serialised_entry_body_size) + static std::vector get_entry(const uint8_t*& data, size_t& size) { auto header = serialized::peek(data, size); const size_t body_size = header.size; - if (body_size > max_transaction_size) - { - throw std::logic_error(ccf::kv::describe_serialised_entry_size_error( - body_size, max_transaction_size, "extract from ledger")); - } + // This is a buffer-bounds safety check, not the configurable transaction + // size limit: that limit applies only when serialising new transactions. + // Deserialisation (including recovery replay) is exempt, so entries + // written under a larger or unset limit can always be read back. if (body_size + ccf::kv::serialised_entry_header_size > size) { throw std::logic_error(fmt::format( diff --git a/src/kv/generic_serialise_wrapper.h b/src/kv/generic_serialise_wrapper.h index 006a2cb8fa61..abab15950a3d 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -176,15 +176,18 @@ namespace ccf::kv size_ += crypto_util->get_header_length() + sizeof(size_t) + serialised_private_domain.size(); } - if (size_ > max_transaction_size) - { - throw MaxTransactionSizeExceeded(describe_serialised_entry_size_error( - size_, max_transaction_size, "serialise")); - } entry_header.set_size(size_); + // The configured limit applies to the whole serialised ledger entry, + // including the fixed-size entry header. size_ += sizeof(SerialisedEntryHeader); + if (size_ > max_transaction_size) + { + throw MaxTransactionSizeExceeded( + describe_serialised_entry_size_error(size_, max_transaction_size)); + } + std::vector entry(size_); auto* data_ = entry.data(); @@ -311,9 +314,7 @@ namespace ccf::kv size_t size, ccf::kv::Term& term, EntryFlags& flags, - bool historical_hint = false, - size_t max_transaction_size = - SerialisedEntryHeader::max_serialised_entry_body_size) override + bool historical_hint = false) override { current_reader = &public_reader; const auto* data_ = data; @@ -322,12 +323,6 @@ namespace ccf::kv const auto tx_header = serialized::read(data_, size_); - if (tx_header.size > max_transaction_size) - { - throw MaxTransactionSizeExceeded(describe_serialised_entry_size_error( - tx_header.size, max_transaction_size, "deserialise")); - } - flags = static_cast(tx_header.flags); if (tx_header.size != size_) diff --git a/src/kv/kv_types.h b/src/kv/kv_types.h index ce53eb841c7b..96de7bc58572 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -343,9 +343,7 @@ namespace ccf::kv size_t size, ccf::kv::Term& term, EntryFlags& flags, - bool historical_hint = false, - size_t max_transaction_size = - SerialisedEntryHeader::max_serialised_entry_body_size) = 0; + bool historical_hint = false) = 0; virtual std::optional start_map() = 0; virtual Version deserialise_entry_version() = 0; virtual uint64_t deserialise_read_header() = 0; diff --git a/src/kv/serialised_entry_format.h b/src/kv/serialised_entry_format.h index 2efb7a4a5f49..e0aef2745608 100644 --- a/src/kv/serialised_entry_format.h +++ b/src/kv/serialised_entry_format.h @@ -48,18 +48,16 @@ namespace ccf::kv sizeof(SerialisedEntryHeader); static inline std::string describe_serialised_entry_size_error( - size_t body_size, size_t max_body_size, const char* operation) + size_t entry_size, size_t max_entry_size) { return fmt::format( - "Cannot {} transaction with serialised body size {} bytes. The " - "configured maximum is {} bytes. The transaction size compared to this " - "limit is the size stored in the ledger entry header: the serialised " - "transaction body after the fixed {}-byte ledger entry header, " - "including any ledger encryption header, public domain size field, " - "public domain and encrypted private domain.", - operation, - body_size, - max_body_size, + "Cannot serialise transaction with total serialised size {} bytes. The " + "configured maximum is {} bytes. The size compared to this limit is the " + "whole ledger entry: the fixed {}-byte ledger entry header plus the " + "serialised transaction body (any ledger encryption header, public " + "domain size field, public domain and encrypted private domain).", + entry_size, + max_entry_size, serialised_entry_header_size); } } diff --git a/src/kv/store.h b/src/kv/store.h index 0654ee02161d..1f461385d3ee 100644 --- a/src/kv/store.h +++ b/src/kv/store.h @@ -766,13 +766,8 @@ namespace ccf::kv public_only ? ccf::kv::SecurityDomain::PUBLIC : std::optional()); - auto v_ = d.init( - data.data(), - data.size(), - view, - entry_flags, - is_historical, - max_transaction_size); + auto v_ = + d.init(data.data(), data.size(), view, entry_flags, is_historical); if (!v_.has_value()) { LOG_FAIL_FMT("Initialisation of deserialise object failed"); diff --git a/src/kv/test/kv_serialisation.cpp b/src/kv/test/kv_serialisation.cpp index 635a5a3b765a..857c16f9df11 100644 --- a/src/kv/test/kv_serialisation.cpp +++ b/src/kv/test/kv_serialisation.cpp @@ -208,12 +208,13 @@ TEST_CASE( } TEST_CASE( - "Reject deserialised transactions exceeding configured serialised size" * + "Deserialisation is not subject to the transaction size limit" * doctest::test_suite("serialisation")) { auto consensus = std::make_shared(); auto encryptor = std::make_shared(); + // Serialise a transaction under a permissive (default) limit. ccf::kv::Store kv_store; kv_store.set_consensus(consensus); kv_store.set_encryptor(encryptor); @@ -223,20 +224,22 @@ TEST_CASE( { auto tx = kv_store.create_tx(); auto handle = tx.rw(map); - handle->put("small", "ok"); + handle->put("large", std::string(2048, 'A')); REQUIRE(tx.commit() == ccf::kv::CommitResult::SUCCESS); } const auto latest_data = consensus->get_latest_data(); REQUIRE(latest_data.has_value()); + // The cap applies only to serialisation. A tiny limit on the target store + // must not prevent it from deserialising an already-serialised transaction. ccf::kv::Store kv_store_target; kv_store_target.set_encryptor(encryptor); kv_store_target.set_max_transaction_size(1); - REQUIRE_THROWS_AS( - kv_store_target.deserialize(latest_data.value())->apply(), - ccf::kv::MaxTransactionSizeExceeded); + REQUIRE( + kv_store_target.deserialize(latest_data.value())->apply() == + ccf::kv::ApplyResult::PASS); } TEST_CASE( diff --git a/src/node/node_state.h b/src/node/node_state.h index eba3ce0d2684..a6587897aa32 100644 --- a/src/node/node_state.h +++ b/src/node/node_state.h @@ -1477,8 +1477,7 @@ namespace ccf while (size > 0) { - auto entry = ::consensus::LedgerEnclave::get_entry( - data, size, network.tables->get_max_transaction_size()); + auto entry = ::consensus::LedgerEnclave::get_entry(data, size); LOG_INFO_FMT( "Deserialising public ledger entry #{} [{} bytes]", @@ -1767,8 +1766,7 @@ namespace ccf while (size > 0) { - auto entry = ::consensus::LedgerEnclave::get_entry( - data, size, network.tables->get_max_transaction_size()); + auto entry = ::consensus::LedgerEnclave::get_entry(data, size); LOG_INFO_FMT( "Deserialising private ledger entry {} [{}]", @@ -1983,8 +1981,6 @@ namespace ccf recovery_store = std::make_shared( true /* Check transactions in order */, true /* Make use of historical secrets */); - recovery_store->set_max_transaction_size( - network.tables->get_max_transaction_size()); auto recovery_history = std::make_shared( *recovery_store, self,