fix: secure POST /transactions/submit against third-party XDR (#117) - #125
Conversation
…-app#117) Bind submissions to the authenticated wallet (source account, fee-bump inner source, or Soroban invocation auth), enforce a per-type operation allowlist, make submission idempotent per transaction hash via a unique-index migration, rate limit per wallet and IP, and persist records before Horizon submission so persistence failures surface instead of being silently dropped. Generated with Codebuff 🤖 Co-Authored-By: Codebuff <noreply@codebuff.com>
EmeditWeb
left a comment
There was a problem hiding this comment.
✅ Automated Audit: solves
The PR addresses every root cause enumerated in issue #117: source-account binding (source or fee-bump inner source, with a cryptographically sound relaxation allowing the wallet as a Soroban address-credential authorizer, whose signature Horizon enforces), a per-type operation allowlist keyed to declared contract IDs, hash idempotency backed by partial unique indexes plus a pre-insert check and unique-violation race backstop, per-wallet and per-IP throttling, and persist-before-submit so failures surface as TRANSACTION_PERSISTENCE_FAILED instead of fire-and-forget. Comprehensive unit tests were added covering each rejection branch and the happy path, satisfying the testing standard. Confidence is capped below 0.9 because key validation logic (assertWalletAuthorizes, extractInvocationAttributes) falls in the truncated portion of the diff and cannot be fully verified.
Gaps identified:
- Contract-ID enforcement is conditional: if a contract-id env var is unset, the allowlist degrades to function-name-only matching, permitting invocations of attacker-deployed contracts whose functions share StepFi names
- If invocation.contractId cannot be extracted but the function name matches, the contract check is silently skipped rather than failing closed
- Persisted rows are not rolled back or marked failed when the subsequent Horizon submission fails, leaving stale 'pending' rows attributable to the submitting wallet
- Full assertWalletAuthorizes implementation not visible in the provided diff (only test coverage attests to its behavior)
Audited by stepfi-audit-bot 🤖
|
@Marvelg256 fix Merge Conflicts and gaps identified in your codes |
EmeditWeb
left a comment
There was a problem hiding this comment.
❌ Automated Audit: does_not_solve
@Marvelg256 Please look into the issue again and address the gaps below.
The code changes substantively address all five root causes described in issue #117: source-account binding via assertWalletAuthorizes, operation allowlist per declared type, idempotency via hash uniqueness + pre-check, per-wallet rate limiting via WalletThrottlerGuard, and persistence-first flow. Tests cover every rejection branch. However, the independent verification reports merge conflicts with the base branch, and per the rules this means the PR cannot be approved. Additionally, the PR title is flagged as vague (shares no keywords with the linked issue), which is an author-fixable gap.
Gaps identified:
- Merge conflicts with base branch must be resolved before merge
- PR title should reference specific fix keywords from the issue (e.g. 'source-account binding', 'third-party XDR')
CI checks: ✅ PASSED: build-test
Merge conflicts:
Audited by stepfi-audit-bot 🤖
…izon rejection (StepFi-app#117) Closes StepFi-app#117 Addresses the audit gaps on the secured submit endpoint: the per-type contract check no longer degrades to function-name-only matching when the contract ID is unset or unextractable, and persisted records are marked failed when Horizon rejects the transaction instead of lingering as stale pending rows. Also resolves the committed merge-conflict markers in the progress tracker. 🤖 Generated with Codebuff Co-Authored-By: Codebuff <noreply@codebuff.com>
EmeditWeb
left a comment
There was a problem hiding this comment.
✅ Automated Audit: solves
@Marvelg256 Excellent work, thank you! 🎉
The diff genuinely implements all four root causes from #117: assertWalletAuthorizes enforces source-account/authorizer binding (blocking third-party XDR), assertOperationAllowlist enforces a per-type Soroban function+contract allowlist, idempotency is enforced via a pre-insert lookup plus a partial unique-index migration with a unique-violation race backstop, and rate limits are added via WalletThrottlerGuard + @Throttle. Persistence is now awaited before Horizon submission with markTransactionFailed on rejection, eliminating the fire-and-forget drop. The independent sandbox run is authoritative and PASSED (35 suites, 407 tests), including the new rejection-branch tests. One discrepancy to flag: the PR claims '30 suites / 358 tests' while the independent run shows 35/407 — that claimed count is unverified/stale but the underlying fix is confirmed by the actual run. The authorizer-based binding (wallet need not be the strict source) is a deliberate, documented design tradeoff for StepFi's random-source XDR builders and does not enable impersonation since the transaction envelope still requires the true source's signature.
CI checks: none configured
Merge conflicts: ✅ none — but the PR is blocked (failing/missing required checks or reviews).
Independent test run: PASSED
e] Horizon submission error: something unexpected
PASS test/unit/modules/loans/loans.controller.spec.ts (22.469 s)
[Nest] 21128 - 03/23/2026, 6:16:00 AM ERROR [TransactionsService] Horizon submission error: something unexpected
[Nest] 21128 - 03/23/2026, 6:16:00 AM ERROR [TransactionsService] Horizon submission error: something unexpected
[Nest] 21128 - 03/23/2026, 6:16:00 AM ERROR [TransactionsService] Unexpected Horizon lookup error for aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa: server error
PASS test/unit/modules/transactions/transactions.service.spec.ts (123.895 s)
[Nest] 14720 - 08/27/2026, 5:50:12 AM ERROR [UserStatusService] Failed to read user state for GABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVW: DB down
PASS test/unit/modules/auth/user-status.service.spec.ts
PASS test/unit/modules/auth/jwt-auth.guard.spec.ts
PASS test/unit/modules/vendors/vendors.service.spec.ts (25.985 s)
PASS test/unit/modules/vouching/vouching.service.spec.ts
PASS test/unit/stellar/stellar.service.spec.ts
PASS test/unit/modules/health/health.controller.spec.ts
PASS test/unit/jobs/session-cleanup/session-cleanup.service.spec.ts
PASS test/unit/modules/auth/auth.controller.spec.ts
PASS test/unit/modules/transactions/wallet-throttler.guard.spec.ts
PASS test/unit/modules/learners/learner-profile.dto.spec.ts
PASS test/unit/modules/admin/admin-roles.controller.spec.ts
PASS test/unit/modules/learners/learners.controller.spec.ts
PASS test/unit/modules/liquidity/liquidity.controller.spec.ts
PASS test/unit/stellar/contracts/clients/creditline.client.spec.ts
A worker process has failed to exit gracefully and has been force exited. This is likely caused by tests leaking due to improper teardown. Try running with --detectOpenHandles to find leaks. Active timers can also cause this, ensure that .unref() was called on them.
Test Suites: 35 passed, 35 total
Tests: 407 passed, 407 total
Snapshots: 0 total
Time: 134.26 s
Ran all test suites.
Audited by stepfi-audit-bot 🤖
🔗 Related Issue
Closes #117
🔖 Title
Secure
POST /transactions/submit: wallet binding, operation allowlist, idempotency, and rate limits📝 Description
TransactionsService.submitTransaction()previously accepted any well-formed XDR, immediately submitted it to Horizon, and recorded it against the authenticated wallet — without ever verifying that the wallet was actually a party to the transaction. This PR closes that open-relay hole:TRANSACTION_SOURCE_MISMATCHerror before any Horizon call or DB write.invokeHostFunctionwhose function name matches the declaredtype(loan_create,loan_repay,deposit,withdraw,vendor_approve,vendor_suspend) and, when configured, must target the contract owned by that flow. Mismatches are rejected withTRANSACTION_TYPE_MISMATCH/TRANSACTION_OPERATION_NOT_ALLOWED.transaction_hashandhash(with pre-existing-row dedupe); the service checks for an existing record before submitting and returns the original record (duplicate: true) instead of re-submitting, with the unique-constraint violation as the concurrency backstop. Duplicate hashes can no longer create duplicate rows or double Horizon submissions.WalletThrottlerGuardkeys@nestjs/throttleron the authenticated wallet (the repo's established middleware), and the route is limited to 10 req / 60 s per wallet AND per IP via the global guard.TRANSACTION_PERSISTENCE_FAILEDinstead of the old fire-and-forget behavior, and the transaction hash is always known to the status checker / indexer reconciliation paths.🔄 Changes Made
src/modules/transactions/transactions.service.ts— allowlist + source/auth validation, hash idempotency, persist-before-submit flow insubmitTransactionsrc/modules/transactions/wallet-throttler.guard.ts— new per-wallet throttler guardsrc/modules/transactions/transactions.controller.ts— per-wallet + per-IP rate limits, Swagger responses updated (400 codes, 429, 500)src/modules/transactions/dto/submit-transaction-request.dto.ts— documents the enforced guaranteessrc/modules/transactions/dto/submit-transaction-response.dto.ts—statusreflects the recorded status; newduplicateflagsupabase/migrations/20260825000001_add_unique_transaction_hash.sql— unique indexes + dedupetest/unit/modules/transactions/transactions.service.spec.ts— happy path + every rejection branch (source mismatch, fee-bump inner source, allowlist/function/contract mismatch, classic ops, duplicate pre-check, unique-violation race, persistence failure, Horizon error mapping)test/unit/modules/transactions/wallet-throttler.guard.spec.ts— new guard tracker teststest/unit/modules/transactions/transactions.controller.spec.ts— compiles withThrottlerModulecontext/progress-tracker.md— documented🗒️ Additional Notes
loan_createuses the wallet as source and passes the strict path.transactions.repository.tswas reviewed but not modified:backfill'supsert(onConflict: 'transaction_hash')remains compatible with the new partial unique index, andfindForReconciliationis unaffected.supabase/migrations/(outsidesrc//test/) as required by the issue andcontext/code-standards.md.✅ Verification
npm run build— zero TypeScript errorsnpm test— 30 suites / 358 tests passing (test count increased from baseline, per the mandatory checklist)