--- 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]]