- 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>
16 KiB
title, tags, created, status
| title | tags | created | status | |||
|---|---|---|---|---|---|---|
| Security Audit — 2026-05-24 |
|
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-
developmentenvironment 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:379–396
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:1127–1159 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.tsand 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
'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_SECRETenv 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
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:95–103
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
isShkeeperWebhookfallback entirely. - Require
SHKEEPER_WEBHOOK_SECRETat 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
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: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:
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: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
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: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:
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
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.