Skip to content

feat(storage): service-wide, stackable guard layer for API limits (#1630) - #1925

Open
AkramBitar wants to merge 4 commits into
mainfrom
feature/1630-api-payload-and-query-bounds
Open

feat(storage): service-wide, stackable guard layer for API limits (#1630)#1925
AkramBitar wants to merge 4 commits into
mainfrom
feature/1630-api-payload-and-query-bounds

Conversation

@AkramBitar

@AkramBitar AkramBitar commented Jul 15, 2026

Copy link
Copy Markdown
Contributor

Add size limits to the storage service so one huge write or one unlimited query can't overload the database. Fixes the [MED] issue #1630 (denial of service from unlimited resource use, CWE-400 / CWE-770).

What changed since the first revision

The first revision implemented the limits only for the transaction store, via per-constructor WithMax* options + inline checks. Per review feedback (that approach doesn't scale beyond one store), this has been reworked into a single, stackable guard decorator layer applied across the whole storage service. The transaction store was migrated onto it, so there is now one mechanism.

How it works

  • New package token/services/storage/db/guard/: a Policy (loaded once from config), payload-size checks, a row-capping LimitIterator, and interface-embedding decorators for the transaction, token, endorser, identity and wallet stores. Each decorator embeds the store interface (delegating everything) and overrides only the methods that need a check, plus the nested write-transactions and returned iterators.
  • Applied once at the multiplexed.Driver seam: every NewXxx result is wrapped, so all backing drivers (SQLite, PostgreSQL) and all stores are covered uniformly. Additional layers (metrics/tracing) can stack the same way, with the concrete SQL store at the bottom.

What is guarded

Write payload sizemaxPayloadSize (default 4 MiB; 0 disables), rejected before reaching the DB:

  • transaction: AddTokenRequest, AddTransaction, AddMovement, AddTransactionEndorsementAck
  • token: StoreToken, StorePublicParams, StoreCertifications
  • endorser: AddValidationRecord
  • identity: StoreIdentityData, StoreSignerInfo, RegisterIdentityDescriptor, AddConfiguration
  • wallet: StoreIdentity

Read capsmaxPageSize (default 1000):

  • paginated QueryTransactionsnil / pagination.None() / over-max pages are rejected
  • streaming iterators — token unspent/spendable/unsupported iterators, QueryValidations, QueryTokenRequests, IteratorConfigurations — capped by a LimitIterator that errors (rather than silently truncating) once the cap is exceeded

Intentionally uncapped: QueryMovements (feeds balance totals; dropping rows would silently return wrong balances).

Also closes two gaps in pagination.ValidateLimited: keyset page size is now capped against maxPageSize, and a typed-nil offset is guarded.

Configuration / upgrade note

Limits are on by default (4 MiB / 1000), so existing deployments get them automatically. Override via token.storage.maxPayloadSize / token.storage.maxPageSize; an explicit 0 disables the respective check. Paging on QueryTransactions cannot be turned off — nil/None are rejected — so any caller that read everything at once now pages (see collectAllTransactions / checks.go).

Not yet addressed (tracked follow-up)

These vectors can't be closed by a wrapper alone, because the SQL has already materialised the full result before the decorator sees it — they need query-level changes and will be handled in a follow-up:

  • Materialized slice/map reads: ListUnspentTokens, ListUnspentTokensByWallets, ListHistoryIssuedTokens, ListAuditTokens, QueryTokenDetails, ConfigurationsByID, GetWalletIDs, GetExistingSignerInfo — need a SQL-level LIMIT (with a defined order + documented truncation) or conversion to iterators.
  • Variadic input-size caps: DeleteTokens, GetTokens, GetTokenRequests — a huge id list expands into a huge IN (...) clause.
  • Keystore.Put: opaque value serialised inside the store, so its size is only known at marshal time.

Closes #1630.

@AkramBitar
AkramBitar marked this pull request as draft July 15, 2026 14:06
@AkramBitar AkramBitar self-assigned this Jul 15, 2026
@AkramBitar AkramBitar added this to the Q3/26 milestone Jul 15, 2026
@AkramBitar AkramBitar changed the title feat(storage): enforce API payload size limits and query result caps (#1630) Enforce API payload size limits and query result caps (#1630) Jul 15, 2026
@AkramBitar
AkramBitar force-pushed the feature/1630-api-payload-and-query-bounds branch from a177bc5 to 2a4b101 Compare July 16, 2026 14:18
@AkramBitar
AkramBitar requested review from HayimShaul and adecaro July 16, 2026 14:19
@AkramBitar
AkramBitar marked this pull request as ready for review July 16, 2026 14:32
@adecaro

adecaro commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Hi @AkramBitar , thanks for submitting this.

My concern is the following: The storage service has many queries that would require similar constraints. Right now, this opts-based approach works only for the Transactions db.

I would suggest to perform an analysis of the entire storage service to understand which limits need to be put in place and then design a flexible solution to address the issues. A wrapper-based solution could also be interested so that we can stack different layers on top of each other each perform a different function and the bottom one running the actual SQL query.

What do you think?

Thanks 🙏

@AkramBitar
AkramBitar force-pushed the feature/1630-api-payload-and-query-bounds branch from d3b4171 to 5b0249d Compare July 21, 2026 14:59
@AkramBitar AkramBitar changed the title Enforce API payload size limits and query result caps (#1630) feat(storage): service-wide, stackable guard layer for API limits (#1630) Jul 21, 2026
@AkramBitar
AkramBitar marked this pull request as draft July 21, 2026 15:37
@AkramBitar
AkramBitar force-pushed the feature/1630-api-payload-and-query-bounds branch 2 times, most recently from c24eced to 6d4a9bd Compare July 23, 2026 11:37
@AkramBitar
AkramBitar force-pushed the feature/1630-api-payload-and-query-bounds branch from ba65cd2 to 364565e Compare July 23, 2026 12:02
@AkramBitar

Copy link
Copy Markdown
Contributor Author

Hi @AkramBitar , thanks for submitting this.

My concern is the following: The storage service has many queries that would require similar constraints. Right now, this opts-based approach works only for the Transactions db.

I would suggest to perform an analysis of the entire storage service to understand which limits need to be put in place and then design a flexible solution to address the issues. A wrapper-based solution could also be interested so that we can stack different layers on top of each other each perform a different function and the bottom one running the actual SQL query.

What do you think?

Thanks 🙏

Hi @adecaro,

Thanks a lot for the review. Good idea. I implemented it.

Thanks,
Akram

@AkramBitar
AkramBitar marked this pull request as ready for review July 23, 2026 12:03

// transactionsCheckPageSize is the page size used to iterate all transactions
// during integrity checks; kept below the storage layer's max page size.
const transactionsCheckPageSize = 100

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

shouldn't this be configurable as well?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good point. Rather than adding a separate config key, I've made the checker page at the storage layer's existing token.storage.maxPageSize ca. So it reads within the same bound the guard layer already enforces, with no new knob to keep in sync. The 100 constant is now just the fallback for when the cap is disabled (maxPageSize = 0), since an unbounded read gets rejected and we still need some batch size to page by.

return LimitIterator(it, g.policy.MaxPageSize, "UnspentTokensIteratorBy"), nil
}

func (g *guardedTokenStore) SpendableTokensIteratorBy(ctx context.Context, walletID string, typ token.Type) (tokendriver.SpendableTokensIterator, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think this function is used by the token selectors and I think these token selectors work on the assumption that they can load all the tokens that belong to a wallet. If this is not the case anymore, we selector might report unexpected errors. No?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Good catch, you're right. LimitIterator errors instead of truncating, so a wallet with more than MaxPageSize tokens would fail selection even though the funds exist. UnspentTokensIteratorBy / SpendableTokensIteratorBy are a legitimate "load all tokens of this wallet" operation the selectors rely on, so I'll drop the cap on them and keep it only on the unbounded, non-wallet-scoped reads. Write payload limits are unaffected.

case *none:
return errors.New("unlimited pagination (None) is not allowed")
case *empty:
return nil

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

if maxPageSize > 0, empty should not be allowed, no?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Empty compiles to LIMIT 0, it returns zero rows, so it can never exceed maxPageSize. The unbounded case is None (no LIMIT), which we already reject. Allowing empty is safe; rejecting it would only break harmless zero-row queries.

return g.AuditTransactionStore.QueryTransactions(ctx, params, p)
}

func (g *guardedAuditTx) QueryTokenRequests(ctx context.Context, params driver.QueryTokenRequestsParams) (driver.TokenRequestIterator, error) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

so, in QueryTransactions the caller can extract all the transactions even though in a paginated format. Here instead can only extract up to a certain total number of token requests. I think we need to introduce a standard here. We can say that all queries need to be paginated and we can even say that despite the pagination only a maximum number of rows can be extracted. What do you think?

…at the storage layer's existing token.storage.maxPageSize ca. So it reads within the same bound the guard layer already enforces, with no new knob to keep in sync. The 100 constant is now just the fallback for when the cap is disabled (maxPageSize = 0), since an unbounded read gets rejected and we still need some batch size to page by.

Signed-off-by: AkramBitar <akram@il.ibm.com>
Signed-off-by: AkramBitar <akram@il.ibm.com>
Signed-off-by: AkramBitar <akram@il.ibm.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

API-level payload size limits and query result caps [MED]

2 participants