Skip to content

api/sign-fee-bump: fee-bump sponsorship signs any transaction XDR with no validation, auth, or rate limit — unbounded sponsor-drain vector #124

Description

@prodbycorne

Overview

src/app/api/sign-fee-bump/route.ts is a public, unauthenticated Next.js API route that takes a caller-supplied innerTxXdr, wraps it in a fee-bump transaction sponsored by STELLAR_FEE_SPONSOR_SECRET, signs the fee-bump envelope with the sponsor's keypair, and returns the signed XDR:

const { innerTxXdr } = body;
...
innerTxObj = TransactionBuilder.fromXDR(innerTxXdr, networkPassphrase) as Transaction;
...
const feeBumpTx = buildFeeBumpTransaction(innerTxObj, sponsorKeypair.publicKey(), networkPassphrase);
feeBumpTx.sign(sponsorKeypair);
return NextResponse.json({ feeBumpTxXdr: feeBumpTx.toEnvelope().toXDR('base64') });

There is no check anywhere in this handler that innerTxObj actually invokes a SmartDrop pool contract's lock_assets/unlock_assets, no check on operation count/type, no per-account or per-IP rate limit, and no session/auth requirement of any kind — it only verifies the XDR parses. The intended client-side gate (isFeeSponsored in src/lib/soroban.ts's lockAssets/unlockAssets, based on the caller's XLM balance being under 1.0) is purely advisory: it lives entirely in the browser and is never re-checked server-side. Anyone can curl this endpoint directly with an arbitrary, unrelated, already-self-signed transaction (e.g. a Payment operation between two accounts they control) and receive back a valid fee-bump envelope signed by SmartDrop's sponsor keypair. Once submitted to the network, the sponsor account pays the base fee for a transaction that has nothing to do with SmartDrop's pools. buildFeeBumpTransaction (src/lib/soroban.ts:238-257) even scales the sponsored fee by the inner transaction's operation count (innerOps = txObj.operations.length || 1), so an attacker can pad a transaction with the maximum allowed operations to amplify the sponsored fee per request. With no rate limiting, this is a fully automatable drain of the sponsor's XLM balance, unrelated to any real SmartDrop deposit/withdrawal activity.

Requirements

  • Validate innerTxObj server-side before fee-bumping: it must contain exactly the operation(s) SmartDrop actually sponsors (an invokeHostFunction call whose target contract ID is a known pool contract — from the factory — and whose function name is lock_assets or unlock_assets), rejecting anything else with 400.
  • Require the inner transaction to already carry a valid signature from its own source account before fee-bumping it (reject unsigned/invalid-signature inner transactions rather than blindly trusting XDR structure).
  • Add a rate limit (per source-account and/or per-IP) on this route, since it authorizes real, sponsor-funded spend.
  • Cap the number of operations / bound the fee amplification buildFeeBumpTransaction will sponsor for a single call.
  • Log/alert on rejected fee-bump attempts so abuse is observable.

Acceptance Criteria

  • A request with an innerTxXdr for a Payment operation (or any operation that isn't an invokeHostFunction call to a known pool's lock_assets/unlock_assets) is rejected with 400, not sponsored.
  • A request whose invokeHostFunction targets a contract ID that is not a currently-known pool (from the factory) is rejected.
  • A request with an unsigned or invalidly-signed inner transaction is rejected before the sponsor ever signs a fee-bump for it.
  • Repeated requests from the same source account/IP within a short window are throttled (429) rather than each producing a new sponsored signature.
  • Existing legitimate lock/unlock sponsored-fee flow (src/lib/soroban.ts lockAssets/unlockAssets when isFeeSponsored) continues to work end-to-end.

Additional Notes

More precise references

  • src/app/api/sign-fee-bump/route.ts:1-79 — the entire handler; confirmed no validation beyond "does this parse as XDR" (line 45-55) exists anywhere in the file.
  • src/lib/soroban.ts:238-257 (buildFeeBumpTransaction) — confirmed innerOps = txObj.operations.length || 1 directly scales baseFee = Math.max(100, Math.ceil(innerFee / innerOps)), i.e. the caller's own transaction shape controls how much the sponsor commits to pay.
  • src/lib/soroban.ts:902-916 and :1029-1043 — confirmed isFeeSponsored is computed client-side from fetchAccountBalances(userAddress) and is never passed to, or re-verified by, the API route; the route trusts whatever XDR it's handed regardless of the caller's real balance.
  • src/lib/soroban.ts:944-961 and :1086-1102 — the two call sites that POST to /api/sign-fee-bump, both with only { innerTxXdr: signedTransaction } in the body, confirming the route's only input surface is the raw XDR.

Additional edge cases

  • An attacker doesn't need a SmartDrop account/wallet at all — any Stellar keypair with a trivial signed transaction (even a 0-XLM self-payment) is enough to receive a valid sponsor signature and drain fees on submission.
  • Because buildFeeBumpTransaction accepts innerTx: Transaction | string, a caller can also submit a transaction the sponsor account itself is unrelated to entirely (any random source account), so this isn't limited to griefing SmartDrop users — it's an open subsidized-fee oracle for the whole network as long as the endpoint is reachable.
  • If STELLAR_FEE_SPONSOR_SECRET funds are exhausted by abuse, legitimate low-balance users attempting to deposit/unlock would silently start failing the real sponsored flow too (shared blast radius).

Implementation sketch

  1. Decode innerTxObj.operations and require operations.length === 1 and operations[0].type === 'invokeHostFunction'.
  2. Extract the invoked contract address and function name (mirroring the XDR-walking already done in validateSimulationAuth/decodeAuthEntryContractFunction in soroban.ts:514-591) and check the contract ID against sorobanService's known pool set (from getFactoryPools()), and the function name against an allow-list (lock_assets, unlock_assets).
  3. Verify innerTxObj.signatures.length > 0 and that the signature validates against the transaction's declared source account before proceeding.
  4. Add a simple in-memory or edge-cache rate limiter keyed by the inner transaction's source account (e.g. N requests per minute).

Test/reproduction plan

  • New route.test.ts: POST a Payment-only signed XDR → expect 400, no feeBumpTxXdr in response, sponsor keypair never invoked.
  • POST a valid invokeHostFunction XDR targeting a contract ID not in the current factory pool list → expect 400.
  • POST a valid lock_assets call against a real pool, unsigned → expect 400 (currently this would succeed, since signatures aren't checked at all).
  • POST the same valid, properly-signed lock_assets XDR N+1 times in quick succession → the (N+1)th is rate-limited.
  • Regression: the existing sponsored-fee happy path (used by lockAssets/unlockAssets when the wallet has <1 XLM) still returns a usable feeBumpTxXdr.

Cross-references

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial CampaignCampaign: Official CampaignOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Stellar WaveIssues in the Stellar wave programThird CampaignCampaign: Third CampaignsecuritySecurity, signing safety, or wallet interaction hardeningvery hardExtremely hard — deep expertise, careful design, and significant time requiredwalletFreighter wallet integration, session, and network switching

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions