Skip to content

Commit d2a46d5

Browse files
fix(sdk): bind document query verification to server-served wire versions and limits
Two trust-boundary gaps let an untrusted transport pair a valid proof with a request the supplied platform version's server would have refused. First, the wire version oneof (V0/V1) was never checked against platform_version.drive_abci.query.document_query bounds, so a V1 request could be verified under a platform version whose server only serves V0. Both verify entry points now run the same check_version gate the server's query_documents dispatch runs, before any decoding, contract lookup, or proof machinery. Second, the DocumentQuery -> DriveDocumentQuery lowering only guarded the u16 cast, so limits 101..=65535 - which the server refuses with InvalidLimit via DriveDocumentQuery::from_typed_clauses' default_query_limit cap (100) - reached a raw DriveDocumentQuery and could verify a proof no honest server would produce. The lowering now mirrors from_typed_clauses exactly: 0 stays the unset-default sentinel, 1..=100 passes, anything above is refused with the server's own QuerySyntaxError::InvalidLimit. Tests reuse the panicking ContextProvider to pin that both rejections happen before any proof machinery runs, and cover the provider-resolved entry point rejecting before its contract lookup.
1 parent 5435934 commit d2a46d5

2 files changed

Lines changed: 196 additions & 25 deletions

File tree

packages/dash-platform-queries/src/documents/document_query.rs

Lines changed: 83 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@ use dpp::{
3131
prelude::{DataContract, Identifier},
3232
InvalidVectorSizeError, ProtocolError,
3333
};
34+
use drive::config::DEFAULT_QUERY_LIMIT;
3435
use drive::query::drive_document_ranked_query::mode_detection::ranked_order_key;
3536
use drive::query::{
3637
DriveDocumentQuery, HavingAggregate, HavingAggregateFunction, HavingClause, HavingOperator,
@@ -651,6 +652,46 @@ fn order_clauses_from_cbor(bytes: &[u8]) -> Result<Vec<OrderClause>, Error> {
651652
}
652653
}
653654

655+
/// Reject a request whose wire version (the `V0`/`V1` oneof arm,
656+
/// i.e. feature version 0/1) falls outside the supplied platform
657+
/// version's `drive_abci.query.document_query` bounds.
658+
///
659+
/// This is the same `check_version` gate the server runs before it
660+
/// decodes anything (`Platform::query_documents` in rs-drive-abci,
661+
/// which answers an out-of-bounds wire version with
662+
/// `QueryError::UnsupportedQueryVersion`). Without it, an untrusted
663+
/// transport could pair a request wire-version the supplied platform
664+
/// version's server refuses to serve with a valid proof produced for
665+
/// the other wire shape, and verification would accept the pair.
666+
///
667+
/// A missing `version` oneof is deliberately let through — the decode
668+
/// that follows reports it with its established error message.
669+
fn check_wire_version_is_served(
670+
request: &GetDocumentsRequest,
671+
platform_version: &PlatformVersion,
672+
) -> Result<(), drive_proof_verifier::Error> {
673+
let Some(version) = &request.version else {
674+
return Ok(());
675+
};
676+
let feature_version: u16 = match version {
677+
V0(_) => 0,
678+
V1(_) => 1,
679+
};
680+
let bounds = &platform_version.drive_abci.query.document_query;
681+
if !bounds.check_version(feature_version) {
682+
return Err(drive_proof_verifier::Error::RequestError {
683+
error: format!(
684+
"GetDocumentsRequest wire version V{feature_version} is outside the \
685+
document_query feature-version bounds {}..={} served at platform version \
686+
{}; the server answers such a request with UnsupportedQueryVersion, so no \
687+
proved response can belong to it",
688+
bounds.min_version, bounds.max_version, platform_version.protocol_version
689+
),
690+
});
691+
}
692+
Ok(())
693+
}
694+
654695
/// Reject the request shapes that can never have produced the proved
655696
/// plain-document response being verified.
656697
///
@@ -748,6 +789,17 @@ fn reject_request_the_server_would_not_have_proved(
748789
/// front, mirroring the server's own gates in
749790
/// `rs-drive-abci`'s `validate_and_route` /
750791
/// `reject_offset_off_the_ranked_path`.
792+
///
793+
/// The same reasoning covers the request envelope itself: the wire
794+
/// version (`V0`/`V1` oneof arm) is checked against
795+
/// `platform_version.drive_abci.query.document_query`'s bounds before
796+
/// anything is decoded — the server's `query_documents` dispatch
797+
/// refuses an out-of-bounds wire version with
798+
/// `UnsupportedQueryVersion`, so a proof can never belong to one — and
799+
/// the query limit is capped at the server's
800+
/// [`DEFAULT_QUERY_LIMIT`] during the `DriveDocumentQuery` lowering,
801+
/// exactly as `DriveDocumentQuery::from_typed_clauses` caps it
802+
/// server-side.
751803
pub fn verify_documents_response(
752804
request: GetDocumentsRequest,
753805
contract: Arc<DataContract>,
@@ -756,6 +808,10 @@ pub fn verify_documents_response(
756808
platform_version: &PlatformVersion,
757809
provider: &dyn ContextProvider,
758810
) -> Result<(Option<Documents>, ResponseMetadata, Proof), drive_proof_verifier::Error> {
811+
// First gate, mirroring the server's own dispatch order: a wire
812+
// version the supplied platform version's server refuses to serve
813+
// is rejected before any decoding or proof machinery.
814+
check_wire_version_is_served(&request, platform_version)?;
759815
// `prove` does not survive decoding (a `DocumentQuery` has no such
760816
// field), so read it off the wire request before it is consumed.
761817
let prove = match &request.version {
@@ -791,6 +847,11 @@ pub fn verify_documents_response_with_provider_contract(
791847
platform_version: &PlatformVersion,
792848
provider: &dyn ContextProvider,
793849
) -> Result<(Option<Documents>, ResponseMetadata, Proof), drive_proof_verifier::Error> {
850+
// Same first gate as `verify_documents_response`, run here as well
851+
// so an out-of-bounds wire version is rejected before the provider
852+
// is asked for anything (the contract lookup below is already
853+
// context-provider machinery).
854+
check_wire_version_is_served(&request, platform_version)?;
794855
let contract_id_bytes = match &request.version {
795856
Some(V0(v0)) => v0.data_contract_id.as_slice(),
796857
Some(V1(v1)) => v1.data_contract_id.as_slice(),
@@ -1225,23 +1286,28 @@ impl<'a> TryFrom<&'a DocumentQuery> for DriveDocumentQuery<'a> {
12251286
)
12261287
.map_err(Error::Drive)?;
12271288

1228-
// `DriveDocumentQuery`'s limit is a `u16`; the wire's is a `u32`.
1229-
// The server refuses anything above `u16::MAX` outright
1230-
// (`QuerySyntaxError::InvalidLimit`), so a checked conversion is
1231-
// what actually mirrors it — an `as` cast would wrap 65537 to a
1232-
// 1-document query and verify a proof for a query nobody asked
1233-
// for. `0` keeps its "unset → server default" sentinel meaning.
1234-
let limit = if request.limit != 0 {
1235-
Some(u16::try_from(request.limit).map_err(|_| {
1236-
Error::Config(format!(
1237-
"limit {} does not fit a documents query's u16 limit (max {}); \
1238-
the server rejects such limits with InvalidLimit",
1239-
request.limit,
1240-
u16::MAX
1241-
))
1242-
})?)
1243-
} else {
1244-
None
1289+
// Mirror the limit contract of the server's
1290+
// `DriveDocumentQuery::from_typed_clauses` exactly: `0` (this
1291+
// struct's "unset" sentinel — V0's `limit: 0`, V1's
1292+
// `limit: None`) falls back to the server default, and anything
1293+
// above `DEFAULT_QUERY_LIMIT` (the `config.default_query_limit`
1294+
// every deployed server runs with) is refused with the server's
1295+
// own `QuerySyntaxError::InvalidLimit` rather than truncated or
1296+
// passed through. A `u16::try_from` alone would not do: limits
1297+
// 101..=65535 fit a `u16` but the server refuses them, so a raw
1298+
// `DriveDocumentQuery` carrying one would verify a proof no
1299+
// honest server could have produced.
1300+
let limit = match request.limit {
1301+
0 => None,
1302+
limit if limit > u32::from(DEFAULT_QUERY_LIMIT) => {
1303+
return Err(Error::Drive(drive::error::Error::Query(
1304+
drive::error::query::QuerySyntaxError::InvalidLimit(format!(
1305+
"limit {} greater than max limit {}",
1306+
limit, DEFAULT_QUERY_LIMIT
1307+
)),
1308+
)));
1309+
}
1310+
limit => Some(limit as u16),
12451311
};
12461312

12471313
let (start_at, start_at_included) = match request.start.as_ref() {

packages/dash-platform-queries/tests/document_query_wire_roundtrip.rs

Lines changed: 113 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,11 @@ fn rejects_contract_mismatch() {
294294

295295
/// A `u32` wire limit above `u16::MAX` must be refused, not wrapped.
296296
///
297-
/// `DriveDocumentQuery`'s limit is a `u16`; the server rejects anything
298-
/// larger with `InvalidLimit` and so never proves such a query. An `as`
299-
/// cast would silently turn a request for 65537 documents into a
300-
/// 1-document query, and a proof for *that* query would then verify —
301-
/// binding the proof to something the caller never asked for.
297+
/// The server rejects anything above its query limit cap with
298+
/// `InvalidLimit` and so never proves such a query. An `as` cast would
299+
/// silently turn a request for 65537 documents into a 1-document
300+
/// query, and a proof for *that* query would then verify — binding the
301+
/// proof to something the caller never asked for.
302302
#[test]
303303
fn limit_above_u16_max_is_rejected_not_truncated() {
304304
let contract = test_contract();
@@ -314,6 +314,39 @@ fn limit_above_u16_max_is_rejected_not_truncated() {
314314
);
315315
}
316316

317+
/// The lowering mirrors `DriveDocumentQuery::from_typed_clauses`'
318+
/// limit contract exactly: `0` = unset → server default (`None`
319+
/// here), `1..=DEFAULT_QUERY_LIMIT` passes through, and anything
320+
/// above the cap — including 101..=65535, which fits a `u16` but is
321+
/// server-invalid — is refused with the server's `InvalidLimit`.
322+
#[test]
323+
fn limit_cap_mirrors_the_server() {
324+
let contract = test_contract();
325+
let query = |limit: u32| {
326+
DocumentQuery::new(Arc::clone(&contract), "niceDocument")
327+
.expect("document type exists")
328+
.with_limit(limit)
329+
};
330+
331+
let unset_query = query(0);
332+
let unset = DriveDocumentQuery::try_from(&unset_query).expect("limit 0 is the unset sentinel");
333+
assert_eq!(unset.limit, None, "0 must lower to the server default");
334+
335+
let at_cap_query = query(100);
336+
let at_cap =
337+
DriveDocumentQuery::try_from(&at_cap_query).expect("the server serves limits up to 100");
338+
assert_eq!(at_cap.limit, Some(100));
339+
340+
for limit in [101u32, 65_535, 65_537, u32::MAX] {
341+
let error = DriveDocumentQuery::try_from(&query(limit))
342+
.expect_err("a limit the server refuses must not reach a DriveDocumentQuery");
343+
assert!(
344+
error.to_string().contains("greater than max limit 100"),
345+
"unexpected error for limit {limit}: {error}"
346+
);
347+
}
348+
}
349+
317350
/// Request-shape checks in [`verify_documents_response`].
318351
///
319352
/// Every field asserted here is dropped by the `DocumentQuery` →
@@ -369,9 +402,11 @@ mod verify_binds_the_whole_request {
369402
}
370403

371404
/// Encode `query` onto the V1 wire, let `mutate` reshape the request
372-
/// the way a hostile transport could, and return the rejection.
373-
fn verify_error(
405+
/// the way a hostile transport could, and return the rejection
406+
/// produced when verifying against `verify_at`.
407+
fn verify_error_at_version(
374408
query: DocumentQuery,
409+
verify_at: &PlatformVersion,
375410
mutate: impl FnOnce(&mut GetDocumentsRequest),
376411
) -> drive_proof_verifier::Error {
377412
let contract = Arc::clone(&query.data_contract);
@@ -385,12 +420,20 @@ mod verify_binds_the_whole_request {
385420
contract,
386421
GetDocumentsResponse::default(),
387422
Network::Testnet,
388-
PlatformVersion::latest(),
423+
verify_at,
389424
&NeverCalledProvider,
390425
)
391426
.expect_err("the reshaped request must be rejected")
392427
}
393428

429+
/// [`verify_error_at_version`] against the latest platform version.
430+
fn verify_error(
431+
query: DocumentQuery,
432+
mutate: impl FnOnce(&mut GetDocumentsRequest),
433+
) -> drive_proof_verifier::Error {
434+
verify_error_at_version(query, PlatformVersion::latest(), mutate)
435+
}
436+
394437
fn documents_query() -> DocumentQuery {
395438
DocumentQuery::new(test_contract(), "niceDocument").expect("document type exists")
396439
}
@@ -467,4 +510,66 @@ mod verify_binds_the_whole_request {
467510
"unexpected error: {error}"
468511
);
469512
}
513+
514+
/// A V1 wire request verified against a platform version whose
515+
/// `document_query` bounds are `0..=0` (protocol version 1) must
516+
/// be refused before anything else runs — the server's
517+
/// `query_documents` dispatch answers it with
518+
/// `UnsupportedQueryVersion` and never proves it. The panicking
519+
/// provider pins that the rejection precedes all proof machinery.
520+
#[test]
521+
fn rejects_wire_version_outside_platform_version_bounds() {
522+
let error = verify_error_at_version(documents_query(), v0_platform_version(), |_| {});
523+
assert!(
524+
error
525+
.to_string()
526+
.contains("wire version V1 is outside the document_query feature-version bounds"),
527+
"unexpected error: {error}"
528+
);
529+
}
530+
531+
/// Same wire-version gate on the provider-resolved entry point:
532+
/// the rejection must land before the contract lookup, which is
533+
/// already context-provider machinery (the provider here panics on
534+
/// `get_data_contract`).
535+
#[test]
536+
fn rejects_wire_version_before_provider_contract_lookup() {
537+
use dash_platform_queries::documents::document_query::verify_documents_response_with_provider_contract;
538+
539+
let request = documents_query()
540+
.try_into_request_for_version(v1_platform_version())
541+
.expect("query should encode onto the wire");
542+
543+
let error = verify_documents_response_with_provider_contract(
544+
request,
545+
GetDocumentsResponse::default(),
546+
Network::Testnet,
547+
v0_platform_version(),
548+
&NeverCalledProvider,
549+
)
550+
.expect_err("an out-of-bounds wire version must be rejected");
551+
assert!(
552+
error
553+
.to_string()
554+
.contains("wire version V1 is outside the document_query feature-version bounds"),
555+
"unexpected error: {error}"
556+
);
557+
}
558+
559+
/// Limits in `101..=65535` fit the wire's `u32` (and a `u16`) but
560+
/// the server refuses them with `InvalidLimit`
561+
/// (`DriveDocumentQuery::from_typed_clauses` caps at
562+
/// `DEFAULT_QUERY_LIMIT` = 100), so no proved response can belong
563+
/// to such a request. The panicking provider pins that the
564+
/// rejection precedes all proof machinery.
565+
#[test]
566+
fn rejects_limit_above_server_cap() {
567+
for limit in [101u32, 65_535, u32::MAX] {
568+
let error = verify_error(documents_query().with_limit(limit), |_| {});
569+
assert!(
570+
error.to_string().contains("greater than max limit 100"),
571+
"unexpected error for limit {limit}: {error}"
572+
);
573+
}
574+
}
470575
}

0 commit comments

Comments
 (0)