fix(security): pin transaction status and columns for user-scoped writes (#528) - #537
Conversation
…tes (#528) `transactions` grants table-level INSERT/UPDATE to `authenticated`, and its RLS policies constrain only which ROWS a user may write, never which columns. An authenticated student could therefore POST a row with any column values, provided user_id was theirs and tenant_id was their current tenant. Two impacts, both reproduced against a local database and both reachable without touching a payment provider: 1. Free enrolment. `trigger_manage_transactions` (AFTER INSERT/UPDATE) calls `enroll_user()` whenever product_id is set and status = 'successful', so a self-inserted row wrote `entitlements` — paid course access, no money. 2. Fabricated payout liability. `getPayoutsOwed()` reads transactions with status IN ('successful','refunded') and a platform-settled provider, so a self-inserted row made the payout dashboard report a debt to a school for a sale that never happened. Both are gated on the row reaching 'successful' (or 'refunded'), and neither is reachable from a 'pending' row, so the fix pins that: - The INSERT policy now requires status = 'pending'. - Table-level UPDATE is revoked from `authenticated` and re-granted on only the three columns a user-scoped caller actually writes (status, provider_subscription_id, stripe_payment_intent_id); the UPDATE policy restricts the reachable statuses to 'pending' and 'failed'. The column grant is not redundant with the policy: an RLS WITH CHECK cannot compare NEW to OLD, so a policy alone cannot make a column immutable. Without it a student could open a pending transaction for a cheap product, PATCH its product_id to an expensive one, pay the cheap price, and let the provider webhook flip the row to 'successful'. `enrollUser()` in the mock checkout action is the only legitimate user-scoped caller that needs to write 'successful', so its two inserts move to the admin client; it already validates tenant ownership before writing. Service-role paths (webhooks, reconcilers, crons) and the SECURITY DEFINER `grant_free_subscription()` bypass RLS and are unaffected — verified live. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YUmcqh3asHLsstAso4UNdV
|
Merged and shipped as described in the body — no scope changed during review. What landed:
Verified before merge, against a local database with a real student JWT on the anon key: all four attacks returned Checks: Two notes for whoever picks this up next:
One correction was made to this PR's body before review: it originally claimed |
What
transactionsRLS now restricts which columns a user-scoped caller may write, not just which rows. The INSERT policy pinsstatus = 'pending', and table-levelUPDATEis revoked fromauthenticatedand re-granted on only the three columns a legitimate caller actually writes.Closes #528
Why
transactionsgrants table-levelINSERT/UPDATEtoauthenticated(20260126190500_lms_complete.sql), and the policies in20260313025318_rls_transactions.sqlconstrain only which rows a user may write:So an authenticated student could
POST /rest/v1/transactionswith any column values they liked, as long asuser_idwas theirs andtenant_idwas their current tenant. Both impacts were reproduced end-to-end against a local database, signed in asalice@student.comwith nothing but the anon key:1. Free enrolment.
trigger_manage_transactions(AFTER INSERT/UPDATE) callsenroll_user(NEW.user_id, NEW.product_id)wheneverproduct_idis set andstatus = 'successful'. A single self-inserted row bought a $49 course for one cent:2. Fabricated payout liability.
getPayoutsOwed()(app/actions/platform/payouts.ts:52) reads transactions withstatus IN ('successful','refunded')and a platform-settledpayment_provider. A self-inserted row landed ingrossOwed, so the platform's own payout dashboard reported it owed a school money for a sale that never happened:#512 (PR #525) closed
school_percentage_snapshotspecifically, by making the database compute and freeze that one column. The rest of the row was still caller-controlled.The shape of the fix
Both impacts are gated on the same thing — a row reaching
'successful'(or'refunded'for the payout half) — and neither is reachable from apendingrow. So the invariant enforced is narrow and checkable: an untrusted caller may open a transaction, but may not declare it paid. Only the webhook, verify and reconcile paths, which all run on the service-role client and bypass RLS entirely, may do that.Two layers, defending against different mechanics:
status = 'pending'— closes the self-insert path. Permissive policies OR together, so this replaces the existing policy rather than sitting alongside it.status,provider_subscription_id,stripe_payment_intent_id) via column-level grants, and the policy pinsUSING (status = 'pending')/WITH CHECK (status IN ('pending','failed'))— closes the self-escalate path.after_transaction_updatefires the same trigger, so owning a genuine pending row was a second route to the same place.The column grant is not redundant with the policy. An RLS
WITH CHECKsees only the NEW row and cannot compare it to OLD, so a policy alone cannot make a column immutable. Without the grant, a student could open a legitimate pending transaction for a cheap product,PATCHitsproduct_idto an expensive one, pay the cheap price, and let the provider's webhook flip the row tosuccessful— at which point the trigger enrols them in the expensive course. Column privileges close that at the grant layer, without needing OLD.Ordering matters: a bare
REVOKE ... (column)is a no-op while the table-level grant is held, so table-levelUPDATEis revoked first and the allowed columns re-granted after.Why
enrollUser()moved to the admin clientA full inventory of application write sites found 7 user-scoped writes. Five already write
'pending'or'failed'and are unaffected. The two inserts inenrollUser()(app/[locale]/(public)/checkout/actions.ts, the mock/sandbox checkout) write'successful'and would have broken, so they move tocreateAdminClient(). Both already validate tenant ownership before writing — the product/plan lookups are.eq('tenant_id', tenantId)reads on the user-scoped client, anduser_idcomes from the session — which satisfies thecreateAdminClient()rule inCLAUDE.md.Worth stating explicitly, because it bounds how much this matters:
enrollUser()is reachable only from the "Pay & Enroll (Test)" sandbox button, whichcomponents/public/checkout-form.tsx:678gates behindprocess.env.NODE_ENV !== 'production'. In production a Stripe product rendersStripePaymentForm(the webhook grants enrollment) and a provider-less product renders a "card unavailable" notice, so neither reaches this action. The move to the admin client therefore keeps local development and the E2E sandbox flow working; it is not covering a production payment path.Everything on a service-role client (the Stripe webhook,
lib/payments/webhook-dispatch.ts, the Solana and Binance reconcilers, both cron routes,payment-requests.ts,admin/binance-personal.ts) bypasses RLS and is untouched.grant_free_subscription()isSECURITY DEFINERand the table is notFORCE ROW LEVEL SECURITY, so it runs as the table owner and still inserts its zero-amount'successful'row — verified live, not assumed.How to QA
On a fresh
npm run db:reset, signed in asalice@student.com/password123oncode-academy.lvh.me:3000(Code Academy Pro).Automated state check — every row should report
PASS:This asserts the final grant and policy state rather than the migration text, so it also catches a later migration re-widening things.
Manual — the attacks must fail. Get a student JWT and hit PostgREST directly (this is the part that matters; RLS is invisible to the type checker):
successfulsale → expect403,new row violates row-level security policy:[]:"amount":9999,"status":"successful","payment_provider":"paypal") → expect403."product_id":2002,"amount":19,"status":"pending","payment_provider":"stripe") → expect201. Keep the returnedtransaction_id.PATCH ...?transaction_id=eq.<id>with{"amount":0.01,"payment_provider":"paypal"}) → expect403,permission denied for table transactions.{"status":"successful"}) → expect403, RLS violation.{"status":"failed"}) → expect200. This isapp/api/payments/checkout/route.ts:337and must keep working.{"provider_subscription_id":"sub_test_123"}, then{"stripe_payment_intent_id":"pi_test_123"}) → both expect200. These arepayments/checkout/route.ts:320andcreate-payment-intent/route.ts:241.Manual — the app must still work. Free-course and free-plan checkout (
enrollFree/subscribeFree, both onSECURITY DEFINERRPCs) still enrol, and the inline mock checkout on a paid product still grants access.Screenshots / GIF
No UI change — this is grant/policy DDL plus a Supabase client swap in a server action. The verification evidence is the before/after PostgREST transcript above: the same 8 requests returned
201/200for all four attacks before the migration and403for all four after, with both legitimate paths unchanged.Checklist
npm run typecheckandnpm run test:unitpass — typecheck clean; 370 tests across 31 files passnpm run buildpassestenant_id(or the table genuinely has no such column) — no new queries; the twoenrollUser()inserts already carrytenant_idand keep it on the admin clientSECURITY DEFINERpaths; teacher and admin have SELECT-only policies on this table and are unaffectedtxErrorbranchesmessages/en.jsonandmessages/es.json— n/a, no new stringsnpm run db:reset, has RLS policies, andlib/database.types.tswas regenerated — applies clean from scratch; the migration is RLS policies; no regeneration needed, as it changes no columns or types (grants and policies only)Follow-up (deliberately out of scope)
One adjacent problem surfaced while surveying. It is real, it is not what #528 describes, and folding it in would have made this unreviewable.
Settlement columns are still caller-controlled at INSERT.
settlement_base,settlement_currency,settlement_mintandsettlement_sol_usdremain writable, andapp/api/payments/solana/verify/route.tsprices the on-chain payment against them. A self-insertedpendingrow carryingsettlement_base: 1is a pay-one-lamport-for-a-$100-course path, and it survives this PR because the row is legitimatelypending— the invariant enforced here is "an untrusted caller may not declare a row paid", which does not constrain what a pending row claims it is owed. Closing it properly needs the checkout RPC (direction 3 in the issue), since the SOL/USD rate is fetched off-platform and cannot be recomputed in a trigger. Filed separately.