Skip to content

Commit 1913702

Browse files
fix(drive): bound identifier text in subscription filters before decoding it
Addresses the third review of #5285: - Blocking: base58 decoding is quadratic in its input, and filter operands come from unauthenticated subscription requests. Identifier operands given as text longer than any 32-byte identifier (44 characters) are refused before decoding, for system identifier fields and identifier properties; long string-property operands are unaffected. - An empty byte-array operand no longer becomes null through the index-key round trip (its index form is empty), so `== []` and `IN [..., []]` work. - SDK: a contract stays queued for its proved refresh until it is rebound, so a cancelled or failed `next()` retries it; a checkpoint at the maximum height is rejected instead of overflowing the resume height. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
1 parent 47a61d5 commit 1913702

2 files changed

Lines changed: 160 additions & 12 deletions

File tree

‎packages/rs-drive/src/query/filter.rs‎

Lines changed: 121 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ use std::collections::BTreeMap;
3939
use dpp::data_contract::accessors::v0::DataContractV0Getters;
4040
use dpp::data_contract::document_type::methods::{DocumentTypeBasicMethods, DocumentTypeV0Methods};
4141
use dpp::data_contract::document_type::accessors::DocumentTypeV0Getters;
42-
use dpp::data_contract::document_type::DocumentTypeRef;
42+
use dpp::data_contract::document_type::{DocumentPropertyType, DocumentTypeRef};
4343
use dpp::data_contract::DataContract;
4444
use dpp::platform_value::Value;
4545
use dpp::state_transition::batch_transition::batched_transition::document_transition::DocumentTransitionV0Methods;
@@ -814,15 +814,32 @@ fn canonical_value_for_key(
814814
value: &Value,
815815
platform_version: &PlatformVersion,
816816
) -> Option<Value> {
817-
if let Some(property) = document_type.flattened_properties().get(key) {
818-
let encoded = property
819-
.property_type
820-
.encode_value_for_tree_keys(value)
821-
.ok()?;
822-
return property
823-
.property_type
824-
.decode_value_for_tree_keys(&encoded)
825-
.ok();
817+
let property_type = document_type
818+
.flattened_properties()
819+
.get(key)
820+
.map(|property| &property.property_type);
821+
// Base58 decoding takes time quadratic in its input, and a filter's operands come from
822+
// unauthenticated requests: refuse identifier text longer than any 32-byte identifier.
823+
let identifier = matches!(key, "$id" | "$ownerId" | "$creatorId" | "$moderatedBy")
824+
|| matches!(
825+
property_type,
826+
Some(
827+
DocumentPropertyType::Identifier | DocumentPropertyType::IdentifierWithReference(_)
828+
)
829+
);
830+
if identifier && matches!(value, Value::Text(text) if text.len() > MAX_BASE58_IDENTIFIER_LEN) {
831+
return None;
832+
}
833+
if let Some(property_type) = property_type {
834+
let encoded = property_type.encode_value_for_tree_keys(value).ok()?;
835+
// The index-key form of an empty byte array is empty, which decodes as null.
836+
if encoded.is_empty() && !value.is_null() {
837+
return match property_type {
838+
DocumentPropertyType::ByteArray(_) => Some(Value::Bytes(vec![])),
839+
_ => None,
840+
};
841+
}
842+
return property_type.decode_value_for_tree_keys(&encoded).ok();
826843
}
827844
let serialized = document_type
828845
.serialize_value_for_key(key, value, platform_version)
@@ -832,6 +849,10 @@ fn canonical_value_for_key(
832849
.ok()
833850
}
834851

852+
/// The longest base58 text of a 32-byte identifier.
853+
#[cfg(any(feature = "server", feature = "verify"))]
854+
const MAX_BASE58_IDENTIFIER_LEN: usize = 44;
855+
835856
/// Canonicalize a clause operand with `canonical`: element-wise for the list operands of
836857
/// `IN` and `BETWEEN*`, directly otherwise.
837858
#[cfg(any(feature = "server", feature = "verify"))]
@@ -3341,5 +3362,95 @@ mod tests {
33413362
TransitionCheckResult::Pass
33423363
);
33433364
}
3365+
3366+
#[test]
3367+
fn should_refuse_identifier_text_longer_than_any_identifier() {
3368+
let contract = contract();
3369+
for field in ["identifierField", "$id"] {
3370+
let clauses = if field == "$id" {
3371+
InternalClauses {
3372+
primary_key_equal_clause: Some(WhereClause {
3373+
field: field.to_string(),
3374+
operator: WhereOperator::Equal,
3375+
value: Value::Text("z".repeat(120_000)),
3376+
}),
3377+
..Default::default()
3378+
}
3379+
} else {
3380+
equal(field, Value::Text("z".repeat(120_000)))
3381+
};
3382+
let mut filter = DriveDocumentQueryFilter {
3383+
contract: &contract,
3384+
document_type_name: "withByteArrays".to_string(),
3385+
action_clauses: DocumentActionMatchClauses::Delete {
3386+
original_document_clauses: clauses,
3387+
},
3388+
};
3389+
assert!(
3390+
filter
3391+
.canonicalize_clause_values(PlatformVersion::latest())
3392+
.is_err(),
3393+
"{field}"
3394+
);
3395+
}
3396+
}
3397+
3398+
#[test]
3399+
fn should_keep_empty_byte_array_operands() {
3400+
let contract = contract();
3401+
let platform_version = PlatformVersion::latest();
3402+
for (operator, value) in [
3403+
(WhereOperator::Equal, Value::Bytes(vec![])),
3404+
(
3405+
WhereOperator::In,
3406+
Value::Array(vec![Value::Bytes(vec![]), Value::Bytes(vec![1])]),
3407+
),
3408+
] {
3409+
let mut filter = DriveDocumentQueryFilter {
3410+
contract: &contract,
3411+
document_type_name: "withByteArrays".to_string(),
3412+
action_clauses: DocumentActionMatchClauses::Create {
3413+
new_document_clauses: InternalClauses {
3414+
equal_clauses: if operator == WhereOperator::Equal {
3415+
BTreeMap::from([(
3416+
"byteArrayField".to_string(),
3417+
WhereClause {
3418+
field: "byteArrayField".to_string(),
3419+
operator,
3420+
value: value.clone(),
3421+
},
3422+
)])
3423+
} else {
3424+
BTreeMap::new()
3425+
},
3426+
in_clauses: if operator == WhereOperator::In {
3427+
vec![WhereClause {
3428+
field: "byteArrayField".to_string(),
3429+
operator,
3430+
value: value.clone(),
3431+
}]
3432+
} else {
3433+
vec![]
3434+
},
3435+
..Default::default()
3436+
},
3437+
},
3438+
};
3439+
filter
3440+
.canonicalize_clause_values(platform_version)
3441+
.expect("an empty byte array is a byte array");
3442+
assert!(filter.validate().is_valid(), "{operator:?}");
3443+
let transition = create(
3444+
&contract,
3445+
"withByteArrays",
3446+
BTreeMap::from([("byteArrayField".to_string(), Value::Bytes(vec![]))]),
3447+
);
3448+
assert_eq!(
3449+
filter.matches_document_transition(&transition, None, platform_version),
3450+
TransitionCheckResult::Pass,
3451+
"{operator:?}"
3452+
);
3453+
}
3454+
}
33443455
}
33453456
}

‎packages/rs-sdk/src/platform/subscriptions.rs‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -265,7 +265,8 @@ impl StateTransitionSubscription {
265265
/// Rebind the filters on contracts a delivered update changed to their current version,
266266
/// read with a proof. A contract that can no longer be read ends the subscription.
267267
async fn refresh_contracts(&mut self) -> Result<(), Error> {
268-
while let Some(data_contract_id) = self.contracts_to_refresh.pop_first() {
268+
// An id leaves the queue only once rebound, so a cancelled or failed refresh is retried.
269+
while let Some(data_contract_id) = self.contracts_to_refresh.first().copied() {
269270
let contract = DataContract::fetch(&self.sdk, data_contract_id)
270271
.await?
271272
.ok_or_else(|| {
@@ -276,6 +277,7 @@ impl StateTransitionSubscription {
276277
})?;
277278
self.resolved
278279
.rebind_data_contract(Arc::new(contract), self.sdk.version());
280+
self.contracts_to_refresh.remove(&data_contract_id);
279281
}
280282
Ok(())
281283
}
@@ -303,7 +305,13 @@ impl StateTransitionSubscription {
303305
};
304306
Ok(match responses {
305307
Responses::Checkpoint(checkpoint) => {
306-
self.resume_from = Some(checkpoint.block_height + 1);
308+
let resume_from = checkpoint.block_height.checked_add(1).ok_or_else(|| {
309+
Error::Generic(format!(
310+
"the node sent a checkpoint at height {}, after which no stream can resume",
311+
checkpoint.block_height
312+
))
313+
})?;
314+
self.resume_from = Some(resume_from);
307315
Some(SubscriptionEvent::Checkpoint {
308316
block_height: checkpoint.block_height,
309317
})
@@ -623,4 +631,33 @@ mod tests {
623631
// ...and the contract is queued to be read again with a proof.
624632
assert!(subscription.contracts_to_refresh.contains(&proved.id()));
625633
}
634+
635+
#[test]
636+
fn should_reject_a_checkpoint_no_stream_can_resume_after() {
637+
let mut subscription = subscription();
638+
subscription
639+
.accept(response(Responses::Checkpoint(Checkpoint {
640+
block_height: 41,
641+
})))
642+
.expect("accepted");
643+
assert!(subscription
644+
.accept(response(Responses::Checkpoint(Checkpoint {
645+
block_height: u64::MAX,
646+
})))
647+
.is_err());
648+
assert_eq!(subscription.resume_height(), Some(42));
649+
}
650+
651+
#[tokio::test]
652+
async fn should_keep_a_contract_queued_until_its_refresh_completes() {
653+
let mut subscription = subscription();
654+
let data_contract_id = Identifier::from([5u8; 32]);
655+
subscription.contracts_to_refresh.insert(data_contract_id);
656+
// The mock SDK has no expectation for this fetch, so the refresh fails part-way, as it
657+
// would if the future were dropped while fetching.
658+
assert!(subscription.refresh_contracts().await.is_err());
659+
assert!(subscription
660+
.contracts_to_refresh
661+
.contains(&data_contract_id));
662+
}
626663
}

0 commit comments

Comments
 (0)