Skip to content

Commit d3b4171

Browse files
committed
refactor(storage): enforce API limits via stackable guard layer (#1630)
Replace the transactions-only WithMax* opts with a single, stackable guard decorator layer applied service-wide at the multiplexed driver seam, addressing review feedback that the per-constructor opts approach did not scale. - New token/services/storage/db/guard package: Policy (loaded once from token.storage.maxPayloadSize / maxPageSize), payload-size checks, a row-capping LimitIterator, and interface-embedding decorators for the transaction, token, endorser, identity, and wallet stores. - multiplexed.Driver wraps each NewXxx result with the guard, so every backing driver (sqlite, postgres) and store is covered by one mechanism. - Remove the maxPayloadSize/maxPageSize fields, WithMax* options and inline checks from the SQL transaction store and its config/driver wiring. - pagination.ValidateLimited now also caps keyset page size and guards a typed-nil offset. - Writes (payload size) and streaming/paginated reads (row cap) are guarded; QueryMovements stays uncapped (balance totals). Materialized slice/map reads are a tracked follow-up. - Update storage/configuration docs to describe the service-wide limits. Signed-off-by: AkramBitar <akram@il.ibm.com>
1 parent 2a4b101 commit d3b4171

25 files changed

Lines changed: 1076 additions & 359 deletions

docs/configuration.md

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -589,10 +589,10 @@ to `<params>_<short_code>` (params still apply when provided; short-code overrid
589589

590590
### Optional: token.storage.maxPayloadSize
591591

592-
Maximum serialised size, in bytes, accepted by a single transaction-store write
593-
(`AddTokenRequest`, `AddTransaction`, `AddMovement`). Writes whose payload exceeds
594-
this size are rejected before reaching the database. A value of `0` disables the
595-
check. When the key is absent, the store default (4 MiB) applies.
592+
Maximum serialised size, in bytes, accepted by a single storage-service write
593+
(applies across the transaction, token, endorser, identity, and wallet stores).
594+
Writes whose payload exceeds this size are rejected before reaching the database.
595+
A value of `0` disables the check. When the key is absent, the default (4 MiB) applies.
596596

597597
```yaml
598598
token:
@@ -604,19 +604,18 @@ token:
604604

605605
### Optional: token.storage.maxPageSize
606606

607-
Maximum number of rows a single transaction-store read may return. It limits the
608-
pages accepted by transaction queries and caps token-request queries so an
609-
unlimited scan cannot exhaust database resources. When the key is absent, the
610-
store default (1000) applies.
607+
Maximum number of rows a single storage-service read may return. It bounds the pages
608+
accepted by paginated queries and caps streaming iterator reads so an unlimited scan
609+
cannot exhaust database resources. When the key is absent, the default (1000) applies.
611610

612611
```yaml
613612
token:
614613
storage:
615614
maxPageSize: 1000
616615
```
617616

618-
See [Transaction Store API Limits](services/storage.md#transaction-store-api-limits)
619-
for details, including why movement/balance queries are intentionally not row-capped.
617+
See [Storage API Limits](services/storage.md#storage-api-limits) for details,
618+
including why movement/balance queries are intentionally not row-capped.
620619

621620
---
622621

docs/services/storage.md

Lines changed: 47 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -315,28 +315,50 @@ Cleanup behavior is controlled by the configuration section. See the [Configurat
315315

316316
See the [Configuration Guide](../configuration.md), Section `Optional: token.tms.<name>.services.network.fabric.recovery`, for detailed parameter descriptions and tuning recommendations.
317317

318-
## Transaction Store API Limits
319-
320-
The transaction store (used by both **TTXDB** and **AuditDB**) limits its writes and
321-
reads so one huge write or one runaway query can't eat up the database. The checks
322-
live in the single SQL implementation
323-
(`token/services/storage/db/sql/common/transactions.go`), so the owner and auditor
324-
stores are both covered.
325-
326-
**Write size limit.** `AddTokenRequest`, `AddTransaction`, and `AddMovement` reject a
327-
call whose payload is larger than `maxPayloadSize` (default 4 MiB; `0` turns the check
328-
off). `AddTokenRequest` measures the raw token request plus its metadata;
329-
transaction and movement records add up their field lengths instead, to keep the
330-
write path fast.
331-
332-
**Read size limits.** `QueryTransactions` needs a page with a size limit (`nil` and
333-
`pagination.None()` are rejected; callers page through the full result set).
334-
`QueryTokenRequests` adds a hard `LIMIT` equal to `maxPageSize` (default 1000).
335-
`QueryMovements` is left uncapped on purpose: it feeds balance totals, and dropping
336-
rows there would quietly return wrong balances. It is already narrowed by its required
337-
filters.
338-
339-
Both limits have built-in defaults. You can override them in configuration
340-
(`token.storage.maxPayloadSize` / `maxPageSize`) or in code with the
341-
`WithMaxPayloadSize` / `WithMaxPageSize` store options. See the
342-
[Configuration Guide](../configuration.md) for the config keys.
318+
## Storage API Limits
319+
320+
The storage service limits its writes and reads so one huge write or one runaway
321+
query can't exhaust the database (issue #1630, CWE-400 / CWE-770). The limits are
322+
enforced by a single, stackable **guard** decorator layer
323+
(`token/services/storage/db/guard/`) applied once at the multiplexed driver
324+
(`token/services/storage/db/multiplexed/driver.go`) as each store is created, so
325+
every backing driver (SQLite, PostgreSQL) and every store is covered by the same
326+
mechanism. The decorators embed the store interfaces and override only the methods
327+
that need a check, delegating everything else — new layers (metrics, tracing) can be
328+
stacked the same way, with the concrete SQL store at the bottom.
329+
330+
A single `Policy` (`MaxPayloadSize`, `MaxPageSize`) drives every check. It is loaded
331+
once from configuration; absent keys fall back to the built-in defaults, and an
332+
explicit `0` disables that check.
333+
334+
**Write size limit.** Writes whose serialised payload exceeds `maxPayloadSize`
335+
(default 4 MiB; `0` disables) are rejected before reaching the database. Covered
336+
writes include the transaction store's `AddTokenRequest` / `AddTransaction` /
337+
`AddMovement` / `AddTransactionEndorsementAck`, the token store's `StoreToken` /
338+
`StorePublicParams` / `StoreCertifications`, the endorser store's
339+
`AddValidationRecord`, the identity store's `StoreIdentityData` / `StoreSignerInfo` /
340+
`RegisterIdentityDescriptor` / `AddConfiguration`, and the wallet store's
341+
`StoreIdentity`.
342+
343+
**Read size limits.**
344+
- Paginated reads (`QueryTransactions`) require a bounded page: `nil` and
345+
`pagination.None()` are rejected, and a page size larger than `maxPageSize`
346+
(default 1000) is rejected. Callers page through the full result set.
347+
- Streaming iterator reads are capped at `maxPageSize` rows via a limiting iterator
348+
that errors (rather than silently truncating) once the cap is exceeded. Covered
349+
reads include the token store's unspent/spendable/unsupported iterators, the
350+
endorser store's `QueryValidations`, the transaction store's `QueryTokenRequests`,
351+
and the identity store's `IteratorConfigurations`.
352+
- `QueryMovements` is left uncapped on purpose: it feeds balance totals, and dropping
353+
rows there would quietly return wrong balances. It is already narrowed by its
354+
required filters.
355+
356+
You can override the limits in configuration (`token.storage.maxPayloadSize` /
357+
`token.storage.maxPageSize`) — see the [Configuration Guide](../configuration.md).
358+
359+
**Not yet covered (tracked follow-up).** Reads that materialise a full slice/map
360+
before returning (e.g. `ListUnspentTokens`, `QueryTokenDetails`,
361+
`ConfigurationsByID`, `GetWalletIDs`) cannot be bounded by a wrapper alone — the SQL
362+
has already loaded everything — so they need a SQL-level `LIMIT` or conversion to
363+
iterators. The opaque `Keystore.Put` value and input-size caps for variadic id lists
364+
(`DeleteTokens`, `GetTokens`) are in the same follow-up.
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package guard_test
8+
9+
import (
10+
"database/sql"
11+
"testing"
12+
13+
"github.com/DATA-DOG/go-sqlmock"
14+
driver "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver"
15+
"github.com/LFDT-Panurus/panurus/token/services/storage/db/guard"
16+
sqlcommon "github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/common"
17+
"github.com/LFDT-Panurus/panurus/token/services/storage/db/sql/query/pagination"
18+
"github.com/stretchr/testify/require"
19+
)
20+
21+
func ownerStore(t *testing.T, db *sql.DB, p guard.Policy) driver.TokenTransactionStore {
22+
t.Helper()
23+
store, err := sqlcommon.NewOwnerTransactionStore(db, db, sqlcommon.TableNames{
24+
Movements: "MOVEMENTS",
25+
Transactions: "TRANSACTIONS",
26+
Requests: "REQUESTS",
27+
TransactionEndorseAck: "TRANSACTION_ENDORSE_ACK",
28+
}, nil, nil)
29+
require.NoError(t, err)
30+
31+
return guard.WrapOwnerTransaction(store, p)
32+
}
33+
34+
// TestGuardRejectsOversizeWrite verifies the guard rejects an oversize write
35+
// on the atomic transaction before it reaches the database.
36+
func TestGuardRejectsOversizeWrite(t *testing.T) {
37+
db, mockDB, err := sqlmock.New()
38+
require.NoError(t, err)
39+
40+
store := ownerStore(t, db, guard.Policy{MaxPayloadSize: 20, MaxPageSize: 1000})
41+
mockDB.ExpectBegin()
42+
43+
w, err := store.NewTransactionStoreTransaction()
44+
require.NoError(t, err)
45+
46+
err = w.AddTokenRequest(t.Context(), "tx", make([]byte, 100), nil, nil, nil)
47+
require.Error(t, err)
48+
require.Contains(t, err.Error(), "exceeds maximum")
49+
require.NoError(t, mockDB.ExpectationsWereMet())
50+
}
51+
52+
// TestGuardDisabledWritePassesThrough verifies a zero payload limit disables
53+
// the check and the write proceeds to the database.
54+
func TestGuardDisabledWritePassesThrough(t *testing.T) {
55+
db, mockDB, err := sqlmock.New()
56+
require.NoError(t, err)
57+
58+
store := ownerStore(t, db, guard.Policy{MaxPayloadSize: 0, MaxPageSize: 1000})
59+
mockDB.ExpectBegin()
60+
mockDB.ExpectExec("INSERT INTO REQUESTS").WillReturnResult(sqlmock.NewResult(1, 1))
61+
62+
w, err := store.NewTransactionStoreTransaction()
63+
require.NoError(t, err)
64+
65+
require.NoError(t, w.AddTokenRequest(t.Context(), "tx", make([]byte, 100), nil, nil, nil))
66+
require.NoError(t, mockDB.ExpectationsWereMet())
67+
}
68+
69+
// TestGuardRejectsUnlimitedQuery verifies the read guard rejects nil and
70+
// unlimited (None) pagination without querying the database.
71+
func TestGuardRejectsUnlimitedQuery(t *testing.T) {
72+
db, _, err := sqlmock.New()
73+
require.NoError(t, err)
74+
75+
store := ownerStore(t, db, guard.DefaultPolicy())
76+
77+
_, err = store.QueryTransactions(t.Context(), driver.QueryTransactionsParams{}, nil)
78+
require.Error(t, err)
79+
80+
_, err = store.QueryTransactions(t.Context(), driver.QueryTransactionsParams{}, pagination.None())
81+
require.Error(t, err)
82+
}
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package guard
8+
9+
import (
10+
"context"
11+
12+
tokendriver "github.com/LFDT-Panurus/panurus/token/driver"
13+
driver "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver"
14+
)
15+
16+
// WrapEndorser wraps an endorser store with the guard policy.
17+
func WrapEndorser(s driver.EndorserStore, p Policy) driver.EndorserStore {
18+
if s == nil {
19+
return s
20+
}
21+
22+
return &guardedEndorserStore{EndorserStore: s, policy: p}
23+
}
24+
25+
type guardedEndorserStore struct {
26+
driver.EndorserStore
27+
policy Policy
28+
}
29+
30+
func (g *guardedEndorserStore) QueryValidations(ctx context.Context, params driver.QueryValidationRecordsParams) (driver.ValidationRecordsIterator, error) {
31+
it, err := g.EndorserStore.QueryValidations(ctx, params)
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
return LimitIterator(it, g.policy.MaxPageSize, "QueryValidations"), nil
37+
}
38+
39+
func (g *guardedEndorserStore) NewEndorserStoreTransaction() (driver.EndorserStoreTransaction, error) {
40+
w, err := g.EndorserStore.NewEndorserStoreTransaction()
41+
if err != nil {
42+
return nil, err
43+
}
44+
45+
return &guardedEndorserStoreTx{EndorserStoreTransaction: w, policy: g.policy}, nil
46+
}
47+
48+
type guardedEndorserStoreTx struct {
49+
driver.EndorserStoreTransaction
50+
policy Policy
51+
}
52+
53+
func (w *guardedEndorserStoreTx) AddValidationRecord(ctx context.Context, txID string, tokenRequest []byte, meta map[string][]byte, ppHash tokendriver.PPHash) error {
54+
size := len(txID) + len(tokenRequest) + BlobMapSize(meta)
55+
if err := CheckPayload("AddValidationRecord", size, w.policy.MaxPayloadSize); err != nil {
56+
return err
57+
}
58+
59+
return w.EndorserStoreTransaction.AddValidationRecord(ctx, txID, tokenRequest, meta, ppHash)
60+
}
Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package guard
8+
9+
import (
10+
"context"
11+
12+
tokendriver "github.com/LFDT-Panurus/panurus/token/driver"
13+
idriver "github.com/LFDT-Panurus/panurus/token/services/identity/driver"
14+
driver "github.com/LFDT-Panurus/panurus/token/services/storage/db/driver"
15+
)
16+
17+
// WrapIdentity wraps an identity store with the guard policy.
18+
func WrapIdentity(s driver.IdentityStore, p Policy) driver.IdentityStore {
19+
if s == nil {
20+
return s
21+
}
22+
23+
return &guardedIdentityStore{IdentityStore: s, policy: p}
24+
}
25+
26+
type guardedIdentityStore struct {
27+
driver.IdentityStore
28+
policy Policy
29+
}
30+
31+
func (g *guardedIdentityStore) IteratorConfigurations(ctx context.Context, configurationType string) (idriver.IdentityConfigurationIterator, error) {
32+
it, err := g.IdentityStore.IteratorConfigurations(ctx, configurationType)
33+
if err != nil {
34+
return nil, err
35+
}
36+
37+
return LimitIterator(it, g.policy.MaxPageSize, "IteratorConfigurations"), nil
38+
}
39+
40+
func (g *guardedIdentityStore) AddConfiguration(ctx context.Context, wp idriver.IdentityConfiguration) error {
41+
size := len(wp.ID) + len(wp.Type) + len(wp.URL) + len(wp.Config) + len(wp.Raw)
42+
if err := CheckPayload("AddConfiguration", size, g.policy.MaxPayloadSize); err != nil {
43+
return err
44+
}
45+
46+
return g.IdentityStore.AddConfiguration(ctx, wp)
47+
}
48+
49+
func (g *guardedIdentityStore) StoreIdentityData(ctx context.Context, id []byte, identityAudit []byte, tokenMetadata []byte, tokenMetadataAudit []byte) error {
50+
size := len(id) + len(identityAudit) + len(tokenMetadata) + len(tokenMetadataAudit)
51+
if err := CheckPayload("StoreIdentityData", size, g.policy.MaxPayloadSize); err != nil {
52+
return err
53+
}
54+
55+
return g.IdentityStore.StoreIdentityData(ctx, id, identityAudit, tokenMetadata, tokenMetadataAudit)
56+
}
57+
58+
func (g *guardedIdentityStore) StoreSignerInfo(ctx context.Context, id tokendriver.Identity, info []byte) error {
59+
if err := CheckPayload("StoreSignerInfo", len(id)+len(info), g.policy.MaxPayloadSize); err != nil {
60+
return err
61+
}
62+
63+
return g.IdentityStore.StoreSignerInfo(ctx, id, info)
64+
}
65+
66+
func (g *guardedIdentityStore) RegisterIdentityDescriptor(ctx context.Context, descriptor *idriver.IdentityDescriptor, alias tokendriver.Identity) error {
67+
size := len(alias)
68+
if descriptor != nil {
69+
size += len(descriptor.Identity) + len(descriptor.AuditInfo) + len(descriptor.SignerInfo)
70+
}
71+
if err := CheckPayload("RegisterIdentityDescriptor", size, g.policy.MaxPayloadSize); err != nil {
72+
return err
73+
}
74+
75+
return g.IdentityStore.RegisterIdentityDescriptor(ctx, descriptor, alias)
76+
}
Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
/*
2+
Copyright IBM Corp. All Rights Reserved.
3+
4+
SPDX-License-Identifier: Apache-2.0
5+
*/
6+
7+
package guard
8+
9+
import (
10+
"github.com/hyperledger-labs/fabric-smart-client/pkg/utils/errors"
11+
"github.com/hyperledger-labs/fabric-smart-client/platform/common/utils/collections/iterators"
12+
)
13+
14+
// LimitIterator wraps a streaming iterator so that reading more than max items
15+
// fails instead of materialising an unbounded result set. It returns an error
16+
// on the read that would exceed max (rather than silently truncating), so a
17+
// caller that legitimately needs the full set learns it must paginate.
18+
//
19+
// max <= 0 disables the cap, and a nil iterator is returned unchanged, so the
20+
// wrapper is safe to apply unconditionally. op names the read for the error.
21+
func LimitIterator[A any](it iterators.Iterator[*A], max int, op string) iterators.Iterator[*A] {
22+
if it == nil || max <= 0 {
23+
return it
24+
}
25+
26+
return &limitedIterator[A]{Iterator: it, max: max, op: op}
27+
}
28+
29+
type limitedIterator[A any] struct {
30+
iterators.Iterator[*A]
31+
max int
32+
count int
33+
op string
34+
}
35+
36+
func (l *limitedIterator[A]) Next() (*A, error) {
37+
next, err := l.Iterator.Next()
38+
if err != nil {
39+
return nil, err
40+
}
41+
if next == nil {
42+
// exhausted
43+
return nil, nil
44+
}
45+
l.count++
46+
if l.count > l.max {
47+
return nil, errors.Errorf("%s exceeded maximum of %d rows", l.op, l.max)
48+
}
49+
50+
return next, nil
51+
}

0 commit comments

Comments
 (0)