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>
This commit is contained in:
123
09 - Audits/Audit Index - 2026-05-24.md
Normal file
123
09 - Audits/Audit Index - 2026-05-24.md
Normal file
@@ -0,0 +1,123 @@
|
||||
---
|
||||
title: Audit Index — 2026-05-24
|
||||
tags: [audit, index, security, logic, performance]
|
||||
created: 2026-05-24
|
||||
status: open
|
||||
---
|
||||
|
||||
# Audit Index — 2026-05-24
|
||||
|
||||
Full-system audit triggered by completion of Telegram first-class auth, Request Network integration, rate-limiting enablement, and funds ledger. Three parallel audit streams were run against actual source code.
|
||||
|
||||
| Report | Findings |
|
||||
|--------|---------|
|
||||
| [[Security Audit - 2026-05-24]] | 6 critical · 5 high · 7 medium · 4 low |
|
||||
| [[Logic Audit - 2026-05-24]] | 4 critical · 5 high · 7 medium · 2 low |
|
||||
| [[Performance Audit - 2026-05-24]] | 6 high · 8 medium · 4 low |
|
||||
|
||||
---
|
||||
|
||||
## Cross-Cutting Criticals (Fix Immediately)
|
||||
|
||||
These items appear in multiple audit streams or are exploitable right now.
|
||||
|
||||
| ID | Severity | What | Where | Fix Effort |
|
||||
|----|----------|------|-------|------------|
|
||||
| SEC-C3 / LOG-CRIT3 | CRITICAL | Simulated transaction bypass (`SIM_*`) active in production | `paymentRoutes.ts:379` | 1 line — wrap in `NODE_ENV !== 'production'` |
|
||||
| SEC-C4 | CRITICAL | `forceVerifyUser` gate is wrong (`!== development` → unset env passes) | `authController.ts:1127` | 1 line — flip to `=== 'development'` |
|
||||
| SEC-C1 / SEC-C2 | CRITICAL | Hardcoded admin password + logged to stdout on every deploy | `init-admin.ts:7,50` | Remove fallback + delete log line + rotate credential |
|
||||
| SEC-C5 / LOG-CRIT4 | CRITICAL | Hardcoded SHKeeper admin credential in source | `shkeeperPayoutService.ts:224` | Move to env var + rotate |
|
||||
| SEC-C6 | CRITICAL | Access and refresh tokens share the same JWT signing secret | `authService.ts:18,54` | Add `REFRESH_TOKEN_SECRET` env var |
|
||||
| LOG-CRIT1 | CRITICAL | Concurrent webhooks can double-process the same payment (no DB-level lock) | `paymentCoordinator.ts` / `shkeeperWebhook.ts` | Atomic `findOneAndUpdate` with status guard |
|
||||
| LOG-CRIT2 | CRITICAL | Parallel Telegram auth creates orphan User documents (TOCTOU on link+user creation) | `authController.ts:377` | Upsert link first, create user only if upsert won |
|
||||
|
||||
---
|
||||
|
||||
## High-Priority Queue (Fix Before Soft Launch)
|
||||
|
||||
| ID | Stream | What | Where |
|
||||
|----|--------|------|-------|
|
||||
| SEC-H2 | Security | SHKeeper webhook authentication bypass via `User-Agent` / `crypto` heuristic | `shkeeperWebhook.ts:95` |
|
||||
| SEC-H3 | Security | Request Network `allowTestMode: true` hardcoded — test header skips all sig verification | `requestNetworkRoutes.ts:104` |
|
||||
| SEC-H5 | Security | `global.io.emit(...)` broadcasts financial event data to all connected sockets | `shkeeperWebhook.ts:546` |
|
||||
| SEC-H4 | Security | Typing indicator IDOR — no chat membership check | `app.ts:267` |
|
||||
| SEC-H1 | Security | Telegram in-memory replay map reset on restart; replay possible within 24h window | `telegramService.ts:395` |
|
||||
| LOG-HIGH5 | Logic | `verifyEmailWithCode` non-atomic User.save + TempVerification.delete | `authController.ts:620` |
|
||||
| LOG-HIGH3 | Logic | `refreshTokens[]` array grows unboundedly | `authController.ts:62` |
|
||||
| LOG-HIGH4 | Logic | Blocked Telegram user bypasses block when TelegramLink is deleted | `authController.ts:355` |
|
||||
| LOG-MED5 | Logic | Unauthenticated `/payment/callback` endpoint can mutate any payment status | `paymentControllerRoutes.ts:20` |
|
||||
| PERF-H3 | Performance | Unbounded seller fan-out on new request: `User.find({role:'seller'})` + N socket emits | `PurchaseRequestService.ts:190` |
|
||||
| PERF-H4 | Performance | Full chat document (~250 MB for large chats) loaded into memory for every paginated request | `ChatService.ts:370` |
|
||||
|
||||
---
|
||||
|
||||
## Medium Priority (Fix Before Public Launch)
|
||||
|
||||
| ID | Stream | What | Where |
|
||||
|----|--------|------|-------|
|
||||
| SEC-M1 | Security | OTP + reset codes logged in plaintext in all environments | `authController.ts:174,715` |
|
||||
| SEC-M2 | Security | `Math.random()` used for OTP (not CSPRNG) | `authService.ts:226` |
|
||||
| SEC-M3 | Security | No refresh token reuse/theft detection | `authController.ts:510` |
|
||||
| SEC-M4 | Security | Profile update mass-assignment + `validateBeforeSave: false` | `authController.ts:921` |
|
||||
| SEC-M5 | Security | Login Widget auth has no replay protection (Mini App has it) | `authController.ts:110` |
|
||||
| SEC-M6 | Security | No JWT secret length enforcement at startup | `config/index.ts:42` |
|
||||
| LOG-MED1 | Logic | Funds ledger availability check + append not atomic (concurrent double-release possible) | `releaseRefundService.ts:37` |
|
||||
| LOG-MED2 | Logic | `updatePurchaseRequestStatus` bypasses state machine validator | `PurchaseRequestService.ts:551` |
|
||||
| LOG-MED3 | Logic | `getUserPayments` queries `userId` (wrong field) — always returns empty | `paymentService.ts:342` |
|
||||
| LOG-MED4 | Logic | Payout created without verifying a completed inbound payment exists | `shkeeperPayoutService.ts:42` |
|
||||
| PERF-H1 | Performance | N+1: one `Payment.findOne` per request row in buyer dashboard | `PurchaseRequestService.ts:516` |
|
||||
| PERF-H2 | Performance | Missing index on `Payment.purchaseRequestId` | `models/Payment.ts:190` |
|
||||
| PERF-M1 | Performance | Missing compound index `(buyerId, createdAt)` on PurchaseRequest | `models/PurchaseRequest.ts:360` |
|
||||
| PERF-M2 | Performance | Unanchored regex on title/description — full collection scan | `PurchaseRequestService.ts:703` |
|
||||
| PERF-M7 | Performance | `user-online` event broadcast to all sockets globally | `app.ts:300` |
|
||||
|
||||
---
|
||||
|
||||
## Low Priority / Hardening
|
||||
|
||||
| ID | Stream | What |
|
||||
|----|--------|------|
|
||||
| SEC-L1 | Security | Passkey challenge debug logs expose all active challenges + all users' passkey IDs |
|
||||
| SEC-L2 | Security | Login attempt counters in-memory (multi-replica bypass possible) |
|
||||
| SEC-L3 | Security | `FRONTEND_URL` unset allows CORS `*` |
|
||||
| SEC-M7 | Security | Legacy `verifyEmail` token route has no expiry check |
|
||||
| LOG-LOW1 | Logic | Duplicate `/payment/callback` route definition |
|
||||
| LOG-MED7 | Logic | `acceptOffer` notification uses undefined `offer.title` |
|
||||
| PERF-M3 | Performance | Double-fetch pattern in update methods (no `.lean()` on pre-check) |
|
||||
| PERF-M6 | Performance | `getSellers()` unbounded — no `.limit()` |
|
||||
| PERF-M8 | Performance | Post-filter after pagination causes wrong `totalItems` count |
|
||||
|
||||
---
|
||||
|
||||
## Recommended Index Additions
|
||||
|
||||
Add these to eliminate the collection scans identified in the performance audit:
|
||||
|
||||
```ts
|
||||
// models/Payment.ts
|
||||
paymentSchema.index({ purchaseRequestId: 1, status: 1 });
|
||||
|
||||
// models/PurchaseRequest.ts
|
||||
PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Items Confirmed Correctly Handled (PASS)
|
||||
|
||||
- HMAC timing-safe comparison on all webhooks ✓
|
||||
- Telegram `initData` HMAC derivation and bot account rejection ✓
|
||||
- Blocked Telegram user check on existing links ✓
|
||||
- Refresh token rotation (old removed before new issued) ✓
|
||||
- Password change / reset clears all refresh tokens ✓
|
||||
- Socket.IO JWT enforcement on connect ✓
|
||||
- `join-chat-room` membership check ✓
|
||||
- bcrypt work factor = 12 ✓
|
||||
- WebAuthn challenge consumed on first use ✓
|
||||
- All TTL indexes (TempVerification, TelegramSession, Notification) ✓
|
||||
- FundsLedgerEntry idempotency key (sparse unique index) ✓
|
||||
- SHKeeper polling bounded and self-cleaning ✓
|
||||
- Socket.IO room cleanup on disconnect ✓
|
||||
343
09 - Audits/Logic Audit - 2026-05-24.md
Normal file
343
09 - Audits/Logic Audit - 2026-05-24.md
Normal file
@@ -0,0 +1,343 @@
|
||||
---
|
||||
title: Logic & Correctness Audit — 2026-05-24
|
||||
tags: [audit, logic, correctness, findings]
|
||||
created: 2026-05-24
|
||||
status: open
|
||||
---
|
||||
|
||||
# Logic & Correctness Audit — 2026-05-24
|
||||
|
||||
Full-codebase review of business logic, state machines, race conditions, and data integrity. Triggered by completion of Request Network integration, Telegram first-class auth, and the internal funds ledger. Every finding is derived from actual source code — no hypothetical issues are included.
|
||||
|
||||
> [!danger] Action required
|
||||
> 4 CRITICAL findings: two are exploitable in the current production code path (double-spend via concurrent webhooks, simulated-payment bypass); two cause data corruption (orphan User documents, non-atomic create/delete).
|
||||
|
||||
---
|
||||
|
||||
## CRITICAL
|
||||
|
||||
### CRIT-1 — Concurrent Webhooks Can Double-Process the Same Payment
|
||||
**File:** `src/services/payment/shkeeper/shkeeperWebhook.ts:195–241` and `src/services/payment/paymentCoordinator.ts:25–60`
|
||||
**Status:** Open
|
||||
|
||||
The duplicate-detection guard uses an in-memory 10-second window. There is no database-level atomic lock. Two simultaneous webhook deliveries for the same `external_id` will both read `payment` before either writes, both pass the in-memory deduplication check, both enter `PaymentCoordinator.coordinatePaymentUpdate`, and both proceed — because `coordinationState` is an in-memory `Map` whose `get/set` pair is not atomic under async concurrency (any `await` between them allows the event loop to interleave).
|
||||
|
||||
The `status === 'completed'` DB guard at `paymentCoordinator.ts:54` only protects re-processing of already-completed payments. The first webhook has not yet written `completed` when the second arrives, so both pass.
|
||||
|
||||
Side effects of double-processing:
|
||||
- Duplicate chats created between buyer and seller (`chatService.createChat` has no uniqueness guard).
|
||||
- Duplicate socket events sent to the buyer.
|
||||
- Depending on timing, duplicate state transitions on `PurchaseRequest`.
|
||||
|
||||
**Remediation:** Replace the coordinator's check with an atomic MongoDB conditional update:
|
||||
```ts
|
||||
const result = await Payment.findOneAndUpdate(
|
||||
{ _id: paymentId, status: { $ne: 'completed' } },
|
||||
{ $set: { status: 'completed', ... } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!result) return; // already processed
|
||||
```
|
||||
For chat creation, add a unique compound index on `{ 'relatedTo.type': 1, 'relatedTo.id': 1 }` and handle the duplicate key error gracefully.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-2 — Telegram Auto-Provisioning Race Creates Orphan User Documents
|
||||
**File:** `src/services/auth/authController.ts:377–434`
|
||||
**Status:** Open
|
||||
|
||||
The flow is:
|
||||
1. `TelegramLink.findOne({ telegramUserId })` → null (new user)
|
||||
2. `new User({...}).save()` — creates User document
|
||||
3. `TelegramLink.findOneAndUpdate({ telegramUserId }, ..., { upsert: true })`
|
||||
|
||||
Two parallel Telegram auth requests for the same `telegramUserId` both find no link, both create a new `User`, and both try to upsert `TelegramLink`. Because `TelegramLink.telegramUserId` has `unique: true`, only one upsert wins. The loser's `User.save()` has already committed, producing a fully-active `User` document with no `TelegramLink` — it can never be authenticated via Telegram and will not be cleaned up.
|
||||
|
||||
**Remediation:** Use optimistic locking or a MongoDB transaction. Practical approach without transactions:
|
||||
```ts
|
||||
// Step 1: try to upsert the link first
|
||||
const link = await TelegramLink.findOneAndUpdate(
|
||||
{ telegramUserId },
|
||||
{ $setOnInsert: { telegramUserId, source, ... } },
|
||||
{ upsert: true, new: true, rawResult: true }
|
||||
);
|
||||
const isNew = link.lastErrorObject?.upserted != null;
|
||||
// Step 2: create User only if this process won the upsert
|
||||
if (isNew) { user = await User.create({...}); await link.value.updateOne({ userId: user._id }); }
|
||||
else { user = await User.findById(link.value.userId); }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### CRIT-3 — Simulated Transaction Bypass Active in Production
|
||||
**File:** `src/services/payment/paymentRoutes.ts:379–396`
|
||||
**Status:** Open
|
||||
|
||||
*(Also cited in Security Audit C-3.)*
|
||||
|
||||
```ts
|
||||
if (paymentHash.startsWith('SIM_') || ...) {
|
||||
isVerified = true;
|
||||
}
|
||||
```
|
||||
|
||||
No environment guard. A client sending `paymentHash: "SIM_anything"` gets a completed `Payment` and the `PurchaseRequest` advances without any real funds.
|
||||
|
||||
**Remediation:** Wrap in `if (process.env.NODE_ENV !== 'production')`.
|
||||
|
||||
---
|
||||
|
||||
### CRIT-4 — Hardcoded Credential in Production Payment Code
|
||||
**File:** `src/services/payment/shkeeper/shkeeperPayoutService.ts:224–228`
|
||||
**Status:** Open
|
||||
|
||||
*(Also cited in Security Audit C-5.)*
|
||||
|
||||
```ts
|
||||
'Authorization': `Basic ${Buffer.from(`admin:!NMI4WdGkVQ#dQ`).toString('base64')}`
|
||||
```
|
||||
|
||||
**Remediation:** `process.env.SHKEEPER_ADMIN_PASSWORD` — rotate immediately.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### HIGH-1 — `updatePurchaseRequest` Has a Read-Modify-Write Race Condition
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:405–425`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const currentRequest = await PurchaseRequest.findById(requestId); // read
|
||||
// ... validate status ...
|
||||
await PurchaseRequest.findByIdAndUpdate(requestId, updateData, { new: true }); // write
|
||||
```
|
||||
|
||||
Two concurrent calls both read the same `currentRequest.status`, both pass `isValidStatusProgression`, and both write — potentially into conflicting terminal states or setting the same field twice.
|
||||
|
||||
Furthermore, `isValidStatusProgression` allows any status → any terminal state transition (e.g., `pending` → `completed` in one step), because the guard only blocks backward progression among non-terminal states.
|
||||
|
||||
**Remediation:** Use atomic optimistic locking:
|
||||
```ts
|
||||
const updated = await PurchaseRequest.findOneAndUpdate(
|
||||
{ _id: requestId, status: currentStatus }, // guard: status unchanged
|
||||
{ $set: { status: newStatus } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!updated) throw new ConflictError('State changed concurrently — retry');
|
||||
```
|
||||
Additionally, tighten `isValidStatusProgression` to use an explicit allowed-transitions table rather than a blanket terminal-from-anywhere rule.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-2 — Seller Offer Acceptance Has No Concurrency Guard
|
||||
**File:** `src/services/marketplace/SellerOfferService.ts:355–426`
|
||||
**Status:** Open
|
||||
|
||||
`acceptOffer` reads the offer, then updates it in a separate write. Two simultaneous accept calls for different offers on the same purchase request will both succeed: both set their target offer to `accepted`, and both run `updateMany` to reject all others — each query excluding its own `offerId`. The result is two `accepted` offers on the same purchase request.
|
||||
|
||||
**Remediation:** Use `findOneAndUpdate` with a status guard:
|
||||
```ts
|
||||
const updated = await SellerOffer.findOneAndUpdate(
|
||||
{ _id: offerId, status: 'pending' },
|
||||
{ $set: { status: 'accepted' } },
|
||||
{ new: true }
|
||||
);
|
||||
if (!updated) throw new ConflictError('Offer already processed');
|
||||
await SellerOffer.updateMany(
|
||||
{ purchaseRequestId, _id: { $ne: offerId } },
|
||||
{ $set: { status: 'rejected' } }
|
||||
);
|
||||
```
|
||||
Consider wrapping in a MongoDB transaction to keep the accept + reject-others atomic.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-3 — `refreshTokens[]` Array Grows Unboundedly
|
||||
**File:** `src/services/auth/authController.ts:62–63` and login handler
|
||||
**Status:** Open
|
||||
|
||||
Every `login` pushes a new refresh token onto `user.refreshTokens[]` without any cap or expiry pruning. The `User` model has no `maxlength` on this array. A user logging in from many devices, or an attacker rapidly requesting tokens, causes the array to grow without limit — increasing document size and slowing any query that reads the user document.
|
||||
|
||||
The `refreshToken` rotation endpoint prunes the old token on rotation, but `login` does not prune expired tokens before pushing.
|
||||
|
||||
**Remediation:** Before pushing, prune expired tokens:
|
||||
```ts
|
||||
const now = Date.now() / 1000;
|
||||
user.refreshTokens = user.refreshTokens.filter(t => {
|
||||
try { return jwt.decode(t)?.exp > now; } catch { return false; }
|
||||
});
|
||||
```
|
||||
Enforce a hard cap (e.g. `slice(-10)`) after pruning.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-4 — Blocked Telegram User Can Bypass Block via Deleted TelegramLink
|
||||
**File:** `src/services/auth/authController.ts:355–363`
|
||||
**Status:** Open
|
||||
|
||||
The block check queries `TelegramLink` where `status = 'blocked'` AND `blockedReason != 'unlinked_by_user'`. If the blocked user's `TelegramLink` is **deleted** entirely (not just status-changed), the check finds nothing, `activeLink` finds nothing, and the code auto-provisions a brand-new `User` with a fresh `TelegramLink` — bypassing the block entirely.
|
||||
|
||||
**Remediation:** Store the blocked Telegram user ID on the `User` document (a `blockedTelegramIds` set at the app level, or a `status: 'suspended'` flag tied to the Telegram ID) so the block persists even without a `TelegramLink` record.
|
||||
|
||||
---
|
||||
|
||||
### HIGH-5 — `verifyEmailWithCode`: Non-Atomic User Create + TempVerification Delete
|
||||
**File:** `src/services/auth/authController.ts:620–633`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
await user.save(); // line 630
|
||||
await TempVerification.findByIdAndDelete(tempVerification._id); // line 633
|
||||
```
|
||||
|
||||
If `user.save()` succeeds but the delete fails (network error, timeout), the `TempVerification` document remains. The next call with the same `email+code` will find it valid, attempt `User.create` again, and hit the unique email index — returning a 500 error instead of a clean "already verified" message. This persists until the MongoDB TTL scan runs (up to 15 min + scan interval).
|
||||
|
||||
**Remediation:** Delete the TempVerification **before** saving the user, or check for duplicate-key errors after `user.save()` and delete the TempVerification in the catch handler.
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### MED-1 — Funds Ledger Availability Check Is Not Atomic with Append
|
||||
**File:** `src/services/payment/orchestration/releaseRefundService.ts:37–55,99–114`
|
||||
**Status:** Open
|
||||
|
||||
`validateReleaseAvailability` reads a balance via aggregation, checks `availableBalance >= amount`, then `appendFundsLedgerEntry` inserts the entry. Two concurrent release/refund calls can both read the same balance snapshot, both pass the availability check, and both insert entries — resulting in a double-spend from the ledger.
|
||||
|
||||
The `idempotencyKey` on `FundsLedgerEntry` prevents exact duplicates when the same `txHash` is used, but does not prevent two different `txHash` values from both passing the availability check concurrently.
|
||||
|
||||
**Remediation:** Wrap the check + insert in a MongoDB transaction, or add a single-release constraint: before inserting a `release` entry, verify no prior `release` entry exists for the same `paymentId`.
|
||||
|
||||
---
|
||||
|
||||
### MED-2 — `updatePurchaseRequestStatus` Bypasses the State Machine Validator
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:551–589`
|
||||
**Status:** Open
|
||||
|
||||
`updatePurchaseRequestStatus` calls `PurchaseRequest.findByIdAndUpdate` directly, without calling `isValidStatusProgression`. The webhook path (`paymentCoordinator.ts:208`) uses this method, meaning payment-triggered state transitions skip the state machine guard entirely.
|
||||
|
||||
**Remediation:** Add `isValidStatusProgression(currentStatus, newStatus)` check inside `updatePurchaseRequestStatus` before writing.
|
||||
|
||||
---
|
||||
|
||||
### MED-3 — `getUserPayments` Queries Non-Existent Field `userId`
|
||||
**File:** `src/services/payment/paymentService.ts:342–346`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
query.userId = userId;
|
||||
```
|
||||
|
||||
The `Payment` schema has no top-level `userId` field. The buyer is stored as `buyerId` and the seller as `sellerId`. This query will always return zero results for current payments; only legacy payments with `metadata.legacyUserId` are returned (none for new users). The endpoint appears non-functional for all users.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
query.$or = [{ buyerId: userId }, { sellerId: userId }];
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-4 — Payout Created Without Verifying an Inbound Completed Payment Exists
|
||||
**File:** `src/services/payment/shkeeper/shkeeperPayoutService.ts:42–80`
|
||||
**Status:** Open
|
||||
|
||||
`createPayoutTask` takes `buyerId`, `sellerId`, `purchaseRequestId`, and `amount` from the request body. The service checks for duplicate payouts (`existingPayout` idempotency) but does not verify that a corresponding inbound `Payment` with `status: 'completed'` and `direction: 'in'` exists. An admin could accidentally initiate a payout for a purchase request that has never been paid.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
const inboundPayment = await Payment.findOne({
|
||||
purchaseRequestId, direction: 'in', status: 'completed'
|
||||
});
|
||||
if (!inboundPayment) throw new AppError(400, 'No completed inbound payment for this request');
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### MED-5 — Unauthenticated `/payment/callback` Endpoint Can Mutate Payment Status
|
||||
**File:** `src/services/payment/paymentControllerRoutes.ts:20`
|
||||
**Status:** Open
|
||||
|
||||
`POST /callback` is mounted with no authentication middleware. The handler accepts `{ transactionId, status, amount }` and updates a payment's status to any value without verification. Unlike the SHKeeper webhook (which has optional HMAC), this endpoint has zero authentication.
|
||||
|
||||
**Remediation:** Either remove the endpoint (the SHKeeper webhook path supersedes it), restrict to internal network only (Nginx `allow` rules), or add HMAC verification.
|
||||
|
||||
---
|
||||
|
||||
### MED-6 — In-Memory Payment Coordinator State Not Shared Across Replicas
|
||||
**File:** `src/services/payment/paymentCoordinator.ts:24–25`
|
||||
**Status:** Open
|
||||
|
||||
The `coordinationState` Map is process-local. In a multi-process deployment (PM2 cluster, k8s with >1 replica), the 10-second debounce window provides no protection — each process has its own empty map at startup, and webhooks routed to different replicas will both process. The existing DB `status === 'completed'` guard is the only durable protection.
|
||||
|
||||
*(The code comment at line 24 acknowledges this. Fix is the same as CRIT-1 — atomic DB conditional update makes this moot.)*
|
||||
|
||||
---
|
||||
|
||||
### MED-7 — Seller Offer Notification Uses Undefined `offer.title` Field
|
||||
**File:** `src/services/marketplace/SellerOfferService.ts:403,415`
|
||||
**Status:** Open
|
||||
|
||||
`SellerOffer` does not have a `title` field directly — the title belongs to the associated `PurchaseRequest`. `offer.title` resolves to `undefined` in the notification message body, resulting in broken notification text for buyers and sellers.
|
||||
|
||||
**Remediation:** Either populate the `purchaseRequestId` and use `purchaseRequest.title`, or populate the field name from the offer's own descriptive field.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### LOW-1 — Duplicate Callback Route Definitions
|
||||
**File:** `src/services/payment/paymentRoutes.ts:29,256`
|
||||
**Status:** Open
|
||||
|
||||
`POST /callback` is defined at line 29 (mapped to `handlePaymentCallback`) and again as `POST /payments/callback` at line 256. These resolve to different URL paths given the router mount, but suggest dead code. No security impact; creates maintenance confusion.
|
||||
|
||||
**Remediation:** Remove the dead route definition.
|
||||
|
||||
---
|
||||
|
||||
### LOW-2 — `paymentCoordinator.ts` Map Grows Without Bound Until Cleanup Fires
|
||||
**File:** `src/services/payment/paymentCoordinator.ts:35`
|
||||
**Status:** Open
|
||||
|
||||
The cleanup function fires after each coordination cycle but only on active payments. Under a webhook flood before cleanup, the Map accumulates entries. Not a crash risk at current scale; becomes a concern at high webhook volume.
|
||||
|
||||
**Remediation:** The atomic DB fix in CRIT-1 eliminates the need for the Map entirely. Until then, add a size cap and a periodic cleanup interval independent of payment processing.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed PASS
|
||||
|
||||
| Check | Result | Source |
|
||||
|-------|--------|--------|
|
||||
| TempVerification TTL index | PASS | `TempVerification.ts:59` — `expireAfterSeconds: 0` on expiry field |
|
||||
| Legacy email token cleared on first use | PASS | `authController.ts:667` — token set to undefined before save |
|
||||
| SHKeeper HMAC timing-safe comparison | PASS | `shkeeperWebhook.ts:84` — `crypto.timingSafeEqual` |
|
||||
| Socket event DB as source of truth | PASS | DB write committed before emit; emit failure is non-fatal |
|
||||
| `FundsLedgerEntry` idempotency key (unique) | PASS | `FundsLedgerEntry.ts:86` — sparse unique index |
|
||||
| `TelegramSession` TTL index | PASS | `TelegramSession.ts:66` |
|
||||
| Password change clears refresh tokens | PASS | `authController.ts:887` |
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| CRITICAL | 4 |
|
||||
| HIGH | 5 |
|
||||
| MEDIUM | 7 |
|
||||
| LOW | 2 |
|
||||
|
||||
**Top priority:** CRIT-1 (concurrent webhook double-processing) and CRIT-2 (Telegram race condition) require atomic DB operations. CRIT-3 (simulation bypass) and CRIT-4 (hardcoded credential) are one-line fixes.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Security Audit - 2026-05-24]]
|
||||
- [[Performance Audit - 2026-05-24]]
|
||||
- [[Funds Ledger and Escrow State Machine Specification]]
|
||||
- [[Payment Provider Adapter Spec]]
|
||||
- [[Authentication Flow]]
|
||||
- [[Platform Logical Audit - 2026-05-24]]
|
||||
338
09 - Audits/Performance Audit - 2026-05-24.md
Normal file
338
09 - Audits/Performance Audit - 2026-05-24.md
Normal file
@@ -0,0 +1,338 @@
|
||||
---
|
||||
title: Performance Audit — 2026-05-24
|
||||
tags: [audit, performance, database, findings]
|
||||
created: 2026-05-24
|
||||
status: open
|
||||
---
|
||||
|
||||
# Performance Audit — 2026-05-24
|
||||
|
||||
Performance review of MongoDB queries, indexes, in-memory data structures, Socket.IO emission patterns, and pagination. Triggered by completion of Request Network integration, Telegram auth, and the internal funds ledger. Every finding is derived from actual source code.
|
||||
|
||||
> [!warning] Scale risk
|
||||
> H3 (unbounded seller fan-out) and H4 (full chat document in memory) are time-bombs that will cause outages once user counts and message volumes grow past modest scale. Both have zero performance headroom at their current implementation.
|
||||
|
||||
---
|
||||
|
||||
## HIGH
|
||||
|
||||
### H1 — N+1 Query: `getPurchaseRequestsByBuyer` Issues One Payment Lookup Per Request Row
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:516–534`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
await Promise.all(requests.map(async (request) =>
|
||||
Payment.findOne({ purchaseRequestId: request._id })
|
||||
))
|
||||
```
|
||||
|
||||
A page of 10 requests produces 11 DB round-trips (1 list + 10 payment lookups). With a 50-item page, that is 51 round-trips on every buyer dashboard load.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
const requestIds = requests.map(r => r._id);
|
||||
const payments = await Payment.find({ purchaseRequestId: { $in: requestIds } }).lean();
|
||||
const paymentMap = new Map(payments.map(p => [p.purchaseRequestId.toString(), p]));
|
||||
// then: requestWithPayment = { ...r, payment: paymentMap.get(r._id.toString()) }
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H2 — Missing Index on `Payment.purchaseRequestId`
|
||||
**File:** `src/models/Payment.ts:190–196`
|
||||
**Status:** Open
|
||||
|
||||
The Payment schema indexes `buyerId`, `sellerId`, `status`, `transactionHash`, and `providerPaymentId` — but not `purchaseRequestId`. Every call to `Payment.findOne({ purchaseRequestId })` (used in `getPurchaseRequestsByBuyer`, `getPurchaseRequestById`, payment coordinator, notification handlers) causes a full collection scan.
|
||||
|
||||
**Remediation:** Add to `Payment.ts`:
|
||||
```ts
|
||||
paymentSchema.index({ purchaseRequestId: 1, status: 1 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### H3 — Unbounded Seller Fan-Out on New Public Purchase Request
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:190–191`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
sellers = await User.find({ role: "seller", status: "active" }).select(...)
|
||||
```
|
||||
|
||||
When a public purchase request is created, every active seller is fetched with no limit. For 10 000 sellers:
|
||||
- 10 000 `Notification` documents are inserted (not bulk-inserted — individual creates in a loop).
|
||||
- Each insert calls `emitRealTimeNotification` → `getUnreadCount` (an additional `countDocuments` query per seller = 10 000 extra queries).
|
||||
- A 50 ms staggered `setTimeout` per seller means the event loop is managing 500 seconds of scheduled callbacks.
|
||||
|
||||
**Remediation:**
|
||||
1. Cap the fan-out with a configurable batch limit (e.g., 500 most-relevant sellers by category).
|
||||
2. Use `Notification.insertMany(docs)` for bulk creation.
|
||||
3. Push a single room-broadcast (`io.to('sellers').emit(...)`) rather than per-user socket emits.
|
||||
4. Move heavy fan-out to a background job queue (Bull/BullMQ).
|
||||
|
||||
---
|
||||
|
||||
### H4 — Full Chat Document with All Embedded Messages Loaded for Pagination
|
||||
**File:** `src/services/chat/ChatService.ts:370–408`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const chat = await Chat.findById(chatId).populate("messages.senderId", ...)
|
||||
const messages = chat.messages.sort(...).slice(skip, skip + limit)
|
||||
```
|
||||
|
||||
A chat with 50 000 messages (~5 KB each = 250 MB) is fully loaded into Node.js memory on every paginated message request. Pagination happens in JavaScript after the full document is fetched.
|
||||
|
||||
**Remediation (minimum):** Use MongoDB's `$slice` projection:
|
||||
```ts
|
||||
const chat = await Chat.findById(chatId, {
|
||||
messages: { $slice: [skip, limit] },
|
||||
participants: 1,
|
||||
unreadCounts: 1
|
||||
}).lean();
|
||||
```
|
||||
**Recommended long-term:** Extract messages into a separate `Message` collection with server-side pagination (`Message.find({ chatId }).sort({ timestamp: -1 }).skip(skip).limit(limit).lean()`).
|
||||
|
||||
---
|
||||
|
||||
### H5 — Extra `countDocuments` Query on Every Single Notification Creation
|
||||
**File:** `src/services/notification/NotificationService.ts:131–152`
|
||||
**Status:** Open
|
||||
|
||||
`emitRealTimeNotification` calls `emitUnreadCountUpdate`, which calls `getUnreadCount` (a full `countDocuments` query) synchronously within the notification creation hot path. When H3's 10 000-seller fan-out fires, this produces 10 000 additional `countDocuments` queries in rapid succession.
|
||||
|
||||
**Remediation:** Maintain an unread count field directly on the `User` or in a Redis hash. Increment on notification insert rather than re-counting. Or skip the re-query and have the client increment its local counter on socket event receipt.
|
||||
|
||||
---
|
||||
|
||||
### H6 — `getUserPayments` Queries Non-Indexed Field and Has No Default Limit
|
||||
**File:** `src/services/payment/paymentService.ts:331–370`
|
||||
**Status:** Open
|
||||
|
||||
The query builds `query.userId = userId` (line 343), but the Payment schema stores buyers as `buyerId` (ObjectId) — not `userId`. The query hits `metadata.legacyUserId`, which has no index. Additionally, if `options.limit` is not supplied the query returns all matching documents with no limit.
|
||||
|
||||
**Remediation:**
|
||||
1. Fix field: `query.$or = [{ buyerId: userId }, { sellerId: userId }]`
|
||||
2. Add default limit: `.limit(options.limit ?? 100)`
|
||||
|
||||
---
|
||||
|
||||
## MEDIUM
|
||||
|
||||
### M1 — Missing Compound Index `(buyerId, createdAt)` on PurchaseRequest
|
||||
**File:** `src/models/PurchaseRequest.ts:360–369`
|
||||
**Status:** Open
|
||||
|
||||
`getPurchaseRequestsByBuyer` queries `{ buyerId }` with `.sort({ createdAt: -1 })`. The existing single-field index on `buyerId` satisfies the filter but forces MongoDB to sort results post-scan. A compound index eliminates the sort entirely.
|
||||
|
||||
**Remediation:** Add to `PurchaseRequest.ts`:
|
||||
```ts
|
||||
PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 });
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M2 — Unanchored Case-Insensitive Regex Search — Full Collection Scan
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:703–720`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
{ title: { $regex: searchTerm, $options: "i" } }
|
||||
```
|
||||
|
||||
An unanchored, case-insensitive regex cannot use a B-tree index. MongoDB performs a full collection scan with in-memory string matching on every search request.
|
||||
|
||||
**Remediation:** Create a text index:
|
||||
```ts
|
||||
PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' });
|
||||
```
|
||||
Query with `{ $text: { $search: searchTerm } }` and sort by `{ score: { $meta: 'textScore' } }`.
|
||||
|
||||
---
|
||||
|
||||
### M3 — Double-Fetch Pattern in Update Methods (No `.lean()` on Pre-Check)
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:406–425,557–572`
|
||||
**Status:** Open
|
||||
|
||||
Both `updatePurchaseRequest` and `updatePurchaseRequestStatus` call `PurchaseRequest.findById(requestId)` to validate current status, then call `findByIdAndUpdate`. The pre-check `findById` instantiates a full Mongoose document that is immediately discarded.
|
||||
|
||||
**Remediation:** Use `findByIdAndUpdate` with `{ new: false }` to get the pre-update document in a single round-trip, or use `.lean().select('status')` on the pre-check to minimize overhead.
|
||||
|
||||
---
|
||||
|
||||
### M4 — Regex on Embedded `messages.content` Array — Full Array Deserialization
|
||||
**File:** `src/services/chat/ChatService.ts:318–320`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
{ "messages.content": { $regex: filters.search, $options: "i" } }
|
||||
```
|
||||
|
||||
MongoDB must deserialize the entire embedded `messages` array for every chat document to evaluate this filter. There is no index support for nested array field regex searches.
|
||||
|
||||
**Remediation:** Move to a dedicated `Message` collection with a text index on `content`, then query `Message.find({ chatId: { $in: userChatIds }, $text: { $search: searchTerm } })`.
|
||||
|
||||
---
|
||||
|
||||
### M5 — In-Memory Coordination State Not Shared Across Replicas
|
||||
**File:** `src/services/payment/paymentCoordinator.ts:25`
|
||||
**Status:** Open
|
||||
|
||||
The `coordinationState` Map is process-local. In a multi-replica deployment, each process starts with an empty map; the 10-second debounce provides no cross-process deduplication. The DB guard in CRIT-1 (Logic Audit) is the correct production fix; this Map is supplementary at best.
|
||||
|
||||
*(Code comment at line 24 already acknowledges this — a Redis migration is planned.)*
|
||||
|
||||
---
|
||||
|
||||
### M6 — `getSellers()` Is Unbounded (No Limit)
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:728–741`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
await User.find({ role: "seller", isEmailVerified: true }).select(...).lean()
|
||||
```
|
||||
|
||||
No `.limit()`. Returns every seller in the collection. This method is exposed via API.
|
||||
|
||||
**Remediation:** Add `.limit(500)` at minimum, or paginate with a cursor.
|
||||
|
||||
---
|
||||
|
||||
### M7 — `user-online` Event Broadcast to All Connected Sockets Globally
|
||||
**File:** `src/app.ts:300`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
socket.broadcast.emit('user-status-change', { userId, status: 'online', ... })
|
||||
```
|
||||
|
||||
This pushes the online-presence event to every connected socket regardless of whether they have a relationship with the user. With 10 000 concurrent connections, this is 10 000 individual socket writes per login event.
|
||||
|
||||
**Remediation:** Restrict to relevant rooms. For a marketplace, "relevant" means chat participants and active purchase request counterparties:
|
||||
```ts
|
||||
userChatIds.forEach(chatId => {
|
||||
socket.to(`chat-${chatId}`).emit('user-status-change', { userId, status: 'online' });
|
||||
});
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### M8 — Post-Filter After Pagination Causes Wrong `totalItems` Count
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:258–337`
|
||||
**Status:** Open
|
||||
|
||||
The code paginates the base query, then applies visibility filtering (offer ownership, anonymization) after pagination. This means `totalItems` in the response is the pre-filter count, not the count of items the caller can actually see. Users may see fewer items than indicated by the pagination metadata (e.g., "showing 8 of 10" when only 6 are visible).
|
||||
|
||||
**Remediation:** Move visibility filtering into the MongoDB query (aggregation pipeline with `$match` conditions) so `countDocuments` reflects the actual visible set.
|
||||
|
||||
---
|
||||
|
||||
## LOW
|
||||
|
||||
### L1 — `getUserChats` Runs Count Query Sequentially After Find
|
||||
**File:** `src/services/chat/ChatService.ts:324–335`
|
||||
**Status:** Open
|
||||
|
||||
`Chat.find(query)` and `Chat.countDocuments(query)` are independent queries run sequentially. They should run in parallel.
|
||||
|
||||
**Remediation:**
|
||||
```ts
|
||||
const [chats, total] = await Promise.all([
|
||||
Chat.find(query).lean(),
|
||||
Chat.countDocuments(query)
|
||||
]);
|
||||
```
|
||||
*(The notification service at `NotificationService.ts:53–61` already does this correctly — apply the same pattern.)*
|
||||
|
||||
---
|
||||
|
||||
### L2 — `findByIdAndUpdate` Results Not Using `.lean()`
|
||||
**File:** `src/services/marketplace/PurchaseRequestService.ts:418–425,566–572`
|
||||
**Status:** Open
|
||||
|
||||
`findByIdAndUpdate` with `.populate(...)` returns full Mongoose document objects for data that is only read and returned to the client. `.lean()` saves ~15–30% memory and processing per returned document.
|
||||
|
||||
**Remediation:** Add `.lean()` to read-only `findByIdAndUpdate` calls.
|
||||
|
||||
---
|
||||
|
||||
### L3 — Telegram Replay Maps Are Process-Local (Multi-Replica Gap, Same as M5)
|
||||
**File:** `src/services/telegram/telegramService.ts:395–396`
|
||||
**Status:** Open
|
||||
|
||||
```ts
|
||||
const initDataReplayWindow = new Map<string, number>();
|
||||
const webhookReplayWindow = new Map<string, number>();
|
||||
```
|
||||
|
||||
`cleanupReplayMap` is called lazily before each check — entries are evicted correctly once the window expires, so the Maps do not grow unboundedly under normal load. However, like `coordinationState`, they are process-local and will not catch replays across multiple Node replicas.
|
||||
|
||||
**Remediation:** Redis `SET NX EX` (same as Security Audit H-1 recommendation).
|
||||
|
||||
---
|
||||
|
||||
### L4 — PasskeyService Challenge Map: 5-Minute Cleanup Is Correct, Process-Local Only
|
||||
**File:** `src/services/auth/passkeyService.ts:56–63`
|
||||
**Status:** Open
|
||||
|
||||
Cleanup is implemented and the code comment (lines 50–55) already notes the Redis migration path. Not a performance risk at current scale. Same multi-replica caveat as L3.
|
||||
|
||||
---
|
||||
|
||||
## Confirmed PASS
|
||||
|
||||
| Check | Result | Source |
|
||||
|-------|--------|--------|
|
||||
| `TempVerification` TTL index | PASS | `TempVerification.ts:59` — MongoDB-native TTL, no app polling |
|
||||
| `TelegramSession` TTL index | PASS | `TelegramSession.ts:66` |
|
||||
| `Notification` 90-day TTL index | PASS | `Notification.ts:77` — `expireAfterSeconds: 7776000` |
|
||||
| `FundsLedgerEntry` indexes (purchaseRequestId, paymentId, idempotencyKey) | PASS | `FundsLedgerEntry.ts:86–107` |
|
||||
| SHKeeper polling rate bounded | PASS | `shkeeperSimpleAuto.ts:64` — 2-minute interval, self-cleaning |
|
||||
| Socket.IO room cleanup on disconnect | PASS | Socket.IO removes socket from all rooms automatically on disconnect |
|
||||
| `getUserNotifications` count + find in parallel | PASS | `NotificationService.ts:53–61` — already uses `Promise.all` |
|
||||
| Telegram replay map bounded (lazy eviction) | PASS | `telegramService.ts:100` — `cleanupReplayMap` evicts expired entries |
|
||||
|
||||
---
|
||||
|
||||
## Index Additions Summary
|
||||
|
||||
The following indexes should be added to eliminate collection scans identified in this audit:
|
||||
|
||||
```ts
|
||||
// Payment.ts
|
||||
paymentSchema.index({ purchaseRequestId: 1, status: 1 });
|
||||
|
||||
// PurchaseRequest.ts
|
||||
PurchaseRequestSchema.index({ buyerId: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ status: 1, createdAt: -1 });
|
||||
PurchaseRequestSchema.index({ categoryId: 1, status: 1, createdAt: -1 });
|
||||
|
||||
// PurchaseRequest.ts (text search)
|
||||
PurchaseRequestSchema.index({ title: 'text', description: 'text', tags: 'text' });
|
||||
```
|
||||
|
||||
Caution: adding indexes increases write latency and storage. Run `db.collection.getIndexes()` before adding to avoid duplicating existing indexes (some `unique: true` fields already create indexes automatically).
|
||||
|
||||
---
|
||||
|
||||
## Summary
|
||||
|
||||
| Severity | Count |
|
||||
|----------|-------|
|
||||
| HIGH | 6 |
|
||||
| MEDIUM | 8 |
|
||||
| LOW | 4 |
|
||||
|
||||
**Highest urgency:** H3 (seller fan-out) and H4 (full chat in memory) will cause visible degradation with modest growth. H1 (N+1 payments) and H2 (missing index) are easy wins with high query-count impact.
|
||||
|
||||
---
|
||||
|
||||
## Related
|
||||
|
||||
- [[Security Audit - 2026-05-24]]
|
||||
- [[Logic Audit - 2026-05-24]]
|
||||
- [[Backend Architecture]]
|
||||
- [[Data Model Overview]]
|
||||
- [[Funds Ledger and Escrow State Machine Specification]]
|
||||
405
09 - Audits/Security Audit - 2026-05-24.md
Normal file
405
09 - Audits/Security Audit - 2026-05-24.md
Normal file
@@ -0,0 +1,405 @@
|
||||
---
|
||||
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]]
|
||||
Reference in New Issue
Block a user