---
title: Security Audit — 2026-05-24
tags: [audit, security, findings]
created: 2026-05-24
status: open
---

# Security Audit — 2026-05-24

Full-codebase security review triggered by the completion of Telegram first-class auth, Request Network payment integration, and rate-limiting enablement. Every finding below was verified against actual source code — no hypothetical issues are included.

> [!danger] Action required
> 6 CRITICAL findings exist. C-3 and C-4 are exploitable in any non-`development` environment right now and must be fixed before staging is accessible to external testers.

---

## CRITICAL

### C-1 — Hardcoded Admin Password as Fallback Default
**File:** `src/infrastructure/database/init-admin.ts:7`
**Status:** Open

```ts
const adminPassword = process.env.ADMIN_PASSWORD || 'Moji6364';
```

If `ADMIN_PASSWORD` is not set, the admin account is seeded with a password that is now committed to version control. Combined with the predictable default email (`admin@marketplace.com`), the admin account is trivially discoverable.

**Remediation:**
- Remove the `|| 'Moji6364'` fallback entirely.
- Add a startup assertion: `if (!process.env.ADMIN_PASSWORD) throw new Error('ADMIN_PASSWORD is required')`.
- Rotate the credential immediately on any environment where the default may have been used.

---

### C-2 — Admin Password Logged to Stdout on Every Fresh Deploy
**File:** `src/infrastructure/database/init-admin.ts:50`
**Status:** Open

```ts
console.log(`🔑 Password: ${adminPassword}`);
```

The raw admin password (env-supplied or hardcoded default) is written to stdout on every seeding run. Container log aggregators, CloudWatch, Sentry breadcrumbs, and anyone with log-viewer access will have the credential.

**Remediation:** Delete line 50 entirely. Log only `"Admin user created successfully"` — never the credential value.

---

### C-3 — Simulated Transaction Bypass Active in Production
**File:** `src/services/payment/paymentRoutes.ts:379–396`
**Status:** Open

```ts
if (paymentHash.startsWith('SIM_') ||
    (paymentHash.startsWith('0x') && paymentHash.length < 64)) {
  isVerified = true;
  ...
}
```

No environment guard. Any client that sends `paymentHash: "SIM_anything"` in production gets `isVerified = true`, causing a real `Payment` record to be created with `status: 'completed'` and `PurchaseRequest` to advance to `processing` — without any actual funds.

**Remediation:**
```ts
if (process.env.NODE_ENV !== 'production') {
  // SIM_ test path
}
```
Or gate on a separate `ENABLE_PAYMENT_SIMULATION=true` env flag that is absent from production env files.

---

### C-4 — `forceVerifyUser` Gate Uses Wrong Condition
**File:** `src/services/auth/authController.ts:1127–1159` and `src/services/auth/authRoutes.ts:60`
**Status:** Open

```ts
if (process.env.NODE_ENV !== "development") {
  return ResponseHandler.forbidden(res, "...");
}
```

`undefined !== "development"` is `true`, so when `NODE_ENV` is unset (common in CI, some staging configs) the guard **passes** and any unauthenticated caller can verify any user's email instantly, bypassing the entire verification flow.

**Remediation:**
- Change condition to `process.env.NODE_ENV === "development"` (allowlist, not denylist).
- Require an admin JWT on the route regardless of env.
- Consider removing the route from `authRoutes.ts` and only mounting it conditionally at the app level.

---

### C-5 — Hardcoded SHKeeper Admin Credential in Source
**File:** `src/services/payment/shkeeper/shkeeperPayoutService.ts:224–228`
**Status:** Open

```ts
'Authorization': `Basic ${Buffer.from(`admin:!NMI4WdGkVQ#dQ`).toString('base64')}`
```

A plaintext password is committed to version-controlled source. It will be included in every build artifact, Docker image layer, and any repository fork or export.

**Remediation:**
- Replace with `process.env.SHKEEPER_ADMIN_PASSWORD`.
- Add to required-env-vars startup check.
- Rotate the credential immediately.

---

### C-6 — Access Token and Refresh Token Share the Same Signing Secret
**File:** `src/services/auth/authService.ts:18,54`
**Status:** Open

Both `generateToken` (access) and `generateRefreshToken` (refresh) use `this.JWT_SECRET`. The only distinction between token types is the `type: 'refresh'` payload claim — a field under caller control. Algorithm-confusion or secret-leakage attacks can forge long-lived refresh tokens using the access token secret.

**Remediation:**
- Introduce `REFRESH_TOKEN_SECRET` env var (separate value, ≥32 chars).
- Sign refresh tokens with it; verify refresh tokens only against it.
- This fully segregates the two token families.

---

## HIGH

### H-1 — Telegram Replay Map Lost on Server Restart (In-Memory Only)
**File:** `src/services/telegram/telegramService.ts:395–407`
**Status:** Open

```ts
const initDataReplayWindow = new Map<string, number>();
```

`initData` replay protection, the webhook replay window, and the Request Network delivery ID deduplication are all stored in process-local `Map` objects. Any process restart, pod restart, or scale-out creates a fresh empty map. A captured `initData` is replayable immediately after any restart, within the configured max-age window (up to 24 hours).

**Remediation:** Replace with Redis `SET NX EX` pattern:
```ts
const key = `replay:telegram:${fingerprint}`;
const inserted = await redis.set(key, '1', 'NX', 'EX', windowSeconds);
if (!inserted) throw new ReplayError();
```

---

### H-2 — SHKeeper Webhook Authentication Has Spoofable Bypass
**File:** `src/services/payment/shkeeper/shkeeperWebhook.ts:95–103`
**Status:** Open

```ts
const isShkeeperWebhook =
  req.headers['x-shkeeper-api-key'] ||
  req.headers['user-agent']?.includes('python-requests') ||
  payload.crypto === 'BNB-USDT';
```

Any caller who sets `User-Agent: python-requests/2.x.x` or includes `"crypto": "BNB-USDT"` in the body can inject arbitrary payment completion events. The `x-shkeeper-api-key` header value is not validated — its presence alone is sufficient.

Additionally, the HMAC failure branch only rejects in `production` environments (`!== 'development'`), meaning unset `NODE_ENV` silently ignores invalid signatures.

**Remediation:**
- Remove the heuristic `isShkeeperWebhook` fallback entirely.
- Require `SHKEEPER_WEBHOOK_SECRET` at startup; fail if absent.
- Change environment guard from `!== 'development'` to `=== 'development'` (or always enforce).

---

### H-3 — Request Network `allowTestMode: true` Hardcoded in Production Route
**File:** `src/services/payment/requestNetwork/requestNetworkRoutes.ts:104–105` and `signature.ts:51–53`
**Status:** Open

```ts
allowTestMode: true,
// in signature.ts:
if (allowTestMode && isTestHeader(headers, testHeader)) {
  return true; // skip verification
}
```

Any request bearing `x-request-network-test: 1` (or `true` / `yes`) completely bypasses HMAC signature verification. The flag is unconditional — there is no environment check.

**Remediation:**
```ts
allowTestMode: process.env.NODE_ENV !== 'production',
```
Or introduce an explicit `ENABLE_RN_TEST_MODE=true` env flag absent from production.

---

### H-4 — Typing Indicator Socket Events Have No Chat Membership Check (IDOR)
**File:** `src/app.ts:267–291`
**Status:** Open

`typing-start` and `typing-stop` handlers verify only `data.userId === userId` (the caller is who they claim to be) but do not verify the caller is a participant of `data.chatId`. Any authenticated user who knows a chat ID can broadcast typing presence to that room — information disclosure.

**Remediation:** Check membership before broadcasting:
```ts
const chat = await Chat.findById(data.chatId, { participants: 1 }).lean();
const isMember = chat?.participants.some(p => p.userId.equals(userId));
if (!isMember) return;
```
Or maintain a per-socket set of joined room IDs and validate against it without a DB round-trip.

---

### H-5 — Financial Events Broadcast to All Connected Sockets
**File:** `src/services/payment/shkeeper/shkeeperWebhook.ts:546,560`
**Status:** Open

```ts
global.io?.emit('seller-offer-update', { sellerId: ..., paymentId: ..., transactionHash: ... });
```

`io.emit()` sends to every connected socket. Payment completion events including `paymentId`, `transactionHash`, and `offerId` are sent to all users — any client can observe other users' financial events.

**Remediation:**
```ts
global.io?.to(`seller-${selectedOffer.sellerId}`).emit('seller-offer-update', { ... });
global.io?.to(`buyer-${payment.buyerId}`).emit('payment-completed', { ... });
```

---

## MEDIUM

### M-1 — OTP and Reset Codes Logged in Plaintext (All Environments)
**File:** `src/services/auth/authController.ts:174,203,715,757`
**Status:** Open

```ts
console.log(`🔢 Generated verification code for ${email}: ${emailVerificationCode}`);
console.log(`🔢 Generated password reset code for ${email}: ${resetCode}`);
```

6-digit OTPs and password reset codes are written to stdout with no environment guard. Anyone with log access can take over any unverified account or reset any password.

**Remediation:** Delete all four log lines. Never log secrets or codes in any environment.

---

### M-2 — `Math.random()` Used for OTP Generation (Not a CSPRNG)
**File:** `src/services/auth/authService.ts:226`
**Status:** Open

```ts
return Math.floor(100000 + Math.random() * 900000).toString();
```

`Math.random()` is not cryptographically secure. Replace with:
```ts
const bytes = crypto.randomBytes(3);
return (100000 + (bytes.readUIntBE(0, 3) % 900000)).toString();
```

---

### M-3 — Refresh Token Rotation Has No Theft Detection
**File:** `src/services/auth/authController.ts:510–529`
**Status:** Open

Token rotation works (old is removed, new is issued). However, if an attacker steals a refresh token and rotates it first, the legitimate user's subsequent refresh gets `403` but the attacker's session continues. There is no "token already rotated" detection that would invalidate all sessions for that user.

**Remediation:** Implement refresh token families. If a token that has already been rotated is presented again, treat it as a theft signal and set `user.refreshTokens = []` (force re-login everywhere).

---

### M-4 — Profile Update Uses `validateBeforeSave: false` with Full Object Spread
**File:** `src/services/auth/authController.ts:921–938`
**Status:** Open

```ts
user.profile = { ...user.profile, ...profile };
user.preferences = { ...user.preferences, ...preferences };
await user.save({ validateBeforeSave: false });
```

The entire `profile` and `preferences` objects from the request body are spread without an explicit allowlist. `validateBeforeSave: false` additionally skips schema-level guards. A user could inject unexpected fields into their document.

**Remediation:** Use an explicit pick:
```ts
const allowedProfile = pick(profile, ['phone', 'bio', 'website']);
user.profile = { ...user.profile, ...allowedProfile };
```
Remove `{ validateBeforeSave: false }`.

---

### M-5 — Telegram Login Widget Path Has No Replay Protection
**File:** `src/services/auth/authController.ts:110–116`
**Status:** Open

The Mini App flow correctly calls `checkMiniAppReplay` / `rememberMiniAppInitData`. The Login Widget flow (the `else` branch) calls only `verifyTelegramLoginWidget` — no replay check. The Login Widget payload contains a static `hash` valid for up to `miniAppMaxAgeMs` (up to 24 hours). An intercepted payload can be replayed freely within that window.

**Remediation:** Apply the same replay-map (or Redis key) logic to the Login Widget path, keying on `loginWidget.hash`.

---

### M-6 — No JWT Secret Strength Enforcement at Startup
**File:** `src/shared/config/index.ts:42`
**Status:** Open

`JWT_SECRET` is read with `process.env.JWT_SECRET!`. An empty string, `"secret"`, or `"changeme"` is silently accepted, producing trivially forgeable JWTs.

**Remediation:** Add to startup checks:
```ts
if (config.jwtSecret.length < 32) throw new Error('JWT_SECRET must be at least 32 characters');
```

---

### M-7 — Legacy `verifyEmail` Token Route Has No Expiry Check
**File:** `src/services/auth/authController.ts:667–692`
**Status:** Open

The code-based flow (`/verify-email-code`) enforces a 15-minute expiry. The legacy URL-based flow (`GET /verify-email/:token`) queries only by token value, with no `emailVerificationTokenExpires` check. If the token field is persisted without an expiry date, it never becomes invalid.

**Remediation:** Either add `emailVerificationTokenExpires: { $gt: new Date() }` to the query, or deprecate and remove this route if the code-based flow has fully replaced it.

---

## LOW

### L-1 — Passkey Challenge Debug Logs Expose All Active Challenges
**File:** `src/services/auth/passkeyService.ts:128–130,207–212`
**Status:** Open

```ts
console.log('🔍 Available challenges:', Array.from(this.storedChallenges.keys()));
// ...
allUsers.forEach(u => { console.log(`User ${u.email}:`, u.passkeys.map(pk => pk.id)); });
```

On any failed passkey assertion, every registered user's email and all their passkey IDs are dumped to logs. An attacker who triggers many failed assertions can enumerate the passkey corpus from log infrastructure.

**Remediation:** Remove both blocks entirely. Log only the failed assertion's credential ID.

---

### L-2 — In-Memory Login Attempt Counters Not Shared Across Replicas
**File:** `src/services/auth/authService.ts:112`
**Status:** Open

`authAttempts: Map<string, ...>` is process-local. In a multi-replica deployment, an attacker distributing login attempts across replicas bypasses per-user lockout.

**Remediation:** Move to Redis with TTL-expiring keys (same pattern as the rate-limiter Redis adapter that is already planned).

---

### L-3 — CORS Origin: Unset `FRONTEND_URL` Allows All Origins
**File:** `src/app.ts:332`
**Status:** Open

`cors({ origin: process.env.FRONTEND_URL })` — if `FRONTEND_URL` is unset, `cors` treats `undefined` as "allow all origins."

**Remediation:** Add a startup assertion that `FRONTEND_URL` is a non-empty string.

---

### L-4 — Auth Rate-Limit Counters Are In-Memory (Multi-Replica Gap)
**File:** `src/app.ts` (rate-limit middleware configuration)
**Status:** Open

Same class as L-2. The rate-limit counters are in-memory. Distributing requests across replicas bypasses per-IP limits. A Redis store adapter is already planned.

---

## Confirmed PASS (Verified Correctly Handled)

| Check | Result | Source |
|-------|--------|--------|
| HMAC timing-safe comparison (SHKeeper + RN) | PASS | `shkeeperWebhook.ts:84`, `signature.ts:74` |
| Telegram HMAC derivation (Mini App + Login Widget) | PASS | `telegramService.ts:223-278` |
| Bot account rejection | PASS | `telegramService.ts:278,353` |
| Blocked Telegram user check (`status: 'blocked'`) | PASS | `authController.ts:355-363` |
| Refresh token rotation (old removed before new issued) | PASS | `authController.ts:527-529` |
| Password change clears all refresh tokens | PASS | `authController.ts:887` |
| Password reset clears all refresh tokens | PASS | `authController.ts:797,846` |
| Socket.IO JWT enforcement on connect | PASS | `app.ts:76-94` |
| `join-user-room` IDOR prevention | PASS | `app.ts:114` |
| `join-chat-room` membership check | PASS | `app.ts:241-247` |
| bcrypt work factor = 12 | PASS | `authService.ts` |
| WebAuthn challenge consumed on first use | PASS | `passkeyService.ts:87` |
| Telegram `auth_date` freshness enforcement | PASS | `telegramService.ts:208-223` |

---

## Summary

| Severity | Count |
|----------|-------|
| CRITICAL | 6 |
| HIGH | 5 |
| MEDIUM | 7 |
| LOW | 4 |

**Immediate priority:** C-3 (simulation bypass) and C-4 (forceVerify gate) are one-line fixes and are exploitable today. C-1, C-2, C-5 (hardcoded/logged credentials) must be resolved and rotated before any external access to staging or production.

---

## Related

- [[Security Architecture]]
- [[Authentication Flow]]
- [[Webhook Security Spec]]
- [[Threat Model - Amanat Escrow Platform]]
- [[Logic Audit - 2026-05-24]]
- [[Performance Audit - 2026-05-24]]
