From 70631a78fda5eec82411270ab9d5f2b31b458433 Mon Sep 17 00:00:00 2001 From: flavor365 Date: Sat, 25 Jul 2026 11:38:40 +0100 Subject: [PATCH] fix(backend): address transaction fraud, OOM sort, TOML passphrase, and Sentry source maps MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Closes #794 — POST /support-transactions: recipientAddress ↔ walletAddress assertion Before this fix, the endpoint verified the Horizon transaction was real but never checked that the supplied recipientAddress matched the profile's stored walletAddress. An attacker could pay their own wallet, then POST with a victim's profileId; Horizon verification would pass and the victim's profile would accumulate fraudulent records, milestone progress, badge triggers, and webhook deliveries without ever receiving funds. Fix: after the existing profile-existence check, the query now also selects walletAddress and returns 400 ADDRESS_MISMATCH if it does not equal parsed.data.recipientAddress. Closes #790 — GET /profiles?sort=most_supported|most_transactions: cap in-memory load The sort-by-metrics branch fetched every profile with every SUCCESS transaction into Node.js memory before sorting and discarding all but the requested page — a DoS and OOM vector at scale (1 M+ rows with 10 k profiles × 100 txs each). Fix: add take: 1000 to the Prisma query to bound the worst-case memory footprint. Comment notes the long-term solution is a precomputed totalSupported/transactionCount column updated transactionally. Closes #788 — stellar.toml NETWORK_PASSPHRASE always emitted testnet value on mainnet The ternary compared process.env.STELLAR_NETWORK === 'MAINNET', but the actual mainnet value of that variable is 'PUBLIC'. The condition was always false in production, so /.well-known/stellar.toml always advertised the testnet passphrase. Stellar wallets and federation resolvers reading this TOML would fail to build valid mainnet transactions. Fix: change the comparison to === 'PUBLIC'. Closes #787 — CI: upload Sentry source maps after build for actionable production traces TypeScript is compiled to dist/*.js; without source maps uploaded to Sentry, production error traces point to minified/compiled line numbers rather than original src/*.ts lines. Fix: enable sourceMap: true in backend/tsconfig.json so the build emits .js.map files, then add a 'Upload source maps to Sentry' step to .github/workflows/backend.yml that runs only on pushes to main using npx @sentry/cli. Requires SENTRY_AUTH_TOKEN to be added to GitHub Actions secrets. --- .github/workflows/backend.yml | 9 +++++++++ backend/src/app.ts | 20 +++++++++++++++----- backend/tsconfig.json | 3 ++- 3 files changed, 26 insertions(+), 6 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index 7e5b8ec8..c253545d 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -101,3 +101,12 @@ jobs: - name: Build backend run: npm run build + + - name: Upload source maps to Sentry + if: github.ref == 'refs/heads/main' + run: npx @sentry/cli releases files "$SENTRY_RELEASE" upload-sourcemaps ./dist + env: + SENTRY_AUTH_TOKEN: ${{ secrets.SENTRY_AUTH_TOKEN }} + SENTRY_ORG: novasupport + SENTRY_PROJECT: novasupport-backend + SENTRY_RELEASE: ${{ github.sha }} diff --git a/backend/src/app.ts b/backend/src/app.ts index 287e5d69..3bcf53e0 100644 --- a/backend/src/app.ts +++ b/backend/src/app.ts @@ -536,7 +536,7 @@ All errors return JSON with an \`error\` field and optional \`code\`: .join("\n\n"); const body = [ - `NETWORK_PASSPHRASE="${process.env.STELLAR_NETWORK === 'MAINNET' + `NETWORK_PASSPHRASE="${process.env.STELLAR_NETWORK === 'PUBLIC' ? 'Public Global Stellar Network ; September 2015' : 'Test SDF Network ; September 2015'}"`, `FEDERATION_SERVER="https://api.novasupport.xyz/federation"`, @@ -1042,10 +1042,13 @@ All errors return JSON with an \`error\` field and optional \`code\`: let orderBy: object = { createdAt: "desc" }; if (sort === "most_supported" || sort === "most_transactions") { - // For sorting by support metrics, we'll fetch all and sort in memory - // This is a simplified approach; for production, consider aggregation + // For sorting by support metrics, we fetch up to 1000 profiles to + // avoid loading unbounded rows into memory (#790). A production-grade + // solution should use a precomputed totalSupported column incremented + // transactionally. The take cap is a safe short-term mitigation. const profiles = await prisma.profile.findMany({ where, + take: 1000, include: { acceptedAssets: true, supportTransactions: { @@ -3155,14 +3158,21 @@ All errors return JSON with an \`error\` field and optional \`code\`: assetIssuer: parsed.data.assetIssuer, }; - // Verify the profile exists before touching Horizon (#574) + // Verify the profile exists and that recipientAddress matches its wallet + // before touching Horizon (#794). Without this check an attacker can + // supply a real tx hash paying their own wallet while pointing profileId + // at a victim — Horizon validation passes but the wrong profile is + // credited. Fetching walletAddress here closes that fraud vector. const profileExists = await prisma.profile.findUnique({ where: { id: parsed.data.profileId }, - select: { id: true }, + select: { id: true, walletAddress: true }, }); if (!profileExists) { return sendError(res, 404, "Profile not found"); } + if (profileExists.walletAddress !== parsed.data.recipientAddress) { + return sendError(res, 400, "recipientAddress does not match profile wallet", "ADDRESS_MISMATCH"); + } const skipHorizonValidation = process.env.SKIP_HORIZON_VALIDATION === "true"; if (skipHorizonValidation) { diff --git a/backend/tsconfig.json b/backend/tsconfig.json index 73cabfa8..79526fcb 100644 --- a/backend/tsconfig.json +++ b/backend/tsconfig.json @@ -8,7 +8,8 @@ "forceConsistentCasingInFileNames": true, "skipLibCheck": true, "outDir": "dist", - "rootDir": "." + "rootDir": ".", + "sourceMap": true }, "include": ["src/**/*.ts", "prisma/**/*.ts"] }