Files
nick-doc/09 - Audits/Security Audit - 2026-05-24.md
Siavash Sameni 940ad0c655 Add full system audit reports and Telegram Mini App debug handoff
- Three-stream audit (security / logic / performance) with 35+ findings
  derived from actual source code, each with file:line and remediation
- Audit Index cross-references criticals across streams into prioritized
  fix tiers: immediately / before soft launch / before public launch
- Telegram Mini App debug handoff documenting what was implemented and
  all remaining work items with exact file lists and test commands
- Updated architecture, data model, auth API, and registration flow docs
  to reflect Telegram auth, TON wallet, and email verification additions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-24 17:20:08 +04:00

16 KiB
Raw Blame History

title, tags, created, status
title tags created status
Security Audit — 2026-05-24
audit
security
findings
2026-05-24 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

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

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:379396 Status: Open

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:

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:11271159 and src/services/auth/authRoutes.ts:60 Status: Open

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:224228 Status: Open

'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:395407 Status: Open

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:

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:95103 Status: Open

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:104105 and signature.ts:5153 Status: Open

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:

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:267291 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:

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

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:

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

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

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

Math.random() is not cryptographically secure. Replace with:

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:510529 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:921938 Status: Open

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:

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:110116 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:

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:667692 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:128130,207212 Status: Open

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.