Skip to main content

Security & Hardening

Single source of truth for KA26's defensive posture: rate limiting, IDOR enforcement, audit logging, financial-attack defense, and the structured SecurityEvent audit table.

Built up incrementally through pre-launch audits, but consolidated 2026-05-02 in a focused red-team / blue-team pass that closed 6 production holes.

1. Defensive layers

KA26 sits behind 5 layers, each catching what the layer above missed:

LayerWhat it doesWhere it lives
L1 — Cloudflare DNS-onlyDDoS scrubbing on ka-26.comCloudflare Free plan
L2 — Auth required by defaultAll write endpoints + every seller/doctor/DP-scoped endpoint check JWTsrc/lib/consumer-auth.ts, src/lib/auth.ts, src/lib/delivery-auth.ts
L3 — IP rate limitIn-memory RATE_LIMITS constants, per-IP bucketssrc/lib/security.ts
L4 — Per-user rate limitCatches botnet bypass of L3 (multi-IP attacks against one user)src/lib/security.ts
L5 — Ownership / IDOR checkEvery [id] route asserts record.ownerId === auth.id before respondingevery route handler

2. Rate-limit constants

All in src/lib/security.ts under RATE_LIMITS. In-memory, single-instance (Cloud Run). Migrate to Redis when scaling to 2+ instances.

ConstantLimitBucket keyWhy
AUTH_LOGIN5 / 5minIPBrute force defense
AUTH_REGISTER3 / 10minIPAnti bot account creation
INVITE_CREATE3 / 1hrIPAnti spam
INVITE_VERIFY10 / 1minIPPin-code brute-force
PRODUCT_CREATE5 / 5minsellerAnti listing spam
PRODUCT_UPDATE20 / 5minsellerBulk-edit headroom
UPLOAD10 / 5minsellerAnti GCS abuse
PUBLIC_READ60 / 1minIPGeneric read throttle
PUBLIC_WRITE10 / 5minIPGeneric write throttle
SAVED_SEARCH5 / 10minIPAnti spam
ORDER_CREATE15 / 5minconsumerAnti bot orders
PUSH_REGISTER5 / 5minconsumerAnti token-table-flood
BID_CREATE10 / 5minbuyerBuyer is hard-capped at 5 active bids; this prevents abuse traffic
BID_MESSAGE30 / 5minbuyer:offerOne every 10s is generous for negotiation
REEL_CREATE5 / 1hrcreatorEach reel = GCS upload + DB row
REEL_COMMENT30 / 5minconsumerAnti UGC spam
REEL_REPORT10 / 1hrconsumerAnti false-flag DoS of moderation
REEL_LIKE60 / 1minfingerprintAnti rating inflation
REEL_VIEW120 / 1minIPAnti view-count distortion (silent OK on overflow)
DOCTOR_VOICE_PX20 / 5mindoctorCaps Sarvam STT cost per doctor
DOCTOR_BROADCAST5 / 5mindoctorPatient-spam protection

3. Per-user OTP brute-force lock (2026-05-02)

POST /api/auth/verify-email originally only had an IP-keyed limit. A botnet across 1000 IPs × 10 codes/5min = 1M codes/5min could brute the 6-digit space inside an hour.

Added a second-tier per-userId lock: 5 wrong codes per consumerId (or sellerId / doctorId) per 5 minutes, regardless of source IP. After 5 wrong codes the user must request a fresh code.

When this fires, we emit a SecurityEvent.SUSPICIOUS_ACTIVITY row with reason: "otp_brute_force_user_lock" — actionable signal in the audit table for "someone is targeting this specific user."

4. Withdrawal TOCTOU race fix (2026-05-02)

POST /api/seller/withdrawals had a classic TOCTOU race: balance read + create were two separate Prisma calls outside any transaction. 100 concurrent requests would each pass the balance check independently, then ALL get created → drain 100× balance.

Fix: wrapped the entire flow in prisma.$transaction({ isolationLevel: "Serializable" }). Postgres detects concurrent serialization conflicts and aborts the loser, which Prisma surfaces as P2034 / 40001. We catch and return 409 with retry hint. The pending-withdrawal guard moved INSIDE the transaction (was outside, also racy).

When a serialization conflict fires, we emit SecurityEvent.SUSPICIOUS_ACTIVITY with reason: "withdrawal_concurrent_serialization_conflict". Legitimate users don't fire two withdrawals at once — this signal is high-confidence evidence of an attack attempt.

5. Internal-key gating

Some endpoints are internal-only (called by other server-side code, not the public). They previously had no auth at all. Now they require x-internal-key: $INTERNAL_API_KEY header. Returns generic 404 (not 401/403) so attackers can't probe the endpoint's existence.

Currently gated:

  • POST /api/saved-searches/notify — triggers WhatsApp notifications to subscribers; was publicly callable

The INTERNAL_API_KEY env var (32-byte hex) is set on Cloud Run.

6. Anti-scraping: trust-score binning

GET /api/seller/trust-score?id=X is intentionally public (consumers see a trust badge). Originally returned exact totalSales integer per seller — let attackers iterate ?id=1,2,3,... and scrape every seller's name + sales volume.

Now:

  • IP rate-limited (60 / min) — blocks scrapers at scale
  • Returns binned totalSalesRange ("5-9", "100-249", etc.) instead of exact int — preserves badge UI, removes precise scraping

7. Structured audit logging — SecurityEvent table

Added 2026-05-02. New Prisma model:

model SecurityEvent {
id Int @id @default(autoincrement())
event String // matches enum in lib/security.ts
ip String?
userId Int?
userType String? // "consumer" | "seller" | "doctor" | "delivery"
route String?
details Json?
createdAt DateTime @default(now())

@@index([event, createdAt])
@@index([ip, createdAt])
@@index([userId, userType])
@@index([createdAt])
}

logSecurity() in src/lib/security.ts does both:

  1. console.log("[SECURITY] ...") for Cloud Logging (stdout)
  2. Fire-and-forget prisma.securityEvent.create({...}) — late-imports prisma to avoid circular dep, swallows DB failures so a write blip can't crash the originating request

Currently wired sites

Will be retrofit incrementally to every 401/429/403 site. As of 2026-05-02:

  • POST /api/errors/reportRATE_LIMITED on overflow
  • POST /api/saved-searches/notifyUNAUTHORIZED_ACCESS on missing key
  • POST /api/auth/verify-emailSUSPICIOUS_ACTIVITY on per-user OTP brute-force lock
  • POST /api/seller/withdrawalsSUSPICIOUS_ACTIVITY on serialization conflict

Event types

SecurityEvent enum in src/lib/security.ts:

EventMeaning
LOGIN_SUCCESS / LOGIN_FAILEDAuth attempt outcome
REGISTER_SUCCESS / REGISTER_FAILEDRegistration outcome
RATE_LIMITEDA 429 fired
UNAUTHORIZED_ACCESSA 401/403 fired
OWNERSHIP_VIOLATIONAn IDOR check tripped
SUSPICIOUS_ACTIVITYHigh-signal attack indicator
INVITE_CREATED / INVITE_ACCEPTED / INVITE_INVALIDReferral lifecycle
PRODUCT_CREATED / PRODUCT_DELETEDHigh-frequency seller actions
UPLOAD_SUCCESS / UPLOAD_REJECTEDFile upload lifecycle
INVALID_INPUTValidation failure

8. Consumer surface attack inventory

Tested 2026-05-02. All defended.

AttackDefense
Anonymous order creationAuth required
Mass account creation (disposable email)Disposable-email blocklist
Mass account creation (real-domain)AUTH_REGISTER rate limit
OTP brute forceIP rate limit + per-userId lock
IDOR — read other consumer's ordersOwnership check
Feedback spamAuth required
Voice search abuse (Sarvam cost)voice-search rate limit (20/5min/IP)
Cron endpoint hijackSecret check
Errors/report floodIP rate limit (60/5min) + 16KB payload cap
Analytics flood (pwa-install / pwa-launch)IP rate limits, silent OK on overflow
saved-searches/notify exposureINTERNAL_API_KEY header required
Unbounded query DoS (?limit=99999)Server caps result sets

9. Seller surface attack inventory

Tested 2026-05-02. All defended.

AttackDefense
Anonymous access to any seller endpointAuth required (15/15)
Seller enumeration via trust-scoreRate limit + binned totalSalesRange
IDOR — read other seller's stores / orders / campaigns / imagesOwnership check
Self-promote to verified (skip docs)Status flips only via admin route
Cross-seller notification leakScoped to authenticated sellerId
Mass product creationPRODUCT_CREATE rate limit (5/5min/seller)
Mass file uploadUPLOAD rate limit (10/5min/seller)
Bypass valid order status flowgetValidStatusTransitions enforces state machine
Withdrawal TOCTOU raceSerializable transaction + 409 on conflict
Seller registration / login floodAUTH_REGISTER + AUTH_LOGIN rate limits

10. Doctor surface

AttackDefense
Anonymous accessAuth required
Voice prescription Sarvam cost burnDOCTOR_VOICE_PX rate limit (20/5min/doctor)
Patient broadcast spamDOCTOR_BROADCAST rate limit (5/5min/doctor)
IDOR — see another doctor's patientsScoped to authenticated doctorId

11. Delivery-partner surface

AttackDefense
Anonymous accessAuth required
Cross-DP assignment status mutationOwnership check on assignment.deliveryPartnerId
Cross-DP location writeScoped to auth.deliveryPartnerId

12. Test coverage

5 dedicated security suites, 51 regression-pinning tests as of 2026-05-02. All in tests/:

  • consumer-account-deletion.test.ts — 15 (Play Store / DPDP compliance)
  • security-red-team-2026-05-02.test.ts — 10 (consumer-side critical fixes)
  • security-seller-red-team-2026-05-02.test.ts — 7 (seller-side fixes)
  • security-followups-2026-05-02.test.ts — 19 (reels rate limits, doctor rate limits, SecurityEvent table, logSecurity wiring)

If a future PR removes any of these protections, the relevant test fires.

13. Known limitations + future work

  1. No CAPTCHA anywhere — acceptable at ~100 DAU; revisit at 5K+ DAU (hCaptcha or Cloudflare Turnstile on registration + OTP)
  2. In-memory rate limit, single-instance — works while Cloud Run runs at min-instances=0 / single concurrent instance. Migrate to Redis when 2+ instances.
  3. Most 429/403 sites don't yet call logSecurity() — ~10 sites are wired, ~30 remain. Retrofit is mechanical, can be done incrementally.
  4. No /admin/security dashboard yet — the SecurityEvent table is queryable but no UI surfaces it. Build when you have a recurring need.
  5. JWT iat-based force-logout corner case — works in practice but has a subtle interaction with jsonwebtoken's noTimestamp option. Not a security risk because deleted accounts have no recoverable login path.