Hopp til hovedinnhold
Erik Nilsen
6 min lesing

RLS beskytter raden, ikke rettigheten

  • #supabase
  • #rls
  • #pentest
  • #nextjs
  • #dmarc

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.

Row-Level Security er lett å stole blindt på. Den svarer presist på ett spørsmål — «er dette din rad?» — og når svaret er nei, får du ingenting. Men på et nylig oppdrag holdt RLS helt tett mot IDOR, samtidig som den lot meg gi meg selv en betalt funksjon gratis. Forskjellen ligger i hva RLS faktisk håndhever, og hva utviklerne trodde den håndhevet.

Kontekst

Oppdraget var en webapplikasjonstest av en personlig budsjettapp hos Lommerusk AS (lommeruskas.no). Stacken var Next.js på Vercel med Supabase som backend og Stripe for betaling. Produktsjefen, Kari Berg ([email protected]), ville særlig vite to ting: kunne en bruker se andres økonomi, og holdt betalingsmuren?

Det første jeg kartla var datamodellen, og den var uvanlig konsekvent: så godt som hele applikasjonstilstanden — transaksjoner, gjeld, sparing, budsjett, husholdningsprofiler og feltet subscriptionPlan — lå i én JSONB-kolonne i tabellen user_app_state, nøklet på user_id. Nettleseren skrev rett til Supabase REST-API. Det betyr at RLS i praksis var den eneste server-kontrollen mellom klienten og dataene.

RLS gjorde jobben sin

Jeg lot AI-en skrive et lite probe-script som gikk gjennom et knippe sannsynlige tabell- og RPC-navn med en autentisert testbruker, og samtidig sjekket cross-tenant-tilgang på user_app_state. Resultatet var ryddig: alle andre tabellnavn ga 404 (ikke eksponert), og mot en annen brukers rad var det helt tett.

GET  /rest/v1/user_app_state?user_id=eq.<annen-bruker>   ->  200, Content-Range: */0
POST /rest/v1/user_app_state    (user_id = <annen-bruker>) ->  403  new row violates row-level security policy

Jeg bekreftet det med to reelle testkontoer: konto B kunne verken lese eller skrive konto A sin rad. Policyen var akkurat som den skal være:

create policy "own rows" on user_app_state
  for all
  using      (auth.uid() = user_id)
  with check (auth.uid() = user_id);

Ingen IDOR. Hadde det vært hele historien, ville rapporten vært kjedelig.

…men raden inneholdt rettigheter

Det som gjorde meg nysgjerrig var at subscriptionPlan lå i den samme klient-skrivbare blob-en. Jeg ba AI-en fange den faktiske skrive-forespørselen appen sendte når jeg la til et element, og der lå hele tilstanden — inkludert plan og profiler — i kroppen. Da ble spørsmålet åpenbart: hva skjer hvis jeg bare endrer den?

Planen navnet var det ingenting å hente på. Skrev jeg subscriptionPlan: "family", ble det avstemt tilbake mot Stripe ved neste innlasting. Server-autoritativt, helt riktig.

Men funksjonen «legg til person i husholdningen» — en betalt Familie-funksjon — var bare sperret av en dialogboks i frontend. Selve dataene, listen med profiler, lå i state.profiles. Og det er min egen rad. RLS sier ja.

// state.profiles er en del av min egen rad — RLS slipper skrivingen gjennom
state.profiles.push({ id: "p2", name: "Ekstra person" });
await supabase.from("user_app_state").upsert({ user_id: me, state });
// -> 200. Etter reload: to fullverdige profiler, på en plan som betaler for én.

Etter innlasting på nytt sto det to profiler i velgeren, jeg kunne bytte mellom dem, og hver hadde sitt eget budsjett og sine egne transaksjoner. Familie- funksjonalitet, på Solo-pris. Klient-tilstandslageret eksponerte til og med en addProfile()-metode direkte i minnet — frontenden var aldri ment å være en sikkerhetsgrense, men her var den den eneste.

Medium — ikke et datainnbrudd, men et brudd på betalings-/rettighetsintegriteten. En bruker med gratis prøveperiode kunne skaffe seg den dyre planens funksjoner uten å betale.

Hvorfor det skjer

RLS er radautorisasjon. Den svarer på «eier du denne raden?», ikke på «har du betalt for denne funksjonen?». Når rettighetsbærende data — plannivå, antall profiler, feature-flagg — ligger i en rad brukeren selv eier og kan skrive til, er det eneste serveren håndhever eierskapet. Forretningsregelen «Solo = én profil» finnes da bare i frontend, og frontend er klientens domene.

Det lumske her er at teamet hadde gjort den vanskelige tingen riktig: de avstemte plan-navnet mot Stripe. Det føltes nok som at betalingen var server-autoritativ. Men de avstemte bare etiketten, ikke grensene etiketten skulle innebære.

Og så det kjedelige funnet

Mens den interessante delen handlet om RLS og rettigheter, fant et helt rutinemessig DNS-oppslag noe langt enklere. Jeg ba AI-en sjekke e-postoppsettet på domenene i scope, og lommeruskas.no hadde verken SPF eller DMARC.

For å vise hva det betyr i praksis, sendte jeg en tydelig merket test-melding med forfalsket avsender (@lommeruskas.no) fra en server som ikke er autorisert for domenet, til en innboks jeg selv kontrollerte. Den ble akseptert og levert.

MAIL FROM:<[email protected]>
RCPT TO:<min-egen-innboks>
... 250 2.6.0 Queued

Plasseringen var det interessante: hos en Microsoft 365-leietaker havnet den rett i innboksen, mens forbruker-Outlook la den i søppelpost. Uten SPF/DMARC er det mottakerens egne heuristikker som avgjør — og de er ikke til å stole på. Meldingen var merket som en autorisert test, og gikk bare til en adresse jeg hadde lov til å sende til; poenget var å vise eksponeringen, ikke å lure noen.

Kontrasten er hele poenget: det ene funnet krevde at jeg forsto en utradisjonell datamodell; det andre var et manglende DNS-felt hvem som helst kunne sjekket på ett minutt. Begge gikk i samme rapport.

AI i loopen

Det meste av legwork-en her var AI-drevet: enumereringen av tabeller og RPC-er, probe-scriptene for cross-tenant-tilgang, fangsten av den ekte forespørselen, og de første forsøkene på å skrive en fremmed user_id (som ga 403 — bra for dem). Jeg ba om en ting av gangen og leste resultatene.

Det maskinen ikke gjorde, var å se hvorfor subscriptionPlan i en skrivbar blob var den interessante tråden, skille det reelle funnet fra støy, holde testingen innenfor scope, og lande alvorlighetsgrad og anbefaling. Den dømmekraften — og ansvaret for den — er fortsatt min. Arbeidsmåten er rask; den erstatter ikke at noen faktisk forstår hva som er et funn.

Hva jeg lærte

  • RLS er ikke rettighetshåndheving. Den beskytter rader, ikke forretningsregler.
  • Ikke lagre rettighetsbærende felter i klient-skrivbar lagring. Plannivå, kvoter og feature-flagg hører hjemme et sted brukeren ikke kan skrive til.
  • Å avstemme etiketten holder ikke hvis grensene etiketten innebærer ikke håndheves i samme lag.
  • En frontend-sperre er UX, ikke en kontroll. Hvis den eneste som stopper en handling er en dialogboks, finnes ikke kontrollen.
  • De avanserte funnene stjeler oppmerksomheten, men sjekk alltid det kjedelige først: SPF, DMARC, sikkerhetsheadere. Det tar minutter og mangler oftere enn du tror.

Veien videre

Den konkrete anbefalingen var å flytte rettighetsavgjørelsene ned i et lag brukeren ikke styrer: en server-handling eller edge-funksjon som validerer profilantall og funksjonsbruk mot det verifiserte abonnementet før noe lagres, eventuelt en database-constraint som binder antall profiler til faktisk plan. Dataene kan gjerne fortsatt ligge i Supabase — men skrivingen som betyr noe for penger og rettigheter må gå gjennom noe som kan si nei.