diff --git a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs index c651d9d3105..9095295425f 100644 --- a/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs +++ b/packages/rs-dpp/src/data_contract/methods/validate_document/v0/mod.rs @@ -5,6 +5,7 @@ use crate::data_contract::document_type::DocumentType; use crate::consensus::basic::document::{ DocumentFieldMaxSizeExceededError, InvalidDocumentTypeError, }; +use crate::consensus::basic::value_error::ValueError; use crate::consensus::basic::BasicError; use crate::consensus::ConsensusError; use crate::data_contract::schema::DataContractSchemaMethodsV0; @@ -46,13 +47,25 @@ impl DataContract { )); }; + if let Some(max_depth) = platform_version.system_limits.max_document_value_depth { + if let Some(actual_depth) = value.first_depth_exceeding(max_depth as usize) { + return Ok(SimpleConsensusValidationResult::new_with_error( + ConsensusError::BasicError(BasicError::ValueError( + ValueError::new_from_string(format!( + "document value depth {actual_depth} exceeds system maximum {max_depth}" + )), + )), + )); + } + } + let validator = document_type.json_schema_validator_ref().deref(); if let Some((key, size)) = value.has_data_larger_than(platform_version.system_limits.max_field_value_size) { let field = match key { - Some(Value::Text(field)) => field, + Some(Value::Text(field)) => field.clone(), _ => "".to_string(), }; return Ok(SimpleConsensusValidationResult::new_with_error( @@ -108,3 +121,111 @@ impl DataContract { self.validate_document_properties_v0(name, document.properties().into(), platform_version) } } + +#[cfg(all(test, feature = "fixtures-and-mocks"))] +mod tests { + use super::DataContractDocumentValidationMethodsV0; + use crate::consensus::basic::value_error::ValueError; + use crate::consensus::basic::BasicError; + use crate::consensus::ConsensusError; + use crate::data_contract::created_data_contract::CreatedDataContract; + use crate::tests::fixtures::get_data_contract_fixture; + use platform_value::Value; + use platform_version::version::PlatformVersion; + + fn data_contract() -> CreatedDataContract { + let platform_version = PlatformVersion::latest(); + get_data_contract_fixture(None, 0, platform_version.protocol_version) + } + + fn nested_document_value(container_count: usize, leaf: Value) -> Value { + let nested = (0..container_count).fold(leaf, |value, depth| { + if depth % 2 == 0 { + Value::Array(vec![value]) + } else { + Value::Map(vec![(Value::Text("nested".to_owned()), value)]) + } + }); + + Value::Map(vec![(Value::Text("name".to_owned()), nested)]) + } + + #[test] + fn should_reject_excessive_document_value_depth_before_field_size_validation() { + let platform_version = PlatformVersion::latest(); + let data_contract = data_contract().data_contract_owned(); + let max_depth = platform_version + .system_limits + .max_document_value_depth + .expect("latest protocol should enforce document value depth"); + let value = nested_document_value( + max_depth as usize, + Value::Text( + "x".repeat(platform_version.system_limits.max_field_value_size as usize + 1), + ), + ); + + let result = data_contract + .validate_document_properties("noTimeDocument", value, platform_version) + .expect("validation should return a consensus result"); + + let Some(ConsensusError::BasicError(BasicError::ValueError(ValueError { .. }))) = + result.first_error() + else { + panic!("expected document value depth error, got {result:?}"); + }; + assert_eq!( + result.first_error().expect("expected an error").to_string(), + format!( + "document value depth {} exceeds system maximum {max_depth}", + max_depth + 1 + ) + ); + } + + #[test] + fn should_allow_document_value_depth_at_the_limit() { + let platform_version = PlatformVersion::latest(); + let data_contract = data_contract().data_contract_owned(); + let max_depth = platform_version + .system_limits + .max_document_value_depth + .expect("latest protocol should enforce document value depth"); + let value = nested_document_value( + max_depth as usize - 1, + Value::Text( + "x".repeat(platform_version.system_limits.max_field_value_size as usize + 1), + ), + ); + + let result = data_contract + .validate_document_properties("noTimeDocument", value, platform_version) + .expect("validation should return a consensus result"); + + assert!(matches!( + result.first_error(), + Some(ConsensusError::BasicError( + BasicError::DocumentFieldMaxSizeExceededError(_) + )) + )); + } + + #[test] + fn should_preserve_valid_document_properties() { + let platform_version = PlatformVersion::latest(); + let data_contract = data_contract().data_contract_owned(); + let value = Value::Map(vec![( + Value::Text("name".to_owned()), + Value::Text("Alice".to_owned()), + )]); + + let result = data_contract + .validate_document_properties("noTimeDocument", value, platform_version) + .expect("validation should return a consensus result"); + + assert!( + result.is_valid(), + "expected valid properties, got {result:?}" + ); + } +} diff --git a/packages/rs-dpp/src/state_transition/mod.rs b/packages/rs-dpp/src/state_transition/mod.rs index c6ca575dfcd..871a754674f 100644 --- a/packages/rs-dpp/src/state_transition/mod.rs +++ b/packages/rs-dpp/src/state_transition/mod.rs @@ -797,7 +797,14 @@ impl StateTransition { bytes: &[u8], platform_version: &PlatformVersion, ) -> Result { - let state_transition = StateTransition::deserialize_from_bytes(bytes)?; + let max_value_depth = platform_version + .system_limits + .max_document_value_depth + .map(usize::from); + let state_transition = + platform_value::with_value_decode_depth_limit(max_value_depth, || { + StateTransition::deserialize_from_bytes(bytes) + })?; #[cfg(all(feature = "state-transitions", feature = "validation"))] { let active_version_range = state_transition.active_version_range(); diff --git a/packages/rs-dpp/src/state_transition/serialization.rs b/packages/rs-dpp/src/state_transition/serialization.rs index d02fb91d016..0b414feb802 100644 --- a/packages/rs-dpp/src/state_transition/serialization.rs +++ b/packages/rs-dpp/src/state_transition/serialization.rs @@ -17,6 +17,7 @@ mod tests { use base64::engine::general_purpose::STANDARD; use base64::Engine; use platform_value::string_encoding::Encoding; + use platform_value::{Identifier, Value}; use crate::bls::native_bls::NativeBlsModule; use crate::data_contract::accessors::v0::DataContractV0Getters; use crate::identity::state_transition::AssetLockProved; @@ -32,10 +33,19 @@ mod tests { use crate::state_transition::data_contract_update_transition::{ DataContractUpdateTransition, DataContractUpdateTransitionV0, }; + use crate::state_transition::batch_transition::batched_transition::document_create_transition::{ + DocumentCreateTransition, DocumentCreateTransitionV0, + }; + use crate::state_transition::batch_transition::batched_transition::document_transition::{ + DocumentTransition, DocumentTransitionV0Methods, + }; use crate::state_transition::batch_transition::batched_transition::document_transition_action_type::DocumentTransitionActionType; + use crate::state_transition::batch_transition::batched_transition::BatchedTransition; use crate::state_transition::batch_transition::{ BatchTransition, BatchTransitionV1, }; + use crate::state_transition::batch_transition::document_base_transition::v0::DocumentBaseTransitionV0; + use crate::state_transition::batch_transition::document_base_transition::DocumentBaseTransition; use crate::state_transition::identity_create_transition::accessors::IdentityCreateTransitionAccessorsV0; use crate::state_transition::identity_create_transition::v0::IdentityCreateTransitionV0; use crate::state_transition::identity_create_transition::IdentityCreateTransition; @@ -412,6 +422,52 @@ mod tests { assert_eq!(state_transition, recovered_state_transition); } + #[test] + fn document_batch_rejects_excessive_value_depth_during_decode() { + let nested = (0..300).fold(Value::Null, |value, _| Value::Array(vec![value])); + let document_transition = + DocumentTransition::Create(DocumentCreateTransition::V0(DocumentCreateTransitionV0 { + base: DocumentBaseTransition::V0(DocumentBaseTransitionV0 { + id: Identifier::default(), + identity_contract_nonce: 1, + document_type_name: "test".to_string(), + data_contract_id: Identifier::default(), + }), + entropy: [0; 32], + data: BTreeMap::from([("nested".to_string(), nested)]), + prefunded_voting_balance: None, + })); + assert_eq!( + document_transition.first_data_depth_exceeding(256), + Some(257) + ); + + let state_transition = StateTransition::Batch(BatchTransition::V1(BatchTransitionV1 { + transitions: vec![BatchedTransition::Document(document_transition)], + ..Default::default() + })); + let bytes = state_transition + .serialize_to_bytes() + .expect("the state transition should encode below the byte limit"); + assert!( + bytes.len() as u64 + <= PlatformVersion::latest() + .system_limits + .max_state_transition_size + ); + + // The intentionally invalid transition is no longer needed after encoding. Avoid walking + // its recursive data during drop so this regression test only exercises decoder behavior. + std::mem::forget(state_transition); + + let error = + StateTransition::deserialize_from_bytes_in_version(&bytes, PlatformVersion::latest()) + .expect_err("excessive nesting must be rejected during decode"); + assert!(error + .to_string() + .contains("value nesting depth 257 exceeds maximum 256")); + } + #[test] fn deserialize_empty_bytes_should_fail() { let result = StateTransition::deserialize_from_bytes(&[]); diff --git a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs index 1e7886aa067..f3b3d336c49 100644 --- a/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs +++ b/packages/rs-dpp/src/state_transition/state_transitions/document/batch_transition/batched_transition/document_transition.rs @@ -269,6 +269,11 @@ pub trait DocumentTransitionV0Methods { fn data_contract_id(&self) -> Identifier; /// get the data of the transition if exits fn data(&self) -> Option<&BTreeMap>; + /// Returns the first document-data container depth greater than `max_depth`. + /// + /// The document's root property map counts as depth one. The traversal borrows transition + /// data so invalid nesting can be rejected before action construction clones recursive values. + fn first_data_depth_exceeding(&self, max_depth: usize) -> Option; /// get the revision of transition if exits fn revision(&self) -> Option; @@ -346,6 +351,20 @@ impl DocumentTransitionV0Methods for DocumentTransition { } } + fn first_data_depth_exceeding(&self, max_depth: usize) -> Option { + let data = self.data()?; + + if max_depth == 0 { + return Some(1); + } + + data.values().find_map(|value| { + value + .first_depth_exceeding(max_depth - 1) + .map(|depth| depth + 1) + }) + } + fn revision(&self) -> Option { match self { DocumentTransition::Create(_) => Some(1), @@ -559,6 +578,25 @@ mod tests { assert!(transition.get_dynamic_property("anything").is_none()); } + #[test] + fn data_depth_check_borrows_create_and_replace_properties() { + let data = BTreeMap::from([( + "nested".to_string(), + Value::Array(vec![Value::Array(vec![Value::Null])]), + )]); + let create = make_create_transition(data.clone()); + let replace = make_replace_transition(data); + + // Root property map is depth 1 and the two arrays reach depth 3. + assert_eq!(create.first_data_depth_exceeding(2), Some(3)); + assert_eq!(replace.first_data_depth_exceeding(3), None); + } + + #[test] + fn data_depth_check_ignores_transitions_without_document_data() { + assert_eq!(make_delete_transition().first_data_depth_exceeding(0), None); + } + #[test] fn get_dynamic_property_returns_none_for_purchase() { let transition = make_purchase_transition(); diff --git a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs index 8220f9f6a02..ed532d1eec8 100644 --- a/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs +++ b/packages/rs-drive-abci/src/execution/validation/state_transition/state_transitions/batch/transformer/v0/mod.rs @@ -28,6 +28,7 @@ use std::sync::Arc; use crate::error::Error; use crate::platform_types::platform::PlatformStateRef; use dpp::consensus::basic::document::{DataContractNotPresentError, InvalidDocumentTypeError}; +use dpp::consensus::basic::value_error::ValueError; use dpp::consensus::basic::BasicError; use dpp::consensus::state::document::document_not_found_error::DocumentNotFoundError; @@ -745,6 +746,21 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition { execution_context: &mut StateTransitionExecutionContext, platform_version: &PlatformVersion, ) -> Result, Error> { + if let Some(max_depth) = platform_version.system_limits.max_document_value_depth { + if let Some(actual_depth) = transition.first_data_depth_exceeding(max_depth as usize) { + return Self::failed_per_transition_action( + transition.base(), + owner_id, + vec![ConsensusError::BasicError(BasicError::ValueError( + ValueError::new_from_string(format!( + "document value depth {actual_depth} exceeds system maximum {max_depth}" + )), + ))], + platform_version, + ); + } + } + match transition { DocumentTransition::Create(document_create_transition) => { let (document_create_action, fee_result) = DocumentCreateTransitionAction::try_from_document_borrowed_create_transition_with_contract_lookup( diff --git a/packages/rs-platform-value/src/lib.rs b/packages/rs-platform-value/src/lib.rs index 5900ccdeada..d4ecf0ae08e 100644 --- a/packages/rs-platform-value/src/lib.rs +++ b/packages/rs-platform-value/src/lib.rs @@ -43,12 +43,41 @@ pub use types::identifier::{Identifier, IdentifierBytes32, IDENTIFIER_MEDIA_TYPE pub use value_serialization::{from_value, to_value}; +use bincode::de::Decoder; +use bincode::error::{AllowedEnumVariants, DecodeError}; use bincode::{Decode, Encode}; pub use patch::{patch, Patch}; +/// The defensive nesting limit used when decoding a [`Value`] without an explicit scope. +pub const DEFAULT_MAX_VALUE_DECODE_DEPTH: usize = 256; + +std::thread_local! { + static VALUE_DECODE_DEPTH_LIMIT: std::cell::Cell> = + const { std::cell::Cell::new(Some(DEFAULT_MAX_VALUE_DECODE_DEPTH)) }; +} + +/// Runs `decode` with the requested container-depth limit for [`Value`] decoding on this thread. +/// +/// This is used by version-aware protocol decoders so historical versions can retain their +/// original behavior while current versions reject excessive nesting before constructing a +/// recursive value tree. +pub fn with_value_decode_depth_limit(max_depth: Option, decode: impl FnOnce() -> T) -> T { + struct RestoreDepthLimit(Option); + + impl Drop for RestoreDepthLimit { + fn drop(&mut self) { + VALUE_DECODE_DEPTH_LIMIT.with(|limit| limit.set(self.0)); + } + } + + let previous_limit = VALUE_DECODE_DEPTH_LIMIT.with(|limit| limit.replace(max_depth)); + let _restore_limit = RestoreDepthLimit(previous_limit); + decode() +} + /// A representation of a dynamic value that can handled dynamically #[non_exhaustive] -#[derive(Clone, Debug, PartialEq, PartialOrd, Encode, Decode)] +#[derive(Clone, Debug, PartialEq, PartialOrd, Encode)] pub enum Value { /// A u128 integer U128(u128), @@ -122,6 +151,164 @@ pub enum Value { Map(ValueMap), } +enum ValueDecodeFrame { + Array { + values: Vec, + remaining: usize, + }, + Map { + entries: ValueMap, + remaining: usize, + pending_key: Option, + }, +} + +fn decode_value_container_len(decoder: &mut D) -> Result +where + D: Decoder, +{ + let len = >::decode(decoder)?; + len.try_into() + .map_err(|_| DecodeError::OutsideUsizeRange(len)) +} + +fn validate_value_decode_depth(depth: usize) -> Result<(), DecodeError> { + VALUE_DECODE_DEPTH_LIMIT.with(|limit| match limit.get() { + Some(max_depth) if depth > max_depth => Err(DecodeError::OtherString(format!( + "value nesting depth {depth} exceeds maximum {max_depth}" + ))), + _ => Ok(()), + }) +} + +impl Decode for Value { + fn decode>(decoder: &mut D) -> Result { + let mut frames = Vec::::new(); + let mut completed_value = None; + + loop { + if let Some(value) = completed_value.take() { + let Some(frame) = frames.last_mut() else { + return Ok(value); + }; + + match frame { + ValueDecodeFrame::Array { values, remaining } => { + values.push(value); + *remaining -= 1; + + if *remaining == 0 { + let ValueDecodeFrame::Array { values, .. } = + frames.pop().expect("the array frame was just observed") + else { + unreachable!("the observed frame changed") + }; + completed_value = Some(Value::Array(values)); + } else { + decoder.unclaim_bytes_read(std::mem::size_of::()); + } + } + ValueDecodeFrame::Map { + entries, + remaining, + pending_key, + } => { + if pending_key.is_none() { + *pending_key = Some(value); + } else { + let key = pending_key + .take() + .expect("the map frame was expecting a value"); + entries.push((key, value)); + *remaining -= 1; + + if *remaining == 0 { + let ValueDecodeFrame::Map { entries, .. } = + frames.pop().expect("the map frame was just observed") + else { + unreachable!("the observed frame changed") + }; + completed_value = Some(Value::Map(entries)); + } else { + decoder.unclaim_bytes_read(std::mem::size_of::<(Value, Value)>()); + } + } + } + } + + continue; + } + + let variant_index = >::decode(decoder)?; + completed_value = Some(match variant_index { + 0 => Value::U128(Decode::decode(decoder)?), + 1 => Value::I128(Decode::decode(decoder)?), + 2 => Value::U64(Decode::decode(decoder)?), + 3 => Value::I64(Decode::decode(decoder)?), + 4 => Value::U32(Decode::decode(decoder)?), + 5 => Value::I32(Decode::decode(decoder)?), + 6 => Value::U16(Decode::decode(decoder)?), + 7 => Value::I16(Decode::decode(decoder)?), + 8 => Value::U8(Decode::decode(decoder)?), + 9 => Value::I8(Decode::decode(decoder)?), + 10 => Value::Bytes(Decode::decode(decoder)?), + 11 => Value::Bytes20(Decode::decode(decoder)?), + 12 => Value::Bytes32(Decode::decode(decoder)?), + 13 => Value::Bytes36(Decode::decode(decoder)?), + 14 => Value::EnumU8(Decode::decode(decoder)?), + 15 => Value::EnumString(Decode::decode(decoder)?), + 16 => Value::Identifier(Decode::decode(decoder)?), + 17 => Value::Float(Decode::decode(decoder)?), + 18 => Value::Text(Decode::decode(decoder)?), + 19 => Value::Bool(Decode::decode(decoder)?), + 20 => Value::Null, + 21 => { + validate_value_decode_depth(frames.len() + 1)?; + let len = decode_value_container_len(decoder)?; + decoder.claim_container_read::(len)?; + + if len == 0 { + Value::Array(Vec::new()) + } else { + frames.push(ValueDecodeFrame::Array { + values: Vec::with_capacity(len), + remaining: len, + }); + decoder.unclaim_bytes_read(std::mem::size_of::()); + continue; + } + } + 22 => { + validate_value_decode_depth(frames.len() + 1)?; + let len = decode_value_container_len(decoder)?; + decoder.claim_container_read::<(Value, Value)>(len)?; + + if len == 0 { + Value::Map(Vec::new()) + } else { + frames.push(ValueDecodeFrame::Map { + entries: Vec::with_capacity(len), + remaining: len, + pending_key: None, + }); + decoder.unclaim_bytes_read(std::mem::size_of::<(Value, Value)>()); + continue; + } + } + found => { + return Err(DecodeError::UnexpectedVariant { + type_name: std::any::type_name::(), + allowed: &AllowedEnumVariants::Range { min: 0, max: 22 }, + found, + }); + } + }); + } + } +} + +bincode::impl_borrow_decode!(Value); + impl Value { /// Returns true if the `Value` is an `Integer`. Returns false otherwise. /// @@ -1318,178 +1505,91 @@ impl Value { } } - /// can determine if there is any very big data in a value - pub fn has_data_larger_than(&self, size: u32) -> Option<(Option, u32)> { - match self { - Value::U128(_) => { - if size < 16 { - Some((None, 16)) - } else { - None - } - } - Value::I128(_) => { - if size < 16 { - Some((None, 16)) - } else { - None - } - } - Value::U64(_) => { - if size < 8 { - Some((None, 8)) - } else { - None - } - } - Value::I64(_) => { - if size < 8 { - Some((None, 8)) - } else { - None - } - } - Value::U32(_) => { - if size < 4 { - Some((None, 4)) - } else { - None - } - } - Value::I32(_) => { - if size < 4 { - Some((None, 4)) - } else { - None - } - } - Value::U16(_) => { - if size < 2 { - Some((None, 2)) - } else { - None - } - } - Value::I16(_) => { - if size < 2 { - Some((None, 2)) - } else { - None - } - } - Value::U8(_) => { - if size < 1 { - Some((None, 1)) - } else { - None - } - } - Value::I8(_) => { - if size < 1 { - Some((None, 1)) - } else { - None - } - } - Value::Bytes(bytes) => { - if (size as usize) < bytes.len() { - Some((None, bytes.len() as u32)) - } else { - None - } - } - Value::Bytes20(_) => { - if size < 20 { - Some((None, 20)) - } else { - None - } - } - Value::Bytes32(_) => { - if size < 32 { - Some((None, 32)) - } else { - None - } - } - Value::Bytes36(_) => { - if size < 36 { - Some((None, 36)) - } else { - None - } - } - Value::EnumU8(_) => { - if size < 1 { - Some((None, 1)) - } else { - None - } - } - Value::EnumString(strings) => { - let max_len = strings.iter().map(|string| string.len()).max(); - if let Some(max) = max_len { - if max > size as usize { - Some((None, max as u32)) - } else { - None + /// Returns the first container depth greater than `max_depth`. + /// + /// Maps and arrays each add one level, including a container at the root. Map keys are + /// included because they are also attacker-controlled [`Value`] instances. The traversal is + /// iterative so checking an invalid value cannot itself consume attacker-selected stack depth. + pub fn first_depth_exceeding(&self, max_depth: usize) -> Option { + let mut pending = vec![(self, 0usize)]; + + while let Some((value, parent_depth)) = pending.pop() { + let children: &[_] = match value { + Value::Array(values) => values, + Value::Map(map) => { + let depth = parent_depth + 1; + if depth > max_depth { + return Some(depth); } - } else { - None - } - } - Value::Identifier(_) => { - if size < 32 { - Some((None, 32)) - } else { - None - } - } - Value::Float(_) => { - if size < 8 { - Some((None, 8)) - } else { - None - } - } - Value::Text(string) => { - if string.len() > size as usize { - Some((None, string.len() as u32)) - } else { - None + for (key, value) in map.iter().rev() { + pending.push((value, depth)); + pending.push((key, depth)); + } + continue; } + _ => continue, + }; + + let depth = parent_depth + 1; + if depth > max_depth { + return Some(depth); } - Value::Bool(_) => { - if size < 1 { - Some((None, 1)) - } else { - None + pending.extend(children.iter().rev().map(|value| (value, depth))); + } + + None + } + + /// Determines whether any scalar data in this value is larger than `size`. + /// + /// Container traversal is iterative to keep stack use independent of attacker-controlled + /// nesting. The reported map key or array child remains the outermost value that identifies + /// the oversized field, matching the previous recursive behavior. + pub fn has_data_larger_than(&self, size: u32) -> Option<(Option<&Value>, u32)> { + let mut pending = vec![(self, None)]; + + while let Some((value, reported_value)) = pending.pop() { + let actual_size = match value { + Value::U128(_) | Value::I128(_) => (size < 16).then_some(16), + Value::U64(_) | Value::I64(_) | Value::Float(_) => (size < 8).then_some(8), + Value::U32(_) | Value::I32(_) => (size < 4).then_some(4), + Value::U16(_) | Value::I16(_) => (size < 2).then_some(2), + Value::U8(_) | Value::I8(_) | Value::EnumU8(_) | Value::Bool(_) | Value::Null => { + (size < 1).then_some(1) } - } - Value::Null => { - if size < 1 { - Some((None, 1)) - } else { - None + Value::Bytes(bytes) => (bytes.len() > size as usize).then_some(bytes.len() as u32), + Value::Bytes20(_) => (size < 20).then_some(20), + Value::Bytes32(_) | Value::Identifier(_) => (size < 32).then_some(32), + Value::Bytes36(_) => (size < 36).then_some(36), + Value::EnumString(strings) => strings + .iter() + .map(|string| string.len()) + .max() + .filter(|actual_size| *actual_size > size as usize) + .map(|actual_size| actual_size as u32), + Value::Text(string) => { + (string.len() > size as usize).then_some(string.len() as u32) } - } - Value::Array(values) => { - for value in values { - if let Some(result) = value.has_data_larger_than(size) { - return Some((Some(value.clone()), result.1)); + Value::Array(values) => { + for value in values.iter().rev() { + pending.push((value, reported_value.or(Some(value)))); } + continue; } - None - } - Value::Map(map) => { - for (key, value) in map { - if let Some(result) = value.has_data_larger_than(size) { - return Some((Some(key.clone()), result.1)); + Value::Map(map) => { + for (key, value) in map.iter().rev() { + pending.push((value, reported_value.or(Some(key)))); } + continue; } - None + }; + + if let Some(actual_size) = actual_size { + return Some((reported_value, actual_size)); } } + + None } } diff --git a/packages/rs-platform-value/tests/coverage_tests.rs b/packages/rs-platform-value/tests/coverage_tests.rs index cae884280c5..182467d53f0 100644 --- a/packages/rs-platform-value/tests/coverage_tests.rs +++ b/packages/rs-platform-value/tests/coverage_tests.rs @@ -23,8 +23,9 @@ use platform_value::patch::{diff, merge}; use platform_value::{ - from_value, patch, platform_value, to_value, BinaryData, Error, Identifier, - IntegerReplacementType, Patch, Value, ValueMap, ValueMapHelper, + from_value, patch, platform_value, to_value, with_value_decode_depth_limit, BinaryData, Error, + Identifier, IntegerReplacementType, Patch, Value, ValueMap, ValueMapHelper, + DEFAULT_MAX_VALUE_DECODE_DEPTH, }; use std::collections::BTreeMap; @@ -272,6 +273,229 @@ mod has_data_larger_than_tests { assert!(v.has_data_larger_than(5).is_some()); assert!(v.has_data_larger_than(100).is_none()); } + + #[test] + fn reports_the_outermost_field_from_nested_containers() { + let v = Value::Map(vec![( + Value::Text("outer".into()), + Value::Array(vec![Value::Map(vec![( + Value::Text("inner".into()), + Value::Text("oversized".into()), + )])]), + )]); + + let (reported_value, actual_size) = v + .has_data_larger_than(5) + .expect("the oversized value should be reported"); + assert_eq!(reported_value, Some(&Value::Text("outer".into()))); + assert_eq!(actual_size, 9); + } + + #[test] + fn iteratively_walks_deeply_nested_values() { + let nested = (0..10_000).fold(Value::Null, |value, _| Value::Array(vec![value])); + let value = Value::Map(vec![(Value::Text("field".into()), nested)]); + + assert!(value.has_data_larger_than(1).is_none()); + + // Avoid recursively dropping the intentionally extreme test value. + std::mem::forget(value); + } + + #[test] + fn reports_deeply_nested_oversized_root_array_without_cloning() { + let value = (0..8_000).fold(Value::Text("oversized".into()), |value, _| { + Value::Array(vec![value]) + }); + + let (reported_value, actual_size) = value + .has_data_larger_than(5) + .expect("the oversized value should be reported"); + assert!(matches!(reported_value, Some(Value::Array(_)))); + assert_eq!(actual_size, 9); + + // Avoid recursively dropping the intentionally extreme test value. + std::mem::forget(value); + } +} + +mod value_decode_depth_tests { + use super::*; + + fn encoded_nested_arrays(depth: usize) -> Vec { + let config = bincode::config::standard(); + let array_prefix = [ + bincode::encode_to_vec(21u32, config).expect("array variant should encode"), + bincode::encode_to_vec(1u64, config).expect("array length should encode"), + ] + .concat(); + let null = bincode::encode_to_vec(20u32, config).expect("null variant should encode"); + let mut encoded = Vec::with_capacity(array_prefix.len() * depth + null.len()); + + for _ in 0..depth { + encoded.extend_from_slice(&array_prefix); + } + encoded.extend_from_slice(&null); + encoded + } + + #[test] + fn iterative_decoder_preserves_every_value_variant() { + let values = vec![ + Value::U128(u128::MAX), + Value::I128(i128::MIN), + Value::U64(u64::MAX), + Value::I64(i64::MIN), + Value::U32(u32::MAX), + Value::I32(i32::MIN), + Value::U16(u16::MAX), + Value::I16(i16::MIN), + Value::U8(u8::MAX), + Value::I8(i8::MIN), + Value::Bytes(vec![1, 2, 3]), + Value::Bytes20([4; 20]), + Value::Bytes32([5; 32]), + Value::Bytes36([6; 36]), + Value::EnumU8(vec![7, 8]), + Value::EnumString(vec!["a".into(), "b".into()]), + Value::Identifier([9; 32]), + Value::Float(1.25), + Value::Text("text".into()), + Value::Bool(true), + Value::Null, + Value::Array(vec![Value::Null, Value::U8(1)]), + Value::Map(vec![ + (Value::Text("first".into()), Value::Array(Vec::new())), + (Value::Array(vec![Value::Null]), Value::Map(Vec::new())), + ]), + ]; + let config = bincode::config::standard(); + + for value in values { + let encoded = bincode::encode_to_vec(&value, config).expect("value should encode"); + let (decoded, consumed) = bincode::decode_from_slice::(&encoded, config) + .expect("value should decode"); + assert_eq!(decoded, value); + assert_eq!(consumed, encoded.len()); + } + } + + #[test] + fn rejects_excessive_nesting_during_iterative_decode() { + let config = bincode::config::standard(); + let encoded = encoded_nested_arrays(4_000); + + let result = bincode::decode_from_slice::(&encoded, config); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("value nesting depth 257 exceeds maximum 256")); + } + + #[test] + fn accepts_nesting_at_the_decode_limit() { + let encoded = encoded_nested_arrays(DEFAULT_MAX_VALUE_DECODE_DEPTH); + let (value, consumed) = + bincode::decode_from_slice::(&encoded, bincode::config::standard()) + .expect("nesting at the limit should decode"); + + assert_eq!(consumed, encoded.len()); + assert_eq!( + value.first_depth_exceeding(DEFAULT_MAX_VALUE_DECODE_DEPTH), + None + ); + std::mem::forget(value); + } + + #[test] + fn scoped_decode_limit_is_restored() { + let encoded = encoded_nested_arrays(2); + let config = bincode::config::standard(); + + let limited = with_value_decode_depth_limit(Some(1), || { + bincode::decode_from_slice::(&encoded, config) + }); + assert!(limited.is_err()); + + let (value, _) = bincode::decode_from_slice::(&encoded, config) + .expect("the default depth limit should be restored"); + assert_eq!(value.first_depth_exceeding(2), None); + } + + #[test] + fn scoped_limit_counts_recursive_map_keys() { + let value = Value::Map(vec![( + Value::Map(vec![(Value::Null, Value::Null)]), + Value::Null, + )]); + let config = bincode::config::standard(); + let encoded = bincode::encode_to_vec(&value, config).expect("value should encode"); + + let result = with_value_decode_depth_limit(Some(1), || { + bincode::decode_from_slice::(&encoded, config) + }); + assert!(result.is_err()); + assert!(result + .unwrap_err() + .to_string() + .contains("value nesting depth 2 exceeds maximum 1")); + } + + #[test] + fn unbounded_scope_still_decodes_deep_values_iteratively() { + let encoded = encoded_nested_arrays(4_000); + let config = bincode::config::standard(); + let (value, consumed) = with_value_decode_depth_limit(None, || { + bincode::decode_from_slice::(&encoded, config) + }) + .expect("an unbounded scope should preserve legacy decoding"); + + assert_eq!(consumed, encoded.len()); + assert_eq!(value.first_depth_exceeding(3_999), Some(4_000)); + std::mem::forget(value); + } +} + +mod value_depth_tests { + use super::*; + + #[test] + fn accepts_container_depth_at_the_limit() { + let value = Value::Map(vec![( + Value::Text("field".into()), + Value::Array(vec![Value::Null]), + )]); + + assert_eq!(value.first_depth_exceeding(2), None); + } + + #[test] + fn rejects_array_map_and_alternating_depth_over_the_limit() { + let arrays = Value::Array(vec![Value::Array(vec![Value::Null])]); + let maps = Value::Map(vec![( + Value::Text("outer".into()), + Value::Map(vec![(Value::Text("inner".into()), Value::Null)]), + )]); + let alternating = Value::Array(vec![Value::Map(vec![( + Value::Text("field".into()), + Value::Array(vec![Value::Null]), + )])]); + + assert_eq!(arrays.first_depth_exceeding(1), Some(2)); + assert_eq!(maps.first_depth_exceeding(1), Some(2)); + assert_eq!(alternating.first_depth_exceeding(2), Some(3)); + } + + #[test] + fn includes_recursive_map_keys_in_the_limit() { + let value = Value::Map(vec![( + Value::Array(vec![Value::Array(vec![Value::Null])]), + Value::Null, + )]); + + assert_eq!(value.first_depth_exceeding(2), Some(3)); + } } // =========================================================================== diff --git a/packages/rs-platform-version/src/version/mocks/v2_test.rs b/packages/rs-platform-version/src/version/mocks/v2_test.rs index 5923453f19d..24908524f52 100644 --- a/packages/rs-platform-version/src/version/mocks/v2_test.rs +++ b/packages/rs-platform-version/src/version/mocks/v2_test.rs @@ -500,6 +500,7 @@ pub const TEST_PLATFORM_V2: PlatformVersion = PlatformVersion { system_limits: SystemLimits { estimated_contract_max_serialized_size: 16384, max_field_value_size: 5000, + max_document_value_depth: None, max_state_transition_size: 20000, // Is different in this test version, not sure if this was a mistake max_transitions_in_documents_batch: 1, withdrawal_transactions_per_block_limit: 4, diff --git a/packages/rs-platform-version/src/version/system_limits/mod.rs b/packages/rs-platform-version/src/version/system_limits/mod.rs index 137681c7e25..f99d97b4a9e 100644 --- a/packages/rs-platform-version/src/version/system_limits/mod.rs +++ b/packages/rs-platform-version/src/version/system_limits/mod.rs @@ -5,6 +5,10 @@ pub mod v2; pub struct SystemLimits { pub estimated_contract_max_serialized_size: u16, pub max_field_value_size: u32, + /// Maximum number of nested map/array containers in document properties. + /// + /// `None` preserves the behavior of protocol versions that predate this limit. + pub max_document_value_depth: Option, /// Max size of a state transition in bytes. /// /// NOTE: This must be equal to the `max-tx-bytes` in the Tenderdash config @@ -26,3 +30,26 @@ pub struct SystemLimits { pub max_token_redemption_cycles: u32, pub max_shielded_transition_actions: u16, } + +#[cfg(test)] +mod tests { + use crate::version::PlatformVersion; + + #[test] + fn document_value_depth_limit_starts_at_protocol_version_12() { + assert_eq!( + PlatformVersion::get(11) + .expect("protocol version 11 should exist") + .system_limits + .max_document_value_depth, + None + ); + assert_eq!( + PlatformVersion::get(12) + .expect("protocol version 12 should exist") + .system_limits + .max_document_value_depth, + Some(256) + ); + } +} diff --git a/packages/rs-platform-version/src/version/system_limits/v1.rs b/packages/rs-platform-version/src/version/system_limits/v1.rs index 3d3ab899d21..ca7da24bf5a 100644 --- a/packages/rs-platform-version/src/version/system_limits/v1.rs +++ b/packages/rs-platform-version/src/version/system_limits/v1.rs @@ -2,7 +2,8 @@ use crate::version::system_limits::SystemLimits; pub const SYSTEM_LIMITS_V1: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, - max_field_value_size: 5120, //5 KiB + max_field_value_size: 5120, //5 KiB + max_document_value_depth: None, max_state_transition_size: 20480, //20 KiB // TODO: this is currently capped at 1 because the batch state-transition // pipeline has known correctness issues with multi-transition batches: diff --git a/packages/rs-platform-version/src/version/system_limits/v2.rs b/packages/rs-platform-version/src/version/system_limits/v2.rs index 04d2f490357..ec7b3a071a2 100644 --- a/packages/rs-platform-version/src/version/system_limits/v2.rs +++ b/packages/rs-platform-version/src/version/system_limits/v2.rs @@ -7,7 +7,10 @@ use crate::version::system_limits::SystemLimits; /// the bare asset-unlock transaction fee and too low a minimum for a Core `TxOut`. pub const SYSTEM_LIMITS_V2: SystemLimits = SystemLimits { estimated_contract_max_serialized_size: 16384, - max_field_value_size: 5120, //5 KiB + max_field_value_size: 5120, //5 KiB + // Use the protocol's existing data-contract schema-depth ceiling as the conservative + // instance budget, bounding pre-schema work well above known document requirements. + max_document_value_depth: Some(256), max_state_transition_size: 20480, //20 KiB max_transitions_in_documents_batch: 1, withdrawal_transactions_per_block_limit: 4,