Skip to content

[F42] propagate the configured datastore key query cap from RPC/gRPC … - #5189

Open
peterjah wants to merge 2 commits into
mainfrom
5059-f42-unbounded-datastore-key-enumeration-enables-underpriced-dos
Open

peterjah wants to merge 2 commits into
mainfrom
5059-f42-unbounded-datastore-key-enumeration-enables-underpriced-dos

Conversation

@peterjah

Copy link
Copy Markdown
Collaborator

…parsing to the scan

  • document all added functions
  • try in sandbox /simulation/labnet
    • if part of node-launch, checked using the resync_check flag
  • unit tests on the added/changed features
    • make tests compile
    • make tests pass
  • add logs allowing easy debugging in case the changes caused problems
  • if the API has changed, update the API specification

@peterjah peterjah linked an issue Aug 20, 2026 that may be closed by this pull request
@peterjah
peterjah requested a review from Leo-Besancon August 20, 2026 09:30
Leo-Besancon
Leo-Besancon previously approved these changes Aug 20, 2026

@Leo-Besancon Leo-Besancon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Implementation looks good to me. @peterjah you know more about existing projects than me, even if this is the expected behaviour, do you think it could break any apps that rely on the no limit behaviour?
I assume if that's the case the fix is just to chunk the queries, probably easy to do?

Not that it's an issue, we just have to make a clear breaking changes list for builders like we've done before.

@peterjah

Copy link
Copy Markdown
Collaborator Author

Implementation looks good to me. @peterjah you know more about existing projects than me, even if this is the expected behaviour, do you think it could break any apps that rely on the no limit behaviour? I assume if that's the case the fix is just to chunk the queries, probably easy to do?

Not that it's an issue, we just have to make a clear breaking changes list for builders like we've done before.

Actually yes its a breaking change, and quite dangerous one because it breaks "silently", just truncating the 500th first elements...
I am actually afraid it could break deployed apps..
Not sure if it worth to merge this :/

@Leo-Besancon

Copy link
Copy Markdown
Member

Implementation looks good to me. @peterjah you know more about existing projects than me, even if this is the expected behaviour, do you think it could break any apps that rely on the no limit behaviour? I assume if that's the case the fix is just to chunk the queries, probably easy to do?
Not that it's an issue, we just have to make a clear breaking changes list for builders like we've done before.

Actually yes its a breaking change, and quite dangerous one because it breaks "silently", just truncating the 500th first elements... I am actually afraid it could break deployed apps.. Not sure if it worth to merge this :/

I agree it's a bit scary. Any maintained project should be able to update easily, but any unmaintained project using this behaviour may break. WDYT @damip?

@damip

damip commented Aug 26, 2026

Copy link
Copy Markdown
Member

I think it is worth the risk given the risk of DDoS.
The ones we need to absolutely check are:

  • bridge
  • MNS
  • deweb
    Anything else that comes to mind that might be listing massively without chunking?

@damip damip left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Review (F42 / #5059)

The fix is correct and consensus-safe for what it covers, and CI is fully green. I traced the wiring end to end: cleanup_datastore_key_range_query already computed the effective count (count.or(max_datastore_query_config)) for validation but discarded it, so JSON-RPC and gRPC forwarded the caller's None into scan_datastore for an unbounded enumeration. The PR propagates the effective value; both call sites pass the configured cap (max_datastore_keys_query = 500 by default for API and gRPC), so a count: None request is now clamped to 500. A request with count > max already errored before (pre-existing check, unchanged). The smart-contract path stays byte-identical (see inline), which is required on main.

Requesting changes for one process item and one documentation item — the code itself looks good:

  1. This PR only fixes one of the three problems in #5059, but it is linked to auto-close it on merge. F42 also covers (a) the smart-contract path (Context::get_keys hardcodes no limit, so SCs still reach scan_datastore with count = None), (b) the scan_datastore internals (the SpeculativeResetType::Set branch still .collect()s the full matching speculative key set before truncation, and the merge path can over-fetch final-state batches), and (c) work-based pricing. Those are execution-semantics changes that need a versioned rollout on dev_breaking — out of scope here, rightly so. Please either unlink the issue from this PR or open a follow-up issue for the consensus-gated residuals before merging, so they don't silently disappear when GitHub closes #5059.

  2. Silent truncation is a client-visible semantics change and should be documented. count: None used to mean "return all keys"; it now means "return up to the configured max (default 500)" with no truncation indicator in the response. This is exactly the error-vs-silent-drop interface decision that routed F42 to an issue. Silent clamping is defensible since pagination exists (start_key + inclusive flags), but it needs an explicit changelog / API-docs note telling client authors to paginate — otherwise existing clients that relied on "None = everything" will silently see incomplete key sets.

Minor (no change needed, just noting): if an operator unsets max_datastore_keys_query, the fallback is None and the path is unbounded again — worth one line in the config comment saying the DoS bound relies on this setting.

@@ -660,7 +660,7 @@ impl ExecutionContext {
let max_datastore_query = None;

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Verified: keeping max_datastore_query = None here makes the new destructured count equal to count.or(None) — an identity — so the smart-contract path's behavior is byte-identical to before. That's the right call for a PR targeting main (bounding the ABI would change execution outcomes and needs a deterministic constant + versioned rollout, as the TODO says).

This is also the residual half of #5059: smart contracts can still trigger unbounded enumeration through this path, including the full speculative-key .collect() inside scan_datastore. Please make sure this residual is tracked (follow-up issue or unlink #5059 from this PR) since merging will auto-close the issue.

) -> Result<(Vec<u8>, Bound<Vec<u8>>, Bound<Vec<u8>>), ModelsError> {
) -> Result<(Vec<u8>, Bound<Vec<u8>>, Bound<Vec<u8>>, Option<u32>), ModelsError> {
// check item count
let count = count.or(max_datastore_query_config);

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

This fallback is what makes the fix work — and it's also where the client-visible semantics change happens: count: None previously meant "return all keys" and now means "return up to max_datastore_query_config (default 500)", with no truncation indicator in the response. Clients that relied on the old behavior will silently get partial results.

Suggest (a) a changelog/API-docs note telling client authors to paginate via start_key/inclusive flags, and (b) mentioning in the function doc that when the operator config is None the returned count can still be None (unbounded), so the DoS bound relies on the config being set.

@peterjah

Copy link
Copy Markdown
Collaborator Author

I think it is worth the risk given the risk of DDoS. The ones we need to absolutely check are:

  • bridge
  • MNS
  • deweb
    Anything else that comes to mind that might be listing massively without chunking?

i will check

@peterjah

peterjah commented Aug 28, 2026

Copy link
Copy Markdown
Collaborator Author

@damip @Leo-Besancon
i digged into it.
It seems the only app that will break is the block explorer MNS resolution feature. I will prepare a fix there.
Another thing that worth to be pointed is about the MAX_DATASTORE_KEYS_QUERY = 500
The value is configurable in node operator config but is not exposed afterwise in the API.
To make something clean, consumer (massa-web3) needs the configured value to use the endpoint properly.
As of today the value 500 is hardcoded in massa-web3... so if any node operator set a lower value, the endpoint called from massa-web3 will error

@peterjah
peterjah force-pushed the 5059-f42-unbounded-datastore-key-enumeration-enables-underpriced-dos branch from 4286b53 to d741b44 Compare August 28, 2026 11:03
@peterjah

Copy link
Copy Markdown
Collaborator Author

Followup issue created: #5241

Issue to expose the configured MAX_DATASTORE_KEYS value:
#5228

@peterjah

Copy link
Copy Markdown
Collaborator Author

I also prepared a doc PR in massa docs
massalabs/docs#461

@Leo-Besancon Leo-Besancon left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

LGTM on this PR and the followup issues you created, but I'd be more confortable with a second opinion as it's quite impactful, so don't merge this yet please :).

@Leo-Besancon
Leo-Besancon requested a review from bilboquet August 31, 2026 11:09
@bilboquet

Copy link
Copy Markdown
Contributor

This PR looks good to me.
What I'm not sure about is if #5241 and #5228 are follows up or are sub tasks that have to be done and merge at the same as this PR.

@damip damip left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Checked the new commit (b896454) since it touches scan_datastore which is shared with the SC path: on main this is only OK if the output is byte-identical. It is. The Set bounding is correct (among the first count + |updates| entry keys at most |updates| can be overridden, so the count-th surviving key is always within that bound, and updates still win via or_insert). With count = None the walk is unbounded so the SC path is unchanged. The replenish change is also output-preserving (sequential merge, refill happens as soon as the queue empties, .max(1) avoids a 0-size fetch being read as exhaustion). The prefix-of-unbounded test is the right property. Two nits inline.

@bilboquet #5241 and #5228 are follow-ups, not blockers. #5241 is consensus-observable and goes through a MIP on dev_breaking, that's why it was split out. #5228 is a nicety: massa-web3 already sends count = 500 explicitly so it already errors on a node with a lower cap, this PR doesn't change that, and everyone runs the default anyway.

@peterjah before merging massalabs/docs#461, double-check the "MAIN.6.0 / DEVN.31.0" claim matches the release this actually ships in.

if let Some(last_k) = last_final_batch_key.take() {
// the last final item was consumed: replenish the queue by querying more
// only the keys still missing to reach `count` are worth fetching
let remaining = count.map(|cnt| {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Correct but low value: the old count batch was already bounded, so this saves at most one partial batch. And it has a downside: once speculative_keys.len() hits count - 1, every speculatively-deleted final key ahead costs one lock + RocksDB seek for a single key (up to ~500 single-key fetches per API call instead of one batch). Not a DoS (the deletes cost gas) but a regression in the adversarial case. I'd revert this part to count (also removes the need for a dedicated test). If you want to keep it, add a floor (e.g. .max(64)).

/// the reset entry mean the scan has to look past the first `count` entry keys to
/// still produce `count` keys.
#[test]
fn test_scan_datastore_count_is_a_prefix_of_unbounded() {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Good test, exactly the property we need. It only covers the Set path though. If the remaining change in the replenish loop stays, add the same prefix-of-unbounded check for the merge path (mock get_datastore_keys honoring the start bound and the count, so the refill is actually exercised).

bilboquet added a commit that referenced this pull request Sep 11, 2026
Implements the #5255 spec (phase 2 of #5057): bound the work per batch,
never the lock.

- ExecutionQueryRequest.max_event_count: batch-wide event budget, init from
  new ApiConfig/GrpcConfig max_events_per_query settings (default 7000).
  Each Events item takes min(remaining, per-item cap) and decrements what it
  returns; past zero it gets a per-item TooLargeResponse error (exists since
  #5236, no proto change) instead of fetching.
- execution.rs get_filtered_sc_output_event takes a limit applied to the
  final-cache and active-history paths (take-after-fetch minimum: cost is
  proportional to the returned plus one truncated fetch).
- Event bytes counted against max_response_size (data.len() + estimated
  per-event overhead const, TODO: measure real size).
- Datastore keys counted against max_response_size (sum of key lengths).
- Single-shot get_filtered_sc_output_event unchanged (unbounded limit).
- New tests: two Events items sharing a budget of 1 (first takes it, second
  errors); scan tripwire documenting the unbounded count=None hole left
  while #5189 is unmerged (breaks on purpose when it lands).
- Observable behavior change (new per-item errors where success before):
  F42 breaking-changes list, same as #5189.
bilboquet added a commit that referenced this pull request Sep 14, 2026
Implements the #5255 spec (phase 2 of #5057): bound the work per batch,
never the lock.

- ExecutionQueryRequest.max_event_count: batch-wide event budget, init from
  new ApiConfig/GrpcConfig max_events_per_query settings (default 7000).
  Each Events item takes min(remaining, per-item cap) and decrements what it
  returns; past zero it gets a per-item TooLargeResponse error (exists since
  #5236, no proto change) instead of fetching.
- execution.rs get_filtered_sc_output_event takes a limit applied to the
  final-cache and active-history paths (take-after-fetch minimum: cost is
  proportional to the returned plus one truncated fetch).
- Event bytes counted against max_response_size (data.len() + estimated
  per-event overhead const, TODO: measure real size).
- Datastore keys counted against max_response_size (sum of key lengths).
- Single-shot get_filtered_sc_output_event unchanged (unbounded limit).
- New tests: two Events items sharing a budget of 1 (first takes it, second
  errors); scan tripwire documenting the unbounded count=None hole left
  while #5189 is unmerged (breaks on purpose when it lands).
- Observable behavior change (new per-item errors where success before):
  F42 breaking-changes list, same as #5189.
bilboquet added a commit that referenced this pull request Sep 15, 2026
Implements the #5255 spec (phase 2 of #5057): bound the work per batch,
never the lock.

- ExecutionQueryRequest.max_event_count: Option<usize> batch budget, fed by
  the pre-existing [execution].max_event_per_query setting (no new knob;
  ApiConfig/GrpcConfig carry it to the transports). Each Events item takes
  min(remaining, per-item cap) and decrements what it returns; past zero it
  gets a per-item TooLargeResponse error (exists since #5236, no proto change)
  instead of fetching. None means unbounded (explicit opt-out for empty
  requests and tests, never for transport batches).
- execution.rs get_filtered_sc_output_event takes a limit applied to the
  final-cache and active-history paths (take-after-fetch minimum: cost is
  proportional to the returned plus one truncated fetch).
- Event bytes counted against max_response_size (data.len() + estimated
  per-event overhead const, marked ponytail: for later measurement).
- Datastore keys counted against max_response_size (sum of key lengths).
- Single-shot get_filtered_sc_output_event unchanged (explicit unbounded,
  marked ponytail: as known follow-up).
- New tests: two Events items sharing a budget of 1 (first takes it, second
  errors); scan tripwire documenting the unbounded count=None hole left
  while #5189 is unmerged (breaks on purpose when it lands).
- Observable behavior change (new per-item errors where success before):
  F42 breaking-changes list, same as #5189.
bilboquet added a commit that referenced this pull request Sep 15, 2026
Implements the #5255 spec (phase 2 of #5057): bound the work per batch,
never the lock.

- ExecutionQueryRequest.max_event_count: Option<usize> batch budget, fed by
  the pre-existing [execution].max_event_per_query setting (no new knob;
  ApiConfig/GrpcConfig carry it to the transports). Each Events item takes
  min(remaining, per-item cap) and decrements what it returns; past zero it
  gets a per-item TooLargeResponse error (exists since #5236, no proto change)
  instead of fetching. None means unbounded (explicit opt-out for empty
  requests and tests, never for transport batches).
- execution.rs get_filtered_sc_output_event takes a limit applied to the
  final-cache and active-history paths (take-after-fetch minimum: cost is
  proportional to the returned plus one truncated fetch).
- Event bytes counted against max_response_size (data.len() + estimated
  per-event overhead const, marked ponytail: for later measurement).
- Datastore keys counted against max_response_size (sum of key lengths).
- Single-shot get_filtered_sc_output_event unchanged (explicit unbounded,
  marked ponytail: as known follow-up).
- New tests: two Events items sharing a budget of 1 (first takes it, second
  errors); scan tripwire documenting the unbounded count=None hole left
  while #5189 is unmerged (breaks on purpose when it lands).
- Observable behavior change (new per-item errors where success before):
  F42 breaking-changes list, same as #5189.
github-merge-queue Bot pushed a commit that referenced this pull request Sep 15, 2026
…#5266)

* [F40] refactor(execution-worker): enforce atomic batch reads by borrowing

Phase 1 of issue #5057. query_state and the other batch getters must
read under a single execution_state read lock: the returned cursors and
fingerprint describe the whole batch, so releasing the lock between
items would silently corrupt reads while keeping a valid stamp.

A comment-only invariant cannot hold that: drop + re-acquire compiles
fine. Hence batch helpers (eval_query_item, *_under) take &ExecutionState
borrowed from the guard: they receive a read capability for an acquisition
they do not own and cannot release. A mid-batch drop is now a compile error
(E0505, verified with a temporary drop test, then removed).

Public trait signatures unchanged (acquire + delegate). Pure code move +
reindent, no logic change. cargo check + clippy clean, crate suite green.

* [F40] feat(execution): batch-wide event budget in query_state (#5255)

Implements the #5255 spec (phase 2 of #5057): bound the work per batch,
never the lock.

- ExecutionQueryRequest.max_event_count: Option<usize> batch budget, fed by
  the pre-existing [execution].max_event_per_query setting (no new knob;
  ApiConfig/GrpcConfig carry it to the transports). Each Events item takes
  min(remaining, per-item cap) and decrements what it returns; past zero it
  gets a per-item TooLargeResponse error (exists since #5236, no proto change)
  instead of fetching. None means unbounded (explicit opt-out for empty
  requests and tests, never for transport batches).
- execution.rs get_filtered_sc_output_event takes a limit applied to the
  final-cache and active-history paths (take-after-fetch minimum: cost is
  proportional to the returned plus one truncated fetch).
- Event bytes counted against max_response_size (data.len() + estimated
  per-event overhead const, marked ponytail: for later measurement).
- Datastore keys counted against max_response_size (sum of key lengths).
- Single-shot get_filtered_sc_output_event unchanged (explicit unbounded,
  marked ponytail: as known follow-up).
- New tests: two Events items sharing a budget of 1 (first takes it, second
  errors); scan tripwire documenting the unbounded count=None hole left
  while #5189 is unmerged (breaks on purpose when it lands).
- Observable behavior change (new per-item errors where success before):
  F42 breaking-changes list, same as #5189.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[F42] Unbounded Datastore Key Enumeration Enables Underpriced DoS

4 participants