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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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}"
)),
)),
));
}
Comment on lines +50 to +59

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Enforce the depth limit during decoding

This validation runs after attacker-controlled document values have already undergone recursive processing. An actual document-batch state transition containing 4,000 single-element arrays encoded to 8,149 bytes, below the 20,480-byte transition limit, and aborted with a stack overflow inside StateTransition::deserialize_from_bytes. Value derives recursive Decode, so the transition never reaches this deterministic ValueError. Values that survive decoding are also recursively cloned while create/replace actions are built and again when self.data().into() prepares validation. Enforce the depth budget in a custom or iterative decoder, then check borrowed transition data before action transformation performs recursive clones.

source: ['codex']

}

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(
Expand Down Expand Up @@ -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:?}"
);
}
}
9 changes: 8 additions & 1 deletion packages/rs-dpp/src/state_transition/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -797,7 +797,14 @@ impl StateTransition {
bytes: &[u8],
platform_version: &PlatformVersion,
) -> Result<Self, ProtocolError> {
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)
})?;
Comment on lines +800 to +807

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Blocking: Keep all over-depth documents on the same consensus path

The decoder applies max_document_value_depth to each Value, while document validation includes the enclosing BTreeMap<String, Value> as depth one. A property containing 256 nested arrays therefore has document depth 257 but Value depth 256: it decodes, reaches first_data_depth_exceeding, returns BasicError::ValueError, and on protocol v12 produces a BumpIdentityDataContractNonce action and paid error. With 257 nested arrays, the decoder instead raises DecodeError::OtherString; deserialization maps that to PlatformDeserializationError, raw-transition decoding maps it to SerializedObjectParsingError, and processing returns an unpaid error without the nonce action. prepare_proposal consequently retains the first transition but removes the second. This contradicts the PR's stated deterministic ValueError behavior and lets equivalent violations select different fee and nonce semantics. Preserve the iterative safety boundary, but coordinate it with document-depth validation through a typed depth failure or a separate defensive ceiling so every protocol depth violation follows the intended consensus path.

source: ['codex']

#[cfg(all(feature = "state-transitions", feature = "validation"))]
{
let active_version_range = state_transition.active_version_range();
Expand Down
56 changes: 56 additions & 0 deletions packages/rs-dpp/src/state_transition/serialization.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -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(&[]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Value>>;
/// 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<usize>;
/// get the revision of transition if exits
fn revision(&self) -> Option<Revision>;

Expand Down Expand Up @@ -346,6 +351,20 @@ impl DocumentTransitionV0Methods for DocumentTransition {
}
}

fn first_data_depth_exceeding(&self, max_depth: usize) -> Option<usize> {
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<Revision> {
match self {
DocumentTransition::Create(_) => Some(1),
Expand Down Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -745,6 +746,21 @@ impl BatchTransitionInternalTransformerV0 for BatchTransition {
execution_context: &mut StateTransitionExecutionContext,
platform_version: &PlatformVersion,
) -> Result<ConsensusValidationResult<BatchedTransitionAction>, 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(
Expand Down
Loading
Loading