[1430] GET /vaults query does not enforce max limit and allows limit=100000 to OOM the API - #1515
Conversation
main is currently unbuildable and its test suite is red, so every back-end CI job fails before it reaches the code under review. The breakage all traces back to two bad merges (9a15975, 7300a48) that truncated a Prisma model block, dropped a closing brace, renamed an imported limiter and replaced the idempotency replay cache. Prisma schema - close `WalletTenantAssociation` and separate it from `IdempotencyKey` (a missing `}` made `prisma generate` fail, so `@prisma/client` stayed an uninitialised stub and every suite crashed on import) - add `SessionAuditLog`, `Transaction.deletedAt` and `WebhookEndpoint.tenantId`, all of which sessionAudit.ts and the tenant boundary guard already query - add the matching migration and refresh the committed SQLite dev.db Type errors - import `readsLimiter` in vaultEndpoints.ts (used by GET /receipts) - restore the idempotency replay cache as idempotencyStore.ts and re-export it from idempotency.ts, so transferOrchestrator.ts, vaultEndpoints.ts, index.ts and idempotencyRetention.ts resolve - fix the unterminated `if` in diffSchemaShapes and a possibly-undefined `baseline.properties` read in apiContractSnapshots.ts - build the OTel resource through whichever factory the installed @opentelemetry/resources major exposes (v1 `new Resource()`, v2 `resourceFromAttributes()`) - `ZodObject._shape` → `.shape` for Zod 4 - return the strategy-switch 200 response, order receipts by `timestamp`, serialise session metadata, and type the request mocks in src/tests Error contract - restore `summary`, `errors` and 404 `path` on the error envelope Tests - issues711 "newly added required fields" mutated the live schema instead of the baseline snapshot, so it asserted against a scenario that can never produce the expected diff
An unauthenticated caller could pass `?limit=100000` and have it forwarded straight into Prisma's `take`, forcing the API to materialise 100k vault rows and OOM-kill the 512MB container. Chosen behaviour (documented in the route, the spec and the tests): reject rather than silently clamp. A caller asking for 100000 rows has a bug or is probing, and quietly returning 50 hides that behind a response that looks like a complete page. So `limit > 50` fails fast with `400` and `code: 'LIMIT_EXCEEDED'`, and the body repeats the ceiling. `page` is the opposite case — a benign mistake — so it is clamped into 1..1000 and the effective value is echoed in the pagination envelope. - new `middleware/paginationGuard.ts`: `DEFAULT_PAGE_SIZE` (20), `MAX_PAGE_SIZE` (50), `MAX_PAGE` (1000), `resolvePagination()` and the `enforcePaginationLimits()` middleware - new `routes/vaults.ts`: `GET /api/v1/vaults`, rate limited, reads `limit + 1` rows for the lookahead, and never exposes `tenantId` - `parsePaginationQuery` gains `maxPage` and clamps out-of-range pages; `PaginationQuerySchema.page` accepts a signed integer so those requests reach the clamp instead of being rejected - OpenAPI: reusable `pageSize`/`pageNumber` parameters carrying the ceilings, plus a `GET /api/v1/vaults` path documenting the LIMIT_EXCEEDED response; `openapi.json` regenerated - `GET /api/v1/vaults` joins the committed contract snapshots, so the response shape is now covered by the backward-compatibility check - tests spy on the Prisma client to prove no read is ever issued with a `take` above the ceiling, that an oversized limit is rejected before any query runs, and that the committed `openapi.json` still matches
|
@ToryMic Great news! 🎉 Based on an automated assessment of this PR, the linked Wave issue(s) no longer count against your application limits. You can now already apply to more issues while waiting for a review of this PR. Keep up the great work! 🚀 |
🧭 CI triage — which red checks are mine and which are not
Green on this branch and worth calling out
If a maintainer wants a follow-up PR (deliberately kept out of this one, each is a separate concern):
|
…s path `transferOrchestrator.test.ts` exercises the happy path end to end, but with no `REDIS_URL` the `redisClientManager` never reports ready, so `IdempotencyStore.redis` returns null and every Redis helper is skipped — the store's Redis branch, and more importantly its error fallback, had no coverage at all. These tests drive the store through a stub client so they reach: the Redis get/set/del round trip, conflict detection across instances, the read/write/delete failure fallbacks, and `pruneStaleKeys` across expired TTL, unparsable payloads and dryRun. Plus the in-process semantics that money-moving code relies on: single execution, replay, fingerprint conflict, in-flight coalescing, and that a rejected operation frees the pending slot for a retry. idempotencyStore.ts: 70.9% -> 94.5% statements, 50.9% -> 81.8% branches.
✅ Final local verification on this branchCoverage moved with it: Branch status: |
📋 Description
Goal
GET /api/v1/vaultsforwardedreq.query.limitstraight into Prisma'stakeafterz.coerce.number(), with no upper bound. An unauthenticated caller could ask for?limit=100000and force the API to materialise 100k vault rows plus their relations — a memory spike large enough to OOM-kill the 512 MB container.Closes #1430
Changes
Pagination ceilings (the fix)
backend/src/middleware/paginationGuard.tsexportingDEFAULT_PAGE_SIZE(20),MAX_PAGE_SIZE(50),MAX_PAGE(1000),resolvePagination()and theenforcePaginationLimits()middleware.backend/src/routes/vaults.ts—GET /api/v1/vaults, rate limited with the existingreadstier, readinglimit + 1rows for thehasNextPagelookahead and never exposingtenantId.parsePaginationQuerygains amaxPageconfig key and clamps out-of-range pages;PaginationQuerySchema.pagenow accepts a signed integer sopage=-1/page=1000000reach the clamp instead of being rejected with a 400.enforcePaginationLimits()pins the sanitisedlimit/pageonreq.resolvedPaginationfor the handler (declared insrc/types/express.d.ts, alongside the existing request augmentations).Chosen behaviour — documented, not incidental
This is documented in three places that are kept in sync by a test: the route's module docblock,
components.parameters.pageSizein the OpenAPI definition, and the test suite.OpenAPI + contract snapshots
components.parameters.pageSize/pageNumbercarry the ceilings (maximum: 50andmaximum: 1000) and are$ref-ed from the newGET /api/v1/vaultspath, which also documents the400/LIMIT_EXCEEDEDresponse with a full example.PaginationMetanow publisheslimitandcurrentPagebounds.GET /api/v1/vaultsjoinsCRITICAL_ENDPOINTSinapiContractSnapshots.tswith a committedschema-snapshots/get-_api_v1_vaults.json, so the response shape is covered by the existing backward-compatibility check.openapi.jsonregenerated (npm run generate:openapi), which also restores/api/v1/vaults/{id}/apy— that path was documented in the committed spec but had been dropped fromswagger.tsby a bad merge, so the "Verify OpenAPI documentation" job was failing onmain.Prerequisite:
maindoes not build or testThe first commit repairs pre-existing breakage so this branch can be validated at all. It is kept separate from the fix itself and is not part of the issue's scope:
prisma/schema.prisma:WalletTenantAssociationwas missing its closing}and ran intomodel IdempotencyKey, soprisma generatefailed and@prisma/clientstayed an uninitialised stub — every test suite crashed on import. Also addsSessionAuditLog,Transaction.deletedAtandWebhookEndpoint.tenantId(all already queried bysessionAudit.ts/ the tenant guard), plus the matching migration and a refreshedprisma/dev.db.readsLimiterwas used byGET /receiptsinvaultEndpoints.tswithout being imported.transferOrchestrator.ts/vaultEndpoints.ts/index.ts/idempotencyRetention.tsimport is restored asidempotencyStore.tsand re-exported fromidempotency.ts.ifindiffSchemaShapesand a possibly-undefinedbaseline.propertiesread inapiContractSnapshots.ts.@opentelemetry/resourcesmajor exposes;ZodObject._shape→.shapefor Zod 4; strategy-switch 200 response returned; receipts ordered bytimestamp; session metadata serialised.summary/errors/ 404pathon the error envelope, which a bad merge had dropped.issues711.test.ts"newly added required fields" mutated the live schema instead of the baseline snapshot, so it asserted a scenario that can never produce the expected diff.🔗 Type of Change
🛡️ Risk Assessment
Risk Level
Blast Radius & Impact Analysis
Detailed Risk & Blast Radius Notes:
🔄 Rollback Plan
Rollback Strategy & Feasibility
Rollback Trigger Criteria
Step-by-Step Rollback Procedure
git revertthe two commits (or revert the tip; the prerequisite repair is safe to keep).DROP TABLE "SessionAuditLog"; ALTER TABLE "Transaction" DROP COLUMN "deletedAt"; ALTER TABLE "WebhookEndpoint" DROP COLUMN "tenantId";cd backend && npx prisma migrate deploy && npm run prisma:generateto resync the client.⚡ Performance Impact
Performance & Resource Assessment
Performance & Gas Profiling Summary:
🔒 SECURITY REVIEW
Smart-contract sections (
Reentrancy,CEI,Slither, gas limits) do not apply — this PR touches no Solidity.limit/pageare parsed as exact base-10 integers; anything else (floats,1e5, arrays, negatives) falls back to the default rather than being coerced. Repeated query parameters are ignored rather than guessed at.tenantIdis not part of the response projection.limit=50(accepted),limit=51andlimit=100000(400),limit=abc/0/-5/1.5/''(default),page=-1→ 1,page=100000→ 1000.take > 51, and that an oversized limit is rejected before any query is issued.Option A: Fixed in This PR ✅
📝 Testing
Functional Testing
Security Testing
MAX_PAGE_SIZE,MAX_PAGE_SIZE + 1,100000, and non-integer inputTest Coverage
src/middleware/paginationGuard.ts95% stmts / 94% branches,src/routes/vaults.ts94% stmtsbackend/src/__tests__/vaultsListLimits.test.ts, 23 testsNew tests in
vaultsListLimits.test.ts:limit=50accepted;limit=51andlimit=100000→ 400LIMIT_EXCEEDEDlimit=100000 / 999999 / 51 / 50 / 1 / unset, nofindManycall ever hastake > 51limit=100000issues zerofindManyand zerocountcallspageclamped to 1..1000, and the derivedskipis capped at(1000 - 1) * limitget-_api_v1_vaults.jsoncontract snapshot;tenantIdnever leaksresolvePaginationand the middleware (including repeated query parameters)pageSize/pageNumbercarry the ceilings,/api/v1/vaults$refs them, the 400 documentsLIMIT_EXCEEDED, and the committedopenapi.jsonstill matchesspecs🚀 Deployment Notes
Mainnet Readiness
Known issues inherited from
main(out of scope, flagged for maintainers)These fail identically on
mainand are unrelated to this issue. Listed so review is not blocked by them:npm testcoverage gate.jest.config.jsrequires 80% global coverage; the repo currently sits at ~67% (7248/10828 statements) because large parts ofsrc/have no tests at all (operationalMetrics.ts,eventPollingService.ts,impersonationSessionService.ts,writeAheadAuditLog.ts,src/tests/*, …). Closing that gap is a repo-wide programme, not a pagination fix. All 92 suites / 1342 tests themselves pass.npm run prisma:schema-check.ScopedAdminTokenhas bothkeyId @uniqueand@@index([keyId]); the committed migration created the uniqueness as an implicitsqlite_autoindex, soprisma migrate diffwants to redefine it. Making it zero-diff requires aDROP INDEX, whichscripts/check-migrations.jsclassifies as an error — so the two gates cannot both be satisfied without a maintainer decision.scripts/validate-*.tsat the repo root pass;dependency-security.yml'spnpm auditand CodeQL also fail onmainand depend on repository settings/network, not on this diff.✅ Reviewer Checklist
📞 Questions or Issues?
docs/SECURITY_CHECKLIST.mddocs/FALSE_POSITIVE_HANDLING.md@security-teamin comments