diff --git a/CHANGELOG.md b/CHANGELOG.md index c1cae74bbec..625c0a247cf 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 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 ec7c776871c..bf6c609ae99 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": { @@ -476,6 +552,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 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", @@ -506,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": { @@ -550,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." }, @@ -592,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" } @@ -699,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": { @@ -708,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" }, @@ -769,7 +874,10 @@ "type": "string" } }, - "required": ["name", "address"], + "required": [ + "name", + "address" + ], "additionalProperties": false }, "recovery_decision_protocol": { @@ -788,7 +896,10 @@ "type": "string" } }, - "required": ["name", "address"], + "required": [ + "name", + "address" + ], "additionalProperties": false } }, @@ -802,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": { @@ -837,7 +960,10 @@ "type": "object", "properties": { "role": { - "enum": ["primary", "backup"], + "enum": [ + "primary", + "backup" + ], "default": "primary" } }, @@ -863,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/include/ccf/node/startup_config.h b/include/ccf/node/startup_config.h index 3d6cef41379..a0a56c37641 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 d11b775b26e..bbf9c53ce82 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 15a1a6df6e0..52dae4f5d10 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 @@ -25,7 +27,22 @@ namespace consensus { 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; + // 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( + "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 5e864b7f9e0..20d295b956c 100644 --- a/src/enclave/enclave.h +++ b/src/enclave/enclave.h @@ -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_, @@ -101,6 +102,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 fe1b1153098..b829d915e00 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 f383563bf14..4b128a465dd 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 13e5645fc16..abab15950a3 100644 --- a/src/kv/generic_serialise_wrapper.h +++ b/src/kv/generic_serialise_wrapper.h @@ -23,6 +23,7 @@ namespace ccf::kv TxID tx_id; EntryType entry_type; SerialisedEntryFlags header_flags; + size_t max_transaction_size; std::shared_ptr crypto_util; @@ -67,10 +68,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_) { @@ -174,8 +178,16 @@ namespace ccf::kv } 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(); @@ -302,7 +314,7 @@ namespace ccf::kv size_t size, ccf::kv::Term& term, EntryFlags& flags, - bool historical_hint) override + bool historical_hint = false) override { current_reader = &public_reader; const auto* data_ = data; @@ -320,7 +332,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 7a85981ad16..96de7bc5857 100644 --- a/src/kv/kv_types.h +++ b/src/kv/kv_types.h @@ -30,6 +30,7 @@ #include #include #include +#include #include #include #include @@ -383,6 +384,13 @@ namespace ccf::kv } }; + class MaxTransactionSizeExceeded : public std::logic_error + { + public: + MaxTransactionSizeExceeded(const std::string& msg) : std::logic_error(msg) + {} + }; + class TxHistory { public: @@ -722,6 +730,7 @@ namespace ccf::kv virtual std::shared_ptr get_history() = 0; virtual std::shared_ptr get_chunker() = 0; virtual EncryptorPtr get_encryptor() = 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/serialised_entry_format.h b/src/kv/serialised_entry_format.h index 46e0650fd9c..e0aef274560 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,18 @@ namespace ccf::kv static constexpr size_t serialised_entry_header_size = sizeof(SerialisedEntryHeader); -} \ No newline at end of file + + static inline std::string describe_serialised_entry_size_error( + size_t entry_size, size_t max_entry_size) + { + return fmt::format( + "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 f82df0588f9..1f461385d3e 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_; + } + + [[nodiscard]] size_t get_max_transaction_size() const override + { + return max_transaction_size; + } + void set_snapshotter(const SnapshotterPtr& snapshotter_) { snapshotter = snapshotter_; @@ -387,6 +414,10 @@ namespace ccf::kv std::unique_ptr snapshot) override { auto e = get_encryptor(); + // 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); } @@ -405,6 +436,9 @@ namespace ccf::kv ccf::kv::Term term = 0; ccf::kv::EntryFlags entry_flags = {}; + // 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()) { diff --git a/src/kv/test/kv_serialisation.cpp b/src/kv/test/kv_serialisation.cpp index 92078bc2478..857c16f9df1 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 @@ -174,6 +175,91 @@ 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( + "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); + + MapTypes::StringString map("public:pub_map"); + + { + auto tx = kv_store.create_tx(); + auto handle = tx.rw(map); + 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( + kv_store_target.deserialize(latest_data.value())->apply() == + ccf::kv::ApplyResult::PASS); +} + +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 08ed6f959e9..7ba6dd7caae 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 55fd7b7c81f..d12e4041f22 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) diff --git a/src/node/rpc/frontend.h b/src/node/rpc/frontend.h index e6b00d3774a..e998558396f 100644 --- a/src/node/rpc/frontend.h +++ b/src/node/rpc/frontend.h @@ -950,6 +950,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 b6a0bf18b51..7de8e0084f7 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 260fb9c924f..c0d453a0382 100644 --- a/tests/infra/e2e_args.py +++ b/tests/infra/e2e_args.py @@ -257,6 +257,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 d972a5311a6..c49130cd35b 100644 --- a/tests/infra/network.py +++ b/tests/infra/network.py @@ -193,6 +193,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 2c4202b9d0f..131005eb5f2 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" * 1024 * 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,23 @@ 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) + # 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, + 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 +110,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()