Skip to content

Solana settlement columns are caller-controlled at INSERT — a pending row can under-quote what it owes (#528 follow-up) #538

Description

@guillermoscript

Found while implementing #528 (PR #537), 2026-07-25. Verified against a local database.

Problem

#528 closed the two impacts that depended on an untrusted caller producing a successful transaction: the INSERT policy on transactions now pins status = 'pending', and table-level UPDATE is revoked from authenticated in favour of a three-column grant.

That invariant is "an untrusted caller may open a transaction, but may not declare it paid". It deliberately says nothing about what a pending row claims it is owed — and the Solana settlement columns are exactly that:

settlement_currency  text     -- 'sol' | 'usdc'
settlement_base      bigint   -- amount in lamports / USDC base units
settlement_mint      text
settlement_sol_usd   numeric(20,8)

All four are still writable by authenticated on INSERT, because app/api/payments/checkout/route.ts:233 legitimately writes them on the user-scoped client and the fix in #528 did not narrow INSERT columns.

Impact

The on-chain payment is verified against the row's own settlement_base, in lib/payments/solana-reconcile.ts:103-106:

if (tx.settlement_base != null && tx.settlement_currency) {
  vTotalBase = Number(tx.settlement_base)
  vSplTokenStr = tx.settlement_mint
  vDecimals = tx.settlement_currency === 'usdc' ? 6 : 9
}

vTotalBase becomes the totalBase handed to verifySplitTransfer. Both callers reach it — app/api/payments/solana/verify/route.ts:27 (reconcileSolanaOneTimeTransaction) and the /api/cron/solana-reconcile backstop. A student can therefore skip the checkout route and POST /rest/v1/transactions directly:

{
  "user_id": "<self>", "tenant_id": "<own tenant>",
  "product_id": <a $100 course>, "amount": 100, "currency": "usd",
  "status": "pending", "payment_provider": "solana",
  "settlement_currency": "sol", "settlement_base": 1
}

The row is pending, so it passes the #528 policy cleanly. They then pay 1 lamport, the verify endpoint compares the on-chain transfer against settlement_base: 1, finds it satisfied, and flips the row to successful on the service-role client — which fires trigger_manage_transactions and grants the entitlement. Full course access for a fraction of a cent, through the legitimate payment path.

amount is caller-controlled at INSERT for the same reason, which is the same shape of problem for any provider that reconciles against the stored amount rather than against the product's price.

Why it wasn't folded into #528

#528's two filed impacts (free enrolment via a self-inserted successful row, and fabricated payout liability) are both gated on the row reaching successful, and both are closed by pinning status. This one is reached through a legitimately pending row, so it needs a different control, and mixing the two would have made that PR unreviewable.

Possible directions

The straightforward fixes for status do not transfer here, because these columns must hold caller-supplied values at insert time:

  • Column-level INSERT grants don't help on their own: the checkout route needs to write these columns, so they'd stay in the allow-list.
  • A BEFORE INSERT trigger that recomputes the values — the pattern Split snapshot is written at a single call site with no DB backstop (#496 follow-up) #512 used for school_percentage_snapshot — can't fully work: settlement_sol_usd comes from a live SOL/USD quote fetched off-platform (getSolUsdPrice()), which a trigger cannot obtain. A trigger could still recompute amount from products.price / plans.price and reject a settlement_base inconsistent with it, which would close most of the gap.
  • Move transaction creation behind a SECURITY DEFINER RPC (direction 3 in transactions RLS restricts rows but not columns — a student can self-insert a 'successful' sale #528) and revoke the direct INSERT grant. The RPC would take product_id/plan_id plus the quoted rate and derive amount, currency and the settlement figures server-side. This is the thorough fix and probably the right one, but it is a real change to the checkout path.
  • Or verify against the product price rather than the row in solana/verify — narrower, and it leaves the column writable for anything else that reads it.

Worth confirming which of settlement_* and amount are actually load-bearing in verification before choosing, rather than hardening all of them by reflex.

Metadata

Metadata

Labels

bugSomething isn't workingpaymentsPayment provider / billing relatedsecuritySecurity vulnerability or hardeningseverity:highHigh severity security finding

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions