---
title: "Isolasjonen holdt, autorisasjonen sprakk: pentest av en regnskaps-SaaS"
date: 2026-07-07
description: "Autorisert pentest av et multi-tenant regnskaps-API. Jeg kom ikke gjennom tenant-isolasjonen, men autorisasjonsmodellen rundt den hadde tre reelle hull."
tags: ["pentest", "api-security", "multi-tenant", "ai", "security"]
url: https://erik.no/blogg/2026-07-07-isolasjon-holdt-autorisasjon-sprakk
---

<Callout type="info">
  **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.
</Callout>

Oppdraget hos Kvittring AS (`kvittringas.no`) hadde en tydelig bestilling fra
kundens tekniske ansvarlige, Jonas Bakke (`jonas.bakke@kvittringas.no`): «kom
inn, få tak i data fra andre kunder». Det er en ærlig og nyttig måte å ramme
inn en test på — den tvinger meg til å angripe det som faktisk betyr noe for en
multi-tenant regnskaps-SaaS: skillet mellom leietakerne.

Kortversjonen: jeg kom ikke gjennom det skillet. Tenant-isolasjonen holdt på
hver eneste vei jeg prøvde. Men rundt den solide kjernen lå tre reelle hull i
selve autorisasjonsmodellen — og det ene lot seg demonstrere helt frem til en
falsk, betalbar leverandørgjeld. Dette er notatene.

## Kontekst

Kvittring AS drifter et regnskaps- og ERP-API på `app.kvittringas.no`. Det er
multi-tenant: mange bedrifter deler samme plattform, og hele
sikkerhetsmodellen står og faller på at tenant A aldri ser tenant B sine
fakturaer, kunder eller lønnstall.

Jeg fikk et signert SOW, en testkonto, og to typer legitimasjon:

- Et **brukertoken** som må sende `X-Tenant-Id: <id>` for tenant-spesifikke
  kall. Serveren skal håndheve at tokenet faktisk er medlem av den tenanten.
- En **tenant-API-nøkkel** som er bundet til én tenant og bærer en liste over
  *scopes* (`customer:read`, `bank:write`, `attachment:write` osv.).

API-et er dokumentert med en OpenAPI-spec på 188 stier og 270 operasjoner.
Det er en stor angrepsflate, og måten jeg jobber på i dag handler like mye om å
temme den flaten som om å skrive enkeltangrep.

## Hvordan jeg jobbet — AI i loopen

Jeg kjører moderne pentester med en AI-agent i loopen. Jeg styrer retningen,
tar de vurderingene som krever menneskelig dømmekraft — scope, etikk,
prioritering, hva som faktisk er et funn — mens agenten gjør mesteparten av
mekanikken: leser spec, skriver scripts, kjører verktøy, leser output og
foreslår neste steg.

### Fra OpenAPI-spec til angrepsplan

Konkret startet jeg med å gi agenten hele OpenAPI-spec'en og be den bygge en
prioritert angrepsplan. Den fanet ut over de ~40 feature-gruppene i API-et
(kunder, fakturaer, bank, lønn, hovedbok …) og produserte per gruppe en rangert
liste med hypoteser: hvilke `{id}`-parametere som er IDOR-kandidater, hvilke
felt i request-body som lukter mass assignment, hvor injeksjon kunne bo, hvilke
endepunkter som er privilegerte. Jeg leste synteserapporten, kastet det
åpenbart feilrettede, og plukket de hypotesene som var verdt å teste live.

### Verktøyene som gjorde jobben

Fra der ble det en vekselvirkning: agenten skrev og kjørte sweepene, jeg leste
resultatene og brøt inn der noe krevde et menneske. Verktøyene som faktisk
gjorde jobben var `nuclei`, `sqlmap`, `swaks`, en OOB-collaborator (interactsh
bak en ngrok-tunnel), `malicious-pdf`, og en haug små `curl`-scripts. Under
vises de sanert.

## Det som holdt — og hvorfor det er et funn i seg selv

Før funnene: det viktigste resultatet er egentlig at kjernen holdt. En test som
bare rapporterer det som er ødelagt, gir et skjevt bilde. Isolasjonen ble
angrepet fra minst et dusin vinkler, og alle ble avvist korrekt.

### Tenant-isolasjon under direkte press

Den mest åpenbare: brukertokenet mitt var medlem av én test-tenant. Jeg byttet
bare ut `X-Tenant-Id` til en tenant jeg *ikke* var medlem av.

```bash
# brukertoken, men X-Tenant-Id for en tenant jeg ikke tilhorer
curl -s -H "Authorization: Bearer <REDACTED>" \
     -H "X-Tenant-Id: 2" \
     https://app.kvittringas.no/api/customers
# {"error":"Forbidden","message":"User access token does not have access to the requested tenant"}
```

Samme mønster gjentok seg overalt. Objektoppslag på `{id}` for fremmede IDer ga
`404` (ikke `403` — den lekker ikke engang at objektet finnes). Fremmede IDer
smuglet inn via request-body ble re-scopet til min egen tenant. List-filtre som
`?employeeId=<fremmed>` returnerte tomt, ikke andre tenanters rader. Til og med
bulk-endepunkter som tar en `postingIds[]`-array validerte hver eneste ID mot
kallerens tenant. Dette er den samme lærdommen som i
[RLS beskytter raden, ikke rettigheten](/blogg/2026-06-03-rls-beskytter-raden-ikke-rettigheten),
bare speilvendt: her *var* rad-isolasjonen konsekvent på plass.

### Injeksjon, autentisering og e-post-hygiene

`sqlmap` mot rapport- og hovedbok-parametrene (`accountFrom`, `amountFrom`,
`vatCode` …) fant ingenting — backenden er Spring med typebinding, og
malformert input avvises med en ren `400` lenge før noe når databasen.

```bash
sqlmap -u "https://app.kvittringas.no/api/ledger/general?startDate=2024-01-01&endDate=2024-12-31&accountFrom=1000&accountTo=9999&amountFrom=0" \
  -H "Authorization: Bearer <REDACTED>" -p "accountFrom,accountTo,amountFrom" \
  --level 3 --risk 2 --batch --dbms postgresql
# all tested parameters do not appear to be injectable
```

Sesjons-JWT-en (ES256) motsto `alg:none`, HS256-forveksling, CVE-2022-21449 og
`jku`/`x5u`-injeksjon. `nuclei` mot CVE- og misconfig-templates ga null høy/
kritisk. Og — verdt å merke seg med tanke på
[Da WordPress holdt, men e-posten åpnet døren](/blogg/2026-06-01-da-wordpress-holdt-men-e-posten-apnet-doren)
— DMARC var her *riktig* satt opp (`p=reject`), så ren avsender-spoofing var
blokkert. Det siste blir viktig om et øyeblikk.

<Callout type="success">
  Negative funn er funn. «Jeg klarte ikke å bryte tenant-skillet på tolv
  forskjellige måter» er en setning kunden bør få svart på hvitt — den er verdt
  mer enn en liste med lavgraderte header-tips.
</Callout>

## Funn 1: en API-nøkkel med scope som ikke betyr noe

<Severity level="high" /> — Broken Function Level Authorization.

Tenant-API-nøkler presenteres i grensesnittet som scope-begrensede: «denne
nøkkelen virker bare med scopene under». Testnøkkelen min hadde blant annet
`attachment:write`, men bevisst **ikke** `attachment:read`. Den asymmetrien ble
den reneste testen: kan en nøkkel som kun har skrivetilgang til vedlegg,
*lese* et vedlegg?

```bash
# nokkelen har attachment:write, men IKKE attachment:read
curl -s -H "Authorization: Bearer <REDACTED>" \
     -F "file=@canary.txt" https://app.kvittringas.no/api/attachments
# 201 {"id": 16913, "contentUrl": "/api/attachments/16913/content", ...}

curl -s -H "Authorization: Bearer <REDACTED>" \
     https://app.kvittringas.no/api/attachments/16913/content
# 200 -> "PENTEST-CANARY-..."   (filinnholdet, uten attachment:read)
```

Den leste innholdet. Og det stoppet ikke der: samme nøkkel opprettet og slettet
ressurstyper den ikke hadde noe scope for i det hele tatt. Scope-listen var
kosmetikk — den ble aldri håndhevet på serversiden.

Konsekvensen er lett å undervurdere fordi den ikke krysser tenant-grenser. Men
den bryter hele løftet om minste privilegium: en nøkkel som en administrator
tror hun deler ut som «read-only, kun kunder» til en tredjeparts-integrasjon,
har i praksis full lese-, skrive- og slettetilgang til hele tenanten — bank,
faktura, lønn, dokumenter, brukere. En slik nøkkel som havner i et git-repo
eller en logg, eksponerer alt.

Dette er den samme distinksjonen som i RLS-posten: kontrollen beskyttet det ene
laget (tenant-skillet) helt fint, men sa ingenting om det andre (hva nøkkelen
faktisk får lov til *innenfor* tenanten).

<Callout type="warning">
  Test scope-håndhevelse med en nøkkel som mangler nettopp det scopet du
  prøver. En nøkkel med `write` men uten `read` er en gavepakke — den skiller
  «virker fordi jeg egentlig har tilgang» fra «virker fordi ingen sjekker».
</Callout>

**Tiltak:** håndhev scope-listen server-side på hvert endepunkt via et sentralt
autorisasjonsfilter, og legg til en kontraktstest som itererer alle operasjoner
i OpenAPI-spec'en og bekrefter at en nøkkel med scope-sett S avvises overalt
utenfor S.

## Funn 2: fakturamottaket tok imot fra hvem som helst

<Severity level="medium" /> — uautentisert dokumentinjeksjon.

Plattformen lar deg sende fakturaer på e-post til et mottak: `<tenant-slug>@
ea.kvittringas.no`. Dokumentet OCR-tolkes, en leverandør opprettes automatisk,
og resultatet legger seg i regnskapskøen klart til å konverteres til en
leverandørgjeld.

Her er nyansen som skiller dette fra e-post-postene jeg har skrevet før: DMARC
var *ikke* hullet. Da jeg testet ren spoofing med `swaks`, ble jeg avvist som
forventet:

```text
<** 550 5.7.1 DMARC checks failed.
```

Det er god hygiene. Problemet lå et annet sted: mottaket bryr seg ikke om
*hvem* avsenderen er, bare at e-posten består DMARC for sitt eget domene. En
melding fra en helt urelatert, tilfeldig avsender — ikke knyttet til tenanten
på noen måte — havnet like fullt i den tenantens fakturakø, ble analysert, og
kunne konverteres til en åpen, betalbar post. Mottaksadressen er dessuten bare
firmanavnet som slug, altså gjettbar for hvilken som helst målbedrift.

Jeg demonstrerte kjeden ende-til-ende og stoppet ved en betalbar post i en
test-tenant — jeg viser bevisst ikke selve innsendingen eller en mal her, siden
det ville være en ferdig oppskrift på fakturasvindel. Poenget er
autorisasjonshullet: mottaket mangler avsenderverifisering. En travel
regnskapsfører som ser en tilsynelatende legitim faktura i køen, er ett klikk
unna å betale den til en angriperkontrollert konto.

Dette er en fetter av «appen holdt, e-posten var døra» fra
[WordPress-posten](/blogg/2026-06-01-da-wordpress-holdt-men-e-posten-apnet-doren)
— men her var ikke e-postoppsettet feil, det var *forretningslogikken rundt
mottaket* som manglet en sjekk.

**Tiltak:** avsender-hviteliste per tenant (eller eksplisitt godkjenning av
førstegangsavsendere), karantene og tydelig «ekstern avsender»-merking i køen,
og ikke-gjettbare mottaksadresser.

## Funn 3: kundebasen kunne telles opp via org.nr

<Severity level="medium" /> — informasjonslekkasje og uautorisert opprettelse.

Endepunktet for å opprette en tenant tar et organisasjonsnummer. Responsen
skiller pent mellom tre tilstander:

```bash
curl -s -X POST -H "Authorization: Bearer <REDACTED>" \
  -H "Content-Type: application/json" \
  -d '{"organizationNumber":"<org.nr>"}' \
  https://app.kvittringas.no/api/tenants
# 400 {"detail":"tenant already exists for organization number"}   <- finnes allerede = kunde
# 201 {...}                                                        <- ny tenant OPPRETTET (squatting)
# 400 {"detail":"organization number was not found in Brreg"}      <- finnes ikke i registeret
```

Det er et eksistens-orakel. Kombinert med at det ikke fantes noen rate-limiting
noe sted (jeg kjørte 80 samtidige kall uten en eneste `429`), kan en angriper —
selvregistrering er åpen — iterere det norske org.nr-rommet og telle opp *hvem*
som er kunder av plattformen. Verre: den negative grenen *oppretter* en tenant
for hvert selskap som ikke allerede er kunde, altså masse-squatting av vilkårlige
selskapers identiteter.

Her ble menneskelig dømmekraft viktig. Jeg *bekreftet mekanismen* på mitt eget
test-org og et syntetisk nummer, men kjørte den bevisst aldri i skala — å telle
opp en ekte kundebase og strø systemet fullt av squattede tenants ville vært
både destruktivt og utenfor det en representativ PoC trenger. Agenten kan kjøre
fort; en av jobbene mine er å si «langt nok, ikke lenger».

Merk også: dette gir *ikke* tilgang til de opptalte tenantenes data. Isolasjonen
holder fortsatt. Man lærer *hvem* kundene er, ikke hva de har.

**Tiltak:** lik respons uansett om selskapet finnes, krav om dokumentert
fullmakt (Altinn/Brreg-rolle) før en tenant opprettes, og rate-limiting.

## Verktøy og oppsett

Litt om reproduserbarheten, siden det ofte er der en leser stopper opp.

- **Angrepsplan:** agenten parset OpenAPI-spec'en og fanet ut per feature-gruppe
  til en rangert hypoteseliste. Jeg orkestrerte det som en liten
  workflow — flere parallelle analyser som ble syntetisert til én prioritert
  test-plan. Jeg kjørte ikke live-angrep i parallell mot produksjon; den delen
  holdt jeg sekvensiell og ratet den ned.
- **`sqlmap`** (`--level 3 --risk 2`, eksplisitt `--dbms postgresql`): agenten
  satte opp kjøringen med riktig `Authorization`-header og parameter-liste. Null
  injiserbare parametere.
- **`nuclei`** med CVE-, misconfig- og exposure-templates mot `app.kvittringas.no`:
  null høy/kritisk. Nyttig mest som en rask bekreftelse på at det ikke lå noe
  åpenbart og uparchet i kanten.
- **OOB:** en interactsh-collaborator bak en ngrok-tunnel for blind SSRF/XXE.
  Agenten la callback-URLen inn i payloadene; jeg fulgte lytteren.
- **`malicious-pdf`** for PDF-pipelinen. Agenten genererte 69 ondsinnede
  PDF-er (XXE/XFA/SSRF/NTLM-varianter) og lastet dem opp via dokumentmottaket.

```bash
python3 malicious-pdf.py https://<min-collaborator>
# 69 PDF-er generert -> lastet opp via /api/documents
# 0 tilbakekall mot collaborator
```

Den siste er et fint eksempel på et verktøy hvis *fravær av treff* er poenget.
Ingen callback betyr at parseren ikke løser eksterne entiteter eller følger
referanser. Fingeravtrykket forklarte hvorfor: `Producer`-metadataen på
plattformens egne PDF-er avslørte `iText Core 9.6.0` — en fersk, patchet
versjon der samtlige kjente iText-CVE-er (XXE i 5.5.x/7.0.x, Ghostscript-
injeksjonen i 7.1.x) for lengst er lukket. Jeg sjekket de bekreftede
versjonene mot CVE-databasene og fant ingenting utnyttbart; backend-versjonene
var dessuten ikke eksponert over HTTP, som er god herding.

## Hva jeg lærte

- **Isolasjon og autorisasjon er to lag.** Denne plattformen fikk det første
  helt riktig og det andre feil. Test dem separat — «kan A se B?» og «får denne
  legitimasjonen lov til X?» er ulike spørsmål med ulike svar.
- **En nøkkel uten scopet du tester er det beste beviset.** Asymmetrien
  `write` uten `read` skilte reell tilgang fra manglende håndhevelse på ett kall.
- **God DMARC stopper spoofing, ikke injeksjon.** Riktig e-postoppsett sier
  ingenting om hvorvidt mottaket verifiserer at avsenderen har noe der å gjøre.
- **Negative funn hører hjemme i rapporten.** «Holdt på tolv vinkler» er et
  resultat kunden har betalt for.
- **AI i loopen er fart; dømmekraft er bremsen.** Agenten fant orakelet og
  kunne trivielt kjørt det i skala. Å la være — å holde seg til en representativ
  PoC — var min jobb, ikke dens.

## Veien videre

Retest er en del av oppdraget. Det jeg er mest nysgjerrig på til neste runde er
scope-håndhevelsen: den riktige fiksen er et sentralt filter, og den enkleste
måten å verifisere at den faktisk dekker alle 270 operasjonene, er nettopp en
generert kontraktstest som går gjennom hele spec'en. Det er en test AI-en kan
skrive på minutter — og en fin sirkel: samme spec som ga meg angrepsplanen,
blir grunnlaget for regresjonstesten som lukker funnet.
