Api key guard hammers - #131
Conversation
EmeditWeb
left a comment
There was a problem hiding this comment.
⚠️ Automated Audit: partial
@Adeyemi-cmd Good start — please look into the gaps identified below.
The code diffs genuinely implement all five requirements from issue #121: cache-backed key records keyed by hash (never full keys) with a 60s TTL, throttled last_used_at writes guarded by a 300s cache flag, normalized single API_KEY_UNAUTHORIZED error code, per-key sliding-window rate limiting via cache-manager with a structured 429, and cache invalidation on revocation in VendorsService. The api-key.guard.spec.ts tests were substantively rewritten to assert cache-hit-avoids-DB, revocation invalidation, rate-limit trip/reset, and error-code uniformity, and vendors.service.spec.ts verifies the cache deletes. However, the PR description is a copy-pasted empty template (Title/Description/Changes Made/Screenshots/Notes all empty or placeholder-only) and it mismatches the issue: it never mentions #121's API-key caching work, instead describing a different TOCTOU nonce/verify fix, so the claimed linkage is unsubstantiated by the description and the coverage of the intended root cause is unverified by any written explanation. Independent CI passed (build-test) and there are no merge conflicts, but because the description is placeholder-only and the actual issue-specific behavior is only evidenced via code/diffs rather than a coherent explanation, and per the rules vague/placeholder descriptions are author-fixable gaps that preclude full approval, this is partial rather than solves.
Gaps identified:
- PR description is an unfilled copy-pasted template (Title, Description, Changes Made, Screenshots, Additional Notes all blank) — must explain WHAT was fixed, WHY it links to #121, and HOW it was tested
- Description content describes a different change (AuthService nonce TOCTOU / verify throttling) and never actually documents the ApiKeyGuard caching/rate-limit/error-normalization work that is the core of #121 — issue linkage and root-cause explanation are missing
- Confirm negative (non-existent key) lookups are not cached is acceptable, but the rate-limit increment resets the full sliding TTL on every request, meaning a steady high-volume key never actually resets/windows correctly — verify window semantics match spec intent
- Regression tests are present and updated, but no explicit test asserts write-amplification bounding (the maybeUpdateLastUsed dirty-flag TTL skip path) — the cache-guard logic is untested directly
CI checks: ✅ PASSED: build-test
Merge conflicts: ✅ none — but the PR is blocked (failing/missing required checks or reviews).
Audited by stepfi-audit-bot 🤖
…into ApiKeyGuard_hammers — preserve atomic nonce claim (StepFi-app#121) Merge upstream/main cbd05ad (fix: domain-bind wallet signature challenges) while retaining ApiKeyGuard hardening (6a52a0d) and atomic nonce claim (5dd4772). Resolves conflicts: - src/modules/auth/auth.service.ts: retains generateNonce message + message_hash/issued_at + buildChallengeMessage from cbd05ad and injects atomic UPDATE ... count:'exact' claim before verify (burn-on-failure) from 5dd4772; removes trailing UPDATE; helpers resolveChallengeMessage/assertChallengeBinding preserved. - test/unit/modules/auth/auth.service.spec.ts: merged suites keep origin domain-binding + HEAD TOCTOU atomicity (parallel double-verify, replay burned, expired burned) and fixes claimResult type. - DTOs/e2e/env/docs/migration synced from upstream (AUTH_* vars, message field, signatureType, 20260825000000_add_nonce...). No merge markers, npm run build green, 94 targeted tests pass. Closes StepFi-app#121, incorporates StepFi-app#118.
f46a49a to
d9e8568
Compare
Closes #121
PR: Harden ApiKeyGuard hot path + fix SIWE nonce TOCTOU race
🔖 Title
hard: ApiKeyGuard hammers Supabase per request— cache key records by hash, collapse write amplification, normalize errors, per-key rate limit, revocation invalidation; plusSIWE nonce TOCTOUatomic claim + wallet throttling (5dd4772)📝 Description
What was the problem?
ApiKeyGuard.canActivate()(src/auth/guards/api-key.guard.ts:35–114) was the scalability ceiling and a DoS amplifier:**sha256(key)→.select('*').eq('key_hash', hash).single()plus an unconditionalUPDATE last_used_at(src/auth/guards/api-key.guard.ts:104–113old). Under vendor traffic this is pure hot-path load; flooding random keys saturates the Supabase connection pool.API_KEY_INVALIDvsAPI_KEY_INACTIVEvsAPI_KEY_EXPIRED(src/auth/guards/api-key.guard.ts:59–80old) let an attacker distinguish revoked vs expired vs nonexistent keys. Low severity alone, sloppy combined with (1).permissionscheck used.some(includes)with no wildcard/hierarchy, pushing consumers toward over-permissioned keys.updateLastUsedfires on every request, no throttling.Incidental hardening in same branch —
AuthService.verifySignature()TOCTOU (5dd4772):SELECT is('used_at', null).single() → verify → UPDATE used_at. Two concurrentPOST /auth/verifywith the same(wallet, nonce, signature)both observedused_at IS NULL, both verified, both succeeded — a classic TOCTOU replay that could mint unlimited sessions from one intercepted pair. The trailingUPDATEat oldsrc/modules/auth/auth.service.ts:158was not atomic with the read.🔄 Changes Made
Core —
src/auth/guards/api-key.guard.ts(src/auth/guards/api-key.guard.ts:1)CACHE_MANAGER(@Inject(CACHE_MANAGER) private readonly cacheManager: Cache) — samecache-manager+ioredispattern already used insrc/modules/liquidity/liquidity.service.ts:54andsrc/modules/transactions/transactions.service.ts:121. No new infra.src/auth/guards/api-key.guard.ts:40):src/auth/guards/api-key.guard.ts:45):canActivate()(src/auth/guards/api-key.guard.ts:67):x-api-key, hashes withcreateHash('sha256'), triescacheManager.get<ApiKeyRecord>(recordCacheKey)first (src/auth/guards/api-key.guard.ts:88). On miss, does the single DBSELECT(src/auth/guards/api-key.guard.ts:96); negative lookups are not cached to avoid polluting the store.src/auth/guards/api-key.guard.ts:111):!is_activeand expiredexpires_atboth throw oneUnauthorizedException({ code: 'API_KEY_UNAUTHORIZED', message: 'Invalid API key.' }). Distinct reasons only inLogger.warn(8-char hash prefix /keyId) — blocks enumeration of revoked vs expired vs nonexistent. Missing/malformed header also maps to same code (src/auth/guards/api-key.guard.ts:75).src/auth/guards/api-key.guard.ts:130): double-checkscacheManager.getbeforeset(recordCacheKey, keyRecord, 60)so inactive/expired never enter cache; new validated path explicitly avoids caching earlier. Revocation explicitly invalidates viaVendorsService.enforceRateLimit(keyRecord.id, keyHash)(src/auth/guards/api-key.guard.ts:143) before permission checks.src/auth/guards/api-key.guard.ts:145some(includes)) but now gated behind rate limit.void this.maybeUpdateLastUsed(keyRecord.id)(src/auth/guards/api-key.guard.ts:164) instead ofawait+ unconditional write.request.apiKeycontract (src/auth/guards/api-key.guard.ts:166request.apiKey = keyRecord).enforceRateLimit()(src/auth/guards/api-key.guard.ts:170):cacheManager.get<number>(rateKey) ?? 0, if>=60throwsHttpException({ code:'API_KEY_RATE_LIMITED' }, 429). Otherwiseset(rateKey, next, 60)— sliding window (each request resets TTL to full window). Cache failures are logged and fail-open (src/auth/guards/api-key.guard.ts:191catches and re-throws onlyHttpException).maybeUpdateLastUsed()(src/auth/guards/api-key.guard.ts:198): checkscacheManager.get<boolean>(lastUsedKey); if flagged, returns. Otherwiseupdate({ last_used_at })+set(lastUsedKey, true, 300). Errors logged atwarn, never throw. Collapses writes to at-most-once-per-5-min per key.Invalidation —
src/modules/vendors/vendors.service.ts(src/modules/vendors/vendors.service.ts:1)CACHE_MANAGER(src/modules/vendors/vendors.service.ts:111@Inject(CACHE_MANAGER) private readonly cacheManager: Cache).revokeApiKey()(src/modules/vendors/vendors.service.ts:636) nowselect('id, key_hash')(was justidcheck), updatesis_active=false, then:src/modules/vendors/vendors.service.ts:676) — guarantees revoked key is rejected within one TTL (60s) even if it was cached, and resets its rate/last-used state. Failures areLogger.warn-only, so revocation still succeeds if Redis is down.Wiring —
src/app.module.ts:13CacheModule(CacheModule.registerAsync({ isGlobal: true, useFactory: getRedisConfig, inject: [ConfigService] })) soApiKeyGuardandVendorsServiceshare the same Redis/in-memory store. Required for the per-keyapikey:*keys to be visible across modules. Uses existinggetRedisConfig(src/config/redis.config.ts) — no new dependency.Auth nonce TOCTOU —
src/modules/auth/auth.service.ts(5dd4772)verifySignature()(src/modules/auth/auth.service.ts:120): replacedSELECT → verify → UPDATEwith atomic claim before verification:count===1; losercount===0→AUTH_NONCE_NOT_FOUND(src/modules/auth/auth.service.ts:207–225). Expiry check moved after claim so expired rows stay burned (src/modules/auth/auth.service.ts:230). TrailingUPDATEremoved. Burn-on-failure tradeoff documented in code: invalid signature / badStrKey/ expired nonce still consumes the challenge (caller mustPOST /auth/nonceagain).src/modules/auth/auth-throttler.guard.ts:1(new):AuthWalletThrottlerGuard extends ThrottlerGuardkeys onreq.body.wallet(unauthenticated verify) fallback toreq.user.wallet/ IP (src/modules/auth/auth-throttler.guard.ts:14). Used alongside global IP guard soPOST /auth/verifyis bounded per wallet AND per IP (5 req/60s).src/modules/auth/auth.controller.ts:13— adds@UseGuards(AuthWalletThrottlerGuard)toPOST /auth/verify(src/modules/auth/auth.controller.ts:78), keeps@Throttle({ default: { limit:5, ttl:60000 } }), adds429Swagger response.src/modules/transactions/wallet-throttler.guard.ts:1hardened to acceptbody.wallet(src/modules/transactions/wallet-throttler.guard.ts:12) and type-check strings, so same infrastructure is reused.Tests
test/unit/modules/auth/api-key.guard.spec.ts:1(rewritten, ~500 lines) — asserts:cacheManager.getreturnsApiKeyRecord→selectmock not called; asserts never stores raw key (key_hashonly, prefixapikey:record:<hash>).VendorsService.revokeApiKeydeletes three keys, manualdelthen DB re-check.canActivatecalls → 61st throws429 API_KEY_RATE_LIMITED(HttpException429); TTL expiry resets counter.API_KEY_UNAUTHORIZED(same code/message); onlyAPI_KEY_INSUFFICIENT_PERMISSIONS(403) andAPI_KEY_RATE_LIMITED(429) remain distinct.maybeUpdateLastUseddirty-flag TTL skip path covered (second call within 300s does not issueUPDATE;updateEqFncall counts andlast_usedcache flag mocked). Explicitly addresses audit gap [12] Add unit tests forAuthService#4.test/unit/modules/vendors/vendors.service.spec.ts:14— providesCACHE_MANAGERmock, verifiesrevokeApiKeydeletesapikey:record:<hash>,apikey:rate:<keyId>,apikey:last_used:<keyId>and tolerates cache failures.test/unit/modules/auth/auth.service.spec.ts:1(extended) — atomicity coverage:should throw AUTH_NONCE_NOT_FOUND when atomic claim loses race (count===0), expiry stays burned,should mark nonce as used via atomic claim before signature verification,burn the nonce even when signature verification fails(second callNOT_FOUND),verifySignature — atomicity / concurrency:parallel double-verify yields exactly one success,replay after success fails,replay during failure burns,expired nonce stays burned.test/unit/modules/auth/auth-throttler.guard.spec.ts:1(new) andtest/unit/modules/auth/auth.controller.spec.ts— wallet-keyed throttler tracker (wallet:…vs fallback IP), controller guards mocked.context/progress-tracker.md:11updated per Ground Rules.