feat(storage): service-wide, stackable guard layer for API limits (#1630) - #1925
feat(storage): service-wide, stackable guard layer for API limits (#1630)#1925AkramBitar wants to merge 4 commits into
Conversation
a177bc5 to
2a4b101
Compare
|
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 🙏 |
d3b4171 to
5b0249d
Compare
c24eced to
6d4a9bd
Compare
…1630) Signed-off-by: AkramBitar <akram@il.ibm.com>
ba65cd2 to
364565e
Compare
Hi @adecaro, Thanks a lot for the review. Good idea. I implemented it. Thanks, |
|
|
||
| // 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 |
There was a problem hiding this comment.
shouldn't this be configurable as well?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
if maxPageSize > 0, empty should not be allowed, no?
There was a problem hiding this comment.
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) { |
There was a problem hiding this comment.
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>
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
token/services/storage/db/guard/: aPolicy(loaded once from config), payload-size checks, a row-cappingLimitIterator, 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.multiplexed.Driverseam: everyNewXxxresult 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 size —
maxPayloadSize(default 4 MiB;0disables), rejected before reaching the DB:AddTokenRequest,AddTransaction,AddMovement,AddTransactionEndorsementAckStoreToken,StorePublicParams,StoreCertificationsAddValidationRecordStoreIdentityData,StoreSignerInfo,RegisterIdentityDescriptor,AddConfigurationStoreIdentityRead caps —
maxPageSize(default 1000):QueryTransactions—nil/pagination.None()/ over-max pages are rejectedQueryValidations,QueryTokenRequests,IteratorConfigurations— capped by aLimitIteratorthat errors (rather than silently truncating) once the cap is exceededIntentionally 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 againstmaxPageSize, 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 explicit0disables the respective check. Paging onQueryTransactionscannot be turned off —nil/Noneare rejected — so any caller that read everything at once now pages (seecollectAllTransactions/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:
ListUnspentTokens,ListUnspentTokensByWallets,ListHistoryIssuedTokens,ListAuditTokens,QueryTokenDetails,ConfigurationsByID,GetWalletIDs,GetExistingSignerInfo— need a SQL-levelLIMIT(with a defined order + documented truncation) or conversion to iterators.DeleteTokens,GetTokens,GetTokenRequests— a huge id list expands into a hugeIN (...)clause.Keystore.Put: opaque value serialised inside the store, so its size is only known at marshal time.Closes #1630.