Skip to content

0013 — Security hardening pass

Status: accepted (2026-07-23)

Context

A documentation-time review of the deployed NestJS backend and edge config surfaced several concrete issues. They were fixed together in one pass.

Decision

Applied and deployed (customer + admin API, Caddy):

  1. Webhook HMACs verify raw bytes. The Fastify app now captures the raw request body (rawBody: true), so Razorpay, RazorpayX, and KYC webhook signature checks hash the exact bytes the provider signed instead of a re-serialized JSON body. Files: apps/api/src/main.ts, the three webhook controllers, kyc.service.ts.
  2. Bank-account penny-drop is owner-scoped. PATCH /v1/bank-accounts/:id/verify previously looked the account up by id only (an IDOR); it now scopes to the caller. Files: modules/payouts/bank-accounts.{controller,service}.ts.
  3. Payout retry respects the approval cap. POST /v1/payouts/:id/retry re-approves as the retrying admin's role, not a forced SUPER_ADMIN, so an ADMIN can no longer exceed the ₹1L cap by failing then retrying. Files: apps/admin-api/.../payouts.{controller,service}.ts.
  4. Rate limiting is enforced. ThrottlerGuard is now registered as a global guard (the module was configured at 100 req/60 s but no guard was wired). Provider webhooks, /health, and /metrics carry @SkipThrottle so callbacks/scrapes are never dropped (an adversarial review caught that @Public skips auth but not throttling). trustProxy is set to one hop so the rate-limit key is the real client IP Caddy appends, not a spoofable X-Forwarded-For. Files: apps/api/src/app.module.ts, apps/api/src/main.ts, the webhook/health/metrics controllers.
  5. Admin API CORS defaults closed. When ADMIN_PORTAL_ORIGIN is unset the admin API no longer reflects any origin — the portal calls it server-side, so browser cross-origin access is not needed. ADMIN_PORTAL_ORIGIN is now in gen-env.sh. File: apps/admin-api/src/main.ts.
  6. /metrics is not publicly reachable. Caddy returns 404 for /v1/metrics and /metrics on the API hosts (using handle blocks, since a bare respond sorts after reverse_proxy); Prometheus still scrapes the endpoints container-to-container on aim_edge. File: deploy/Caddyfile.

Merged via branch security/externalize-provider-credentials (PR #1, merged 2026-07-23) and live in production:

  1. Provider credentials removed from source. The alots.in SMS and SafeGold API keys were hardcoded as fallback defaults in two places — libs/config/src/app.config.ts and libs/common/src/sms/sms.service.ts (and thus in git history). Both are now env-only and required in productionlibs/config/src/validation.schema.ts fails boot if SAFEGOLD_API_KEY, SAFEGOLD_GOLD_TOKEN, SMS_USERNAME, or SMS_APIKEY is missing. The values now live only in /opt/aim/deploy/.env.production. Still open: the old values are in git history and must be rotated at the providers (tracked as an operational follow-up, not a code change).

Consequences

  • Webhook verification is now correct against real provider payloads.
  • The two access-control bugs (IDOR, cap bypass) are closed.
  • Rate limiting adds a 429 path; clients must handle it (the app/website already do single-flight refresh on 401, not 429, so a burst returns 429 to the caller).
  • Metrics are no longer information-disclosed publicly.
  • Item 7 requires an operational step (rotation) that only the account owners can perform; until then the compromised keys remain valid at the providers.
  • Not addressed here (tracked separately): purging the leaked keys from git history, and the website's client-side-only MPIN unlock (see the open question in Features).