Skip to content

Commit 146a8cf

Browse files
fix(security): make transactions server-write-only so a pending row cannot under-quote what it owes (#538) (#539)
#528 pinned `status = 'pending'` on the user-scoped INSERT policy, which closed self-declared payment but said nothing about what a legitimately pending row claims it OWES. The Solana settlement columns are exactly that, and both Solana paths verify against them: `lib/payments/solana-reconcile.ts` builds `verifySplitTransfer({ totalBase })` from `settlement_base`, and `/api/payments/solana/tx` builds the wallet's payment request from it. So a student could POST `/rest/v1/transactions` directly with a pending `solana` row for a $49 product carrying `settlement_currency: 'sol', settlement_base: 1`, pay one lamport, and have the verify endpoint confirm the transfer and flip the row to 'successful' on the service-role client — firing `trigger_manage_transactions` and granting the entitlement. Full course access for a fraction of a cent, through the legitimate payment path. Reproduced against a local database; the same row shape also under-quotes `amount`, which understates the school's payout for the providers that charge from their own catalogue. Which columns are load-bearing, since the issue asks before choosing a control: `settlement_base` (the figure compared on-chain), `settlement_currency` (sets decimals, 6 vs 9), `settlement_mint` (which SPL token counts) and `amount` (the legacy fallback, and the payout figure). `settlement_sol_usd` is read by nothing — it is the audit record of the locked quote. The root cause is the INSERT grant, not the columns: nothing in the browser inserts into `transactions`, and both API routes that do already derive every financial field server-side from tenant-scoped `products`/`plans` reads. So their inserts move to the admin client — the pattern #528 applied to `enrollUser` — and the grant is revoked from `authenticated` and `anon`. The #528 policy is kept and tightened to pin the four settlement columns NULL, so the under-quote stays impossible if the grant is ever restored. Rejected: a BEFORE INSERT trigger recomputing `amount` (it would overwrite the admin-confirmed `payment_amount` on manual payments and `grant_free_subscription()`'s zero-amount row), and a SECURITY DEFINER RPC (it cannot fetch the SOL/USD quote, so it would have to accept the rate from the caller — the very value that must not be caller-supplied). Verified against a local database: the under-quoted insert goes from succeeding to `permission denied`; the same insert as service role still succeeds; `grant_free_subscription()` still inserts as `authenticated` through its DEFINER owner; and `/api/payments/checkout` still creates the pending row end-to-end. Claude-Session: https://claude.ai/code/session_01P8j7YxZRVPT3B4VpENtpng Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
1 parent 4de7f65 commit 146a8cf

8 files changed

Lines changed: 603 additions & 6 deletions

File tree

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,7 @@ Also supported (`products.payment_provider`): `paypal`, `lemonsqueezy`, `solana`
109109
- `enroll_user()` RPC loops through ALL courses for a product (a product can map to multiple courses via `product_courses`) and writes to `entitlements`
110110
- **Subscriptions grant access, not auto-enrollment** — students self-enroll via `/dashboard/student/browse` (`useEnrollment()` hook); `plan_courses` defines which courses a plan covers
111111
- Transactions have two partial unique indexes (not one): `(user_id, product_id) WHERE plan_id IS NULL AND status IN ('pending','successful')` and `(user_id, plan_id) WHERE product_id IS NULL AND status IN (...)`, plus `transactions_provider_charge_id_unique` for Solana idempotency
112+
- **`transactions` is server-write-only.** `authenticated` has no INSERT grant (#538) and an UPDATE grant on only `status`, `provider_subscription_id`, `stripe_payment_intent_id` (#528) — every insert uses `createAdminClient()` or a SECURITY DEFINER function. `amount` and the `settlement_*` columns decide what the buyer owes (`settlement_base` is what the on-chain Solana payment is verified against), so they are derived from `products`/`plans` server-side, never taken from the request. A user-scoped insert fails with `permission denied for table transactions` — by design, not a bug to re-grant around
112113

113114
### Routing & i18n
114115

app/api/payments/checkout/route.ts

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -219,8 +219,8 @@ export async function POST(req: NextRequest) {
219219
// columns) and a student-supplied value must not survive. This write is kept
220220
// deliberately: it computes the identical number, and it keeps the rollback
221221
// migration a safe lever — drop the trigger and this path still snapshots.
222-
const adminClientForSplit = createAdminClient()
223-
const { data: revenueSplit } = await adminClientForSplit
222+
const adminClient = createAdminClient()
223+
const { data: revenueSplit } = await adminClient
224224
.from('revenue_splits')
225225
.select('school_percentage')
226226
.eq('tenant_id', tenantId)
@@ -229,7 +229,25 @@ export async function POST(req: NextRequest) {
229229

230230
// 1. Pending transaction — our correlation id (transaction_id) round-trips
231231
// back on the webhook (LS) or the verify endpoint (Solana).
232-
const { data: transaction, error: txError } = await supabase
232+
//
233+
// The ADMIN client, since #538. The settlement_* figures below are what the
234+
// on-chain payment is later verified against (lib/payments/solana-reconcile.ts
235+
// → verifySplitTransfer({ totalBase })), and this used to be the reason
236+
// `authenticated` had to keep an INSERT grant on `transactions` — which meant a
237+
// student could POST their own pending row claiming it owed 1 lamport for a $49
238+
// product, pay that, and be enrolled. Writing here on the service-role client
239+
// let 20260725180000 revoke the grant outright, so `amount`, `currency`,
240+
// `payment_provider` and all four settlement columns are now server-owned.
241+
//
242+
// Per CLAUDE.md that shifts the tenant check to us, and it is already in place:
243+
// `user.id` comes from the verified session, `tenantId` from the x-tenant-id
244+
// header proxy.ts sets, and every field below is derived from the tenant-scoped
245+
// `plans` / `products` read above on the USER-scoped client — so a caller still
246+
// cannot reference another tenant's catalogue or price. The live SOL/USD quote
247+
// (getSolUsdPrice) never crosses the client boundary in either direction, which
248+
// is why this is an admin-client write and not a SECURITY DEFINER RPC: an RPC
249+
// would have to accept that rate as a caller-supplied parameter.
250+
const { data: transaction, error: txError } = await adminClient
233251
.from('transactions')
234252
.insert({
235253
user_id: user.id,

app/api/stripe/create-payment-intent/route.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { NextRequest, NextResponse } from 'next/server'
22
import { getStripe } from '@/lib/stripe'
33
import { createClient } from '@/lib/supabase/server'
4+
import { createAdminClient } from '@/lib/supabase/admin'
45
import { getCurrentTenantId } from '@/lib/supabase/tenant'
56
import { toCents } from '@/lib/currency'
67
import { getPaymentProvider } from '@/lib/payments'
@@ -135,8 +136,16 @@ export async function POST(req: NextRequest) {
135136
const platformPercentage = split?.platform_percentage || 20
136137
const platformFee = Math.round((amount * platformPercentage) / 100)
137138

138-
// Create transaction record (pending)
139-
const { data: transaction, error: txError } = await supabase
139+
// Create transaction record (pending).
140+
//
141+
// The ADMIN client, since #538: `authenticated` no longer holds an INSERT grant
142+
// on `transactions` (20260725180000), because that grant let a student POST a
143+
// pending row that under-quoted what it owed. Per CLAUDE.md the tenant check
144+
// moves to us and is already in place — `user.id` comes from the verified
145+
// session, `tenantId` from the x-tenant-id header, and `amount` / `currency`
146+
// are derived from the tenant-scoped `plans` / `products` read above on the
147+
// user-scoped client, so a caller cannot reference another tenant's catalogue.
148+
const { data: transaction, error: txError } = await createAdminClient()
140149
.from('transactions')
141150
.insert({
142151
user_id: user.id,

docs/DATABASE_SCHEMA.md

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -555,10 +555,19 @@ Payment transactions. Tenant-scoped.
555555
| `provider_metadata` | JSONB | |
556556
| `stripe_payment_intent_id` | TEXT | Stripe-specific, kept for history |
557557
| `school_percentage_snapshot` | NUMERIC | Revenue split frozen at purchase time — database-owned, never caller-supplied |
558-
| `settlement_currency` / `settlement_base` / `settlement_mint` / `settlement_sol_usd` | | Crypto settlement detail (Solana) |
558+
| `settlement_currency` / `settlement_base` / `settlement_mint` / `settlement_sol_usd` | | Crypto settlement detail (Solana) — server-owned; `settlement_base` is the figure the on-chain payment is verified against |
559559

560560
**Status is `'successful'`** (NOT `'succeeded'`).
561561

562+
**Writes are server-only.** `authenticated` holds no INSERT grant (#538) and only a
563+
three-column UPDATE grant — `status`, `provider_subscription_id`,
564+
`stripe_payment_intent_id` (#528). Every insert goes through a service-role client
565+
(the two checkout routes, the webhooks, the reconcilers, `payment-requests.ts`) or the
566+
SECURITY DEFINER `grant_free_subscription()`. A user-scoped insert fails with
567+
`permission denied for table transactions` — that is deliberate: the row's `amount` and
568+
settlement figures decide what the buyer owes, so they are derived from
569+
`products`/`plans` on the server, never accepted from the caller.
570+
562571
**Three unique indexes, not one** — a purchase is either product-shaped or plan-shaped, never both:
563572

564573
```sql
Lines changed: 163 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,163 @@
1+
-- Issue #538 — an untrusted caller may not open a transaction at all.
2+
--
3+
-- #528 (20260725170000) established the invariant "an untrusted caller may open
4+
-- a transaction, but may not declare it paid": the INSERT policy pins
5+
-- `status = 'pending'`, and table-level UPDATE is revoked in favour of a
6+
-- three-column grant. It deliberately said nothing about WHAT a pending row
7+
-- claims it is owed, and the Solana settlement columns are exactly that:
8+
--
9+
-- settlement_currency text -- 'sol' | 'usdc'
10+
-- settlement_base bigint -- lamports / USDC base units
11+
-- settlement_mint text -- SPL mint; NULL = native SOL
12+
-- settlement_sol_usd numeric(20,8) -- locked quote (audit)
13+
--
14+
-- The on-chain payment is verified against the row's OWN settlement_base
15+
-- (lib/payments/solana-reconcile.ts:104 → verifySplitTransfer({ totalBase })),
16+
-- and the Solana Pay transaction handed to the wallet is built from it too
17+
-- (app/api/payments/solana/tx/route.ts:119). So a student could skip the
18+
-- checkout route and POST /rest/v1/transactions directly:
19+
--
20+
-- { user_id: <self>, tenant_id: <own tenant>, product_id: <a $49 course>,
21+
-- amount: 100, status: 'pending', payment_provider: 'solana',
22+
-- settlement_currency: 'sol', settlement_base: 1 }
23+
--
24+
-- The row is 'pending', so it passed the #528 policy cleanly (reproduced against
25+
-- a local database: the insert above succeeded as role `authenticated` with
26+
-- alice's claims). They then pay 1 lamport, /api/payments/solana/verify finds
27+
-- the transfer satisfies settlement_base, and flips the row to 'successful' on
28+
-- the service-role client — firing trigger_manage_transactions and granting the
29+
-- entitlement. Full course access for a fraction of a cent, through the
30+
-- legitimate payment path.
31+
--
32+
-- WHICH COLUMNS ARE LOAD-BEARING (established before choosing a control).
33+
--
34+
-- settlement_base the figure compared on-chain, and the amount the QR
35+
-- asks the wallet for. Load-bearing in both paths.
36+
-- settlement_currency selects `decimals` (6 for usdc, else 9) — wrong
37+
-- decimals move the verified amount by 10^3.
38+
-- settlement_mint selects which SPL token counts (NULL = native SOL); a
39+
-- caller-chosen mint is one the attacker can mint freely.
40+
-- settlement_sol_usd nothing reads it. Audit record of the locked quote only.
41+
-- amount the fallback both paths use when settlement_base IS
42+
-- NULL (legacy rows), and the figure getPayoutsOwed()
43+
-- sums. No webhook compares it against what the provider
44+
-- actually charged (lib/payments/webhook-dispatch.ts
45+
-- never reads it), so for Lemon Squeezy / PayPal /
46+
-- Binance an under-quoted `amount` does not buy access —
47+
-- those charge from their own catalogue — it understates
48+
-- the school's payout instead.
49+
--
50+
-- THE ROOT CAUSE IS THE GRANT, NOT THE COLUMNS.
51+
--
52+
-- #528 could not narrow INSERT columns because the table-level INSERT grant to
53+
-- `authenticated` is what two API routes rely on. But nothing in the BROWSER
54+
-- inserts into `transactions` — every legitimate insert already happens on the
55+
-- server, and the complete list is:
56+
--
57+
-- app/api/payments/checkout/route.ts user-scoped client
58+
-- app/api/stripe/create-payment-intent/route.ts user-scoped client
59+
-- app/[locale]/(public)/checkout/actions.ts admin client (since #528)
60+
-- app/actions/payment-requests.ts:359 admin client
61+
-- lib/payments/webhook-dispatch.ts, the Stripe webhook, the Solana/Binance
62+
-- reconcilers, both cron routes, scripts/*, tests/playwright/*
63+
-- service role
64+
-- grant_free_subscription() SECURITY DEFINER
65+
--
66+
-- The first two are the only reason the grant exists, and both already derive
67+
-- every financial field server-side from trusted sources: `amount`, `currency`
68+
-- and `payment_provider` come from tenant-scoped reads of `products` / `plans`,
69+
-- and the settlement figures from `getSolUsdPrice()` — a live off-platform quote.
70+
-- Moving those two inserts onto the admin client (this commit) leaves the grant
71+
-- with no legitimate user, so it goes.
72+
--
73+
-- WHY NOT THE OTHER TWO DIRECTIONS THE ISSUE OFFERS.
74+
--
75+
-- A BEFORE INSERT trigger recomputing the values (the #512 pattern for
76+
-- school_percentage_snapshot) would overwrite legitimate ones: completing a
77+
-- payment request inserts the admin-confirmed `payment_amount`, and
78+
-- grant_free_subscription() inserts a zero-amount row for its plan. Rewriting
79+
-- or rejecting those converts a quiet mispricing into a broken payment path.
80+
-- It also cannot help with native SOL at all: settlement_base is derived from
81+
-- a live SOL/USD quote a trigger cannot obtain.
82+
--
83+
-- A SECURITY DEFINER RPC has the same blind spot from the other side. It
84+
-- cannot fetch the quote either, so it would have to accept the rate as a
85+
-- caller parameter — and the rate is precisely the value that must not be
86+
-- caller-supplied, since settlement_base is computed from it. That moves the
87+
-- vulnerability rather than closing it. Inserting on the admin client keeps
88+
-- the quote from crossing the client boundary in either direction.
89+
--
90+
-- WHAT THIS LEAVES REACHABLE. Nothing, for an untrusted caller: INSERT is denied
91+
-- outright here, and #528 already made `amount` and all four settlement_*
92+
-- columns unwritable on UPDATE via the column grant. Both halves are needed —
93+
-- either alone leaves a route to the same row.
94+
95+
BEGIN;
96+
97+
-- ---------------------------------------------------------------------------
98+
-- (a) Revoke INSERT. `POST /rest/v1/transactions` now fails with
99+
-- `permission denied for table transactions`.
100+
-- ---------------------------------------------------------------------------
101+
--
102+
-- A future user-scoped insert will fail loudly with that error rather than
103+
-- silently writing a caller-priced row. That is the intended trade-off on a
104+
-- payments table, and the same one #528 accepted for UPDATE.
105+
--
106+
-- `anon` is included for hygiene rather than to close a live path: the RLS
107+
-- policies on this table are all `TO authenticated`, so an anonymous insert was
108+
-- already rejected. Holding a grant that no policy can satisfy only invites a
109+
-- later policy widening to become an unnoticed hole.
110+
--
111+
-- Untouched, deliberately: service_role (every webhook / reconciler / cron and
112+
-- the four admin-client call sites), and the table owner — SECURITY DEFINER
113+
-- functions run as the owner, so grant_free_subscription() still inserts its
114+
-- zero-amount 'successful' row for the free-plan path.
115+
116+
REVOKE INSERT ON TABLE public.transactions FROM authenticated, anon;
117+
118+
-- The sequence grant is now unusable for its only purpose. Left in place: it is
119+
-- harmless without INSERT, and revoking it would add a second thing to restore
120+
-- if this migration is ever rolled back.
121+
122+
-- ---------------------------------------------------------------------------
123+
-- (b) Keep #528's INSERT policy, and pin the settlement columns NULL in it.
124+
-- ---------------------------------------------------------------------------
125+
--
126+
-- This policy is unreachable while (a) holds — no grant, no policy evaluation.
127+
-- It is retained and tightened because (a) is a single `GRANT INSERT` away from
128+
-- being undone: a rollback of this migration, a future schema dump that
129+
-- re-applies the original `GRANT ALL ON TABLE transactions TO authenticated`
130+
-- from 20260126190500_lms_complete.sql, or someone resolving a `permission
131+
-- denied` error the direct way. In any of those cases the under-quote in this
132+
-- issue stays impossible, because a user-scoped INSERT can no longer carry a
133+
-- settlement claim at all — the columns must be NULL, which sends both Solana
134+
-- paths down their legacy fallback (verify against `amount`) instead of
135+
-- honouring a caller-supplied figure.
136+
--
137+
-- Note what the policy still cannot express: `amount` matching the product's
138+
-- price. A WITH CHECK could subquery `products`/`plans` for it, but that would
139+
-- couple this policy to those tables' SELECT policies — the coupling #512
140+
-- explicitly refused for the same reason (tighten the other policy, and this one
141+
-- starts silently rejecting or accepting the wrong thing). With the grant
142+
-- revoked, `amount` is server-owned; if the grant is ever restored, `amount`
143+
-- becomes caller-controlled again and that is a payout-accuracy problem to
144+
-- reopen, not a course-access one.
145+
146+
DROP POLICY IF EXISTS "Users can create own transactions" ON public.transactions;
147+
148+
CREATE POLICY "Users can create own transactions"
149+
ON public.transactions FOR INSERT TO authenticated
150+
WITH CHECK (
151+
(SELECT auth.uid()) = user_id
152+
AND tenant_id = (SELECT get_tenant_id())
153+
AND status = 'pending'
154+
AND settlement_currency IS NULL
155+
AND settlement_base IS NULL
156+
AND settlement_mint IS NULL
157+
AND settlement_sol_usd IS NULL
158+
);
159+
160+
COMMENT ON COLUMN public.transactions.settlement_base IS
161+
'Locked integer base amount the wallet must pay: lamports (sol) or token base units (usdc). Set once at checkout; /tx and /verify read this, never re-quote. Issue #538: server-owned — the on-chain payment is verified against this figure, so it is written only by a service-role client (app/api/payments/checkout/route.ts) and is unwritable by `authenticated` on both INSERT (grant revoked, 20260725180000) and UPDATE (column grant, 20260725170000).';
162+
163+
COMMIT;
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
-- Rollback for 20260725180000_transactions_insert_lockdown.sql (issue #538).
2+
--
3+
-- NOT APPLIED BY DEFAULT. Running this restores the INSERT grant and the #528
4+
-- policy as 20260725170000_transactions_column_hardening.sql left them — which
5+
-- REOPENS #538: an authenticated student regains the ability to POST a pending
6+
-- `solana` transaction carrying its own settlement claim (settlement_base: 1 on
7+
-- a $49 product), pay a single lamport, and have the verify endpoint confirm it
8+
-- and grant the entitlement.
9+
--
10+
-- It also only makes sense together with reverting the two route changes in the
11+
-- same commit: with the grant back but the inserts still on the admin client,
12+
-- nothing uses the grant and the hole is open for no reason.
13+
--
14+
-- Only run it if the revoked grant has broken a legitimate write path and the fix
15+
-- has to be re-cut.
16+
17+
BEGIN;
18+
19+
GRANT INSERT ON TABLE public.transactions TO authenticated, anon;
20+
21+
DROP POLICY IF EXISTS "Users can create own transactions" ON public.transactions;
22+
23+
CREATE POLICY "Users can create own transactions"
24+
ON public.transactions FOR INSERT TO authenticated
25+
WITH CHECK (
26+
(SELECT auth.uid()) = user_id
27+
AND tenant_id = (SELECT get_tenant_id())
28+
AND status = 'pending'
29+
);
30+
31+
COMMENT ON COLUMN public.transactions.settlement_base IS
32+
'Locked integer base amount the wallet must pay: lamports (sol) or token base units (usdc). Set once at checkout; /tx and /verify read this, never re-quote.';
33+
34+
COMMIT;

0 commit comments

Comments
 (0)