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:
| Layer | What it does | Where it lives |
|---|---|---|
| L1 — Cloudflare DNS-only | DDoS scrubbing on ka-26.com | Cloudflare Free plan |
| L2 — Auth required by default | All write endpoints + every seller/doctor/DP-scoped endpoint check JWT | src/lib/consumer-auth.ts, src/lib/auth.ts, src/lib/delivery-auth.ts |
| L3 — IP rate limit | In-memory RATE_LIMITS constants, per-IP buckets | src/lib/security.ts |
| L4 — Per-user rate limit | Catches botnet bypass of L3 (multi-IP attacks against one user) | src/lib/security.ts |
| L5 — Ownership / IDOR check | Every [id] route asserts record.ownerId === auth.id before responding | every 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.
| Constant | Limit | Bucket key | Why |
|---|---|---|---|
AUTH_LOGIN | 5 / 5min | IP | Brute force defense |
AUTH_REGISTER | 3 / 10min | IP | Anti bot account creation |
INVITE_CREATE | 3 / 1hr | IP | Anti spam |
INVITE_VERIFY | 10 / 1min | IP | Pin-code brute-force |
PRODUCT_CREATE | 5 / 5min | seller | Anti listing spam |
PRODUCT_UPDATE | 20 / 5min | seller | Bulk-edit headroom |
UPLOAD | 10 / 5min | seller | Anti GCS abuse |
PUBLIC_READ | 60 / 1min | IP | Generic read throttle |
PUBLIC_WRITE | 10 / 5min | IP | Generic write throttle |
SAVED_SEARCH | 5 / 10min | IP | Anti spam |
ORDER_CREATE | 15 / 5min | consumer | Anti bot orders |
PUSH_REGISTER | 5 / 5min | consumer | Anti token-table-flood |
BID_CREATE | 10 / 5min | buyer | Buyer is hard-capped at 5 active bids; this prevents abuse traffic |
BID_MESSAGE | 30 / 5min | buyer:offer | One every 10s is generous for negotiation |
REEL_CREATE | 5 / 1hr | creator | Each reel = GCS upload + DB row |
REEL_COMMENT | 30 / 5min | consumer | Anti UGC spam |
REEL_REPORT | 10 / 1hr | consumer | Anti false-flag DoS of moderation |
REEL_LIKE | 60 / 1min | fingerprint | Anti rating inflation |
REEL_VIEW | 120 / 1min | IP | Anti view-count distortion (silent OK on overflow) |
DOCTOR_VOICE_PX | 20 / 5min | doctor | Caps Sarvam STT cost per doctor |
DOCTOR_BROADCAST | 5 / 5min | doctor | Patient-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:
console.log("[SECURITY] ...")for Cloud Logging (stdout)- 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/report—RATE_LIMITEDon overflowPOST /api/saved-searches/notify—UNAUTHORIZED_ACCESSon missing keyPOST /api/auth/verify-email—SUSPICIOUS_ACTIVITYon per-user OTP brute-force lockPOST /api/seller/withdrawals—SUSPICIOUS_ACTIVITYon serialization conflict
Event types
SecurityEvent enum in src/lib/security.ts:
| Event | Meaning |
|---|---|
LOGIN_SUCCESS / LOGIN_FAILED | Auth attempt outcome |
REGISTER_SUCCESS / REGISTER_FAILED | Registration outcome |
RATE_LIMITED | A 429 fired |
UNAUTHORIZED_ACCESS | A 401/403 fired |
OWNERSHIP_VIOLATION | An IDOR check tripped |
SUSPICIOUS_ACTIVITY | High-signal attack indicator |
INVITE_CREATED / INVITE_ACCEPTED / INVITE_INVALID | Referral lifecycle |
PRODUCT_CREATED / PRODUCT_DELETED | High-frequency seller actions |
UPLOAD_SUCCESS / UPLOAD_REJECTED | File upload lifecycle |
INVALID_INPUT | Validation failure |
8. Consumer surface attack inventory
Tested 2026-05-02. All defended.
| Attack | Defense |
|---|---|
| Anonymous order creation | Auth required |
| Mass account creation (disposable email) | Disposable-email blocklist |
| Mass account creation (real-domain) | AUTH_REGISTER rate limit |
| OTP brute force | IP rate limit + per-userId lock |
| IDOR — read other consumer's orders | Ownership check |
| Feedback spam | Auth required |
| Voice search abuse (Sarvam cost) | voice-search rate limit (20/5min/IP) |
| Cron endpoint hijack | Secret check |
| Errors/report flood | IP rate limit (60/5min) + 16KB payload cap |
Analytics flood (pwa-install / pwa-launch) | IP rate limits, silent OK on overflow |
saved-searches/notify exposure | INTERNAL_API_KEY header required |
Unbounded query DoS (?limit=99999) | Server caps result sets |
9. Seller surface attack inventory
Tested 2026-05-02. All defended.
| Attack | Defense |
|---|---|
| Anonymous access to any seller endpoint | Auth required (15/15) |
| Seller enumeration via trust-score | Rate limit + binned totalSalesRange |
| IDOR — read other seller's stores / orders / campaigns / images | Ownership check |
| Self-promote to verified (skip docs) | Status flips only via admin route |
| Cross-seller notification leak | Scoped to authenticated sellerId |
| Mass product creation | PRODUCT_CREATE rate limit (5/5min/seller) |
| Mass file upload | UPLOAD rate limit (10/5min/seller) |
| Bypass valid order status flow | getValidStatusTransitions enforces state machine |
| Withdrawal TOCTOU race | Serializable transaction + 409 on conflict |
| Seller registration / login flood | AUTH_REGISTER + AUTH_LOGIN rate limits |
10. Doctor surface
| Attack | Defense |
|---|---|
| Anonymous access | Auth required |
| Voice prescription Sarvam cost burn | DOCTOR_VOICE_PX rate limit (20/5min/doctor) |
| Patient broadcast spam | DOCTOR_BROADCAST rate limit (5/5min/doctor) |
| IDOR — see another doctor's patients | Scoped to authenticated doctorId |
11. Delivery-partner surface
| Attack | Defense |
|---|---|
| Anonymous access | Auth required |
| Cross-DP assignment status mutation | Ownership check on assignment.deliveryPartnerId |
| Cross-DP location write | Scoped 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
- No CAPTCHA anywhere — acceptable at ~100 DAU; revisit at 5K+ DAU (hCaptcha or Cloudflare Turnstile on registration + OTP)
- In-memory rate limit, single-instance — works while Cloud Run runs at min-instances=0 / single concurrent instance. Migrate to Redis when 2+ instances.
- Most 429/403 sites don't yet call
logSecurity()— ~10 sites are wired, ~30 remain. Retrofit is mechanical, can be done incrementally. - No
/admin/securitydashboard yet — the SecurityEvent table is queryable but no UI surfaces it. Build when you have a recurring need. - JWT iat-based force-logout corner case — works in practice but has a subtle interaction with
jsonwebtoken'snoTimestampoption. Not a security risk because deleted accounts have no recoverable login path.
14. Related
- Account Deletion — the DPDP-compliant deletion flow this complements
- Why monolith — why we don't yet have Redis or a separate auth service
- Testing philosophy — how the regression-pinning tests are structured
- System health — the broader observability story
- CHANGELOG entry — full context