# Da cross-tenant-funnet ikke overlevde verifisering
URL: https://erik.no/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering
Publisert: 2026-07-08 | Tags: pentest, api-security, multi-tenant, ai, security
> 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.
Det mest interessante funnet i denne testen er et jeg endte med å forkaste. Det så ut som et lærebok-BOLA på tvers av leietakere — et endepunkt som svarte `200` der jeg forventet `403`. Så testet jeg det med en konto jeg ikke eide objektet fra, og det kollapset. Dette innlegget er hele undersøkelsen: de eksakte reproduksjonskjedene, de negative kontrollene som bestemte alvorlighetsgraden, tre «vinner» som ikke overlevde første ærlige forsøk på å bruke dem, og den ene veien som faktisk _kunne_ brutt isolasjonen — og hvorfor den ikke gjorde det.
## Oppsettet
Snadder AS (`snadderas.no`) lager en fleir-leietaker SaaS for møtenotater og lettvekts-CRM. Frontenden `app.snadderas.no` er en Vite-SPA som ikke gjør annet enn å snakke med et FastAPI-backend på `api.snadderas.no`. Leietaker-avgrensning skjer med en header, `x-workspace-id`, som klienten sender på hvert kall; autentisering er en httpOnly-cookie med en JWT. I tillegg finnes `admin-api.snadderas.no` (egen admin-realm med egen innlogging), `dev-api.snadderas.no` og en Electron-skrivebordsapp.
Scope var hele `*.snadderas.no` pluss offline reversering av skrivebordsappen. Produktsjefen hos Snadder AS, Jonas Berg (`jonas.berg@snadderas.no`), formulerte målet enkelt: kan én kunde nå en annen kundes data?
Jeg kjører denne typen oppdrag med AI i loopen. I praksis lar jeg spesialiserte agenter gjøre grovarbeidet — recon, endepunkt-kartlegging, å prøve angrepsvektorer, lese logger og foreslå neste steg — mens jeg styrer retningen, setter scope og etikk, bryter inn der det trengs, og tar de vurderingene som krever menneskelig dømmekraft. Det er verdt å si med en gang: den arbeidsmåten produserer plausible funn fort. Den produserer plausible _feil_ like fort. Denne testen ga meg tre av dem.
Første steg var billig: alle tre backendene eksponerte `/openapi.json` uåpnet. Jeg lot en agent parse spesifikasjonen (178 ruter på prod) og flagge alt som tok en `id` i stien eller en URL i body. Person- og selskaps-endepunktene — `GET/PATCH/DELETE /v1/person/{id}` og `/v1/company/{id}` — havnet øverst på lista, fordi de henter på global UUID.
## Funnet som så for godt ut
Jeg logget inn som konto A, som hadde en aktiv prøveperiode og dermed tilgang til CRM-objektene. Jeg opprettet en `person` i arbeidsområde A1, og prøvde så å hente den per ID — men med headeren satt til et _annet_ arbeidsområde, A2.
**Forutsetninger:** innlogget som eier av konto A (rolle `member`, prøveperiode aktiv), gyldig httpOnly-sesjon. A er medlem av både A1 og A2. Objektet ligger i A1.
1. Opprett en `person` i A1 og noter UUID-en.
2. Hent den per ID, men med `x-workspace-id` satt til A2:
```http
GET /v1/person/11111111-1111-4111-8111-111111111111 HTTP/1.1
Host: api.snadderas.no
x-workspace-id: aaaa2222-2222-4222-8222-222222222222
Cookie: access_token=
```
```http
HTTP/1.1 200 OK
Content-Type: application/json
{"status":"success","data":{"id":"1111...","name":"Kari Nordmann",
"email":""},"meta":{"workspaceId":"aaaa1111-..."}}
```
3. Samtidig skjuler list-endepunktet objektet helt korrekt for A2:
```http
GET /v1/person HTTP/1.1
Host: api.snadderas.no
x-workspace-id: aaaa2222-2222-4222-8222-222222222222
Cookie: access_token=
```
```http
HTTP/1.1 200 OK
{"status":"success","data":{"items":[],"nextCursor":null}}
```
En av agentene jeg kjørte parallelt plukket opp nøyaktig dette og rapporterte det som «sannsynlig kritisk BOLA». Resonnementet var greit på papiret: list-visningen filtrerer på arbeidsområdet i headeren, men oppslag-per-ID gjør det ikke — objektet hentes på global UUID, og serveren ser bare ut til å sjekke at kalleren er medlem av arbeidsområdet i headeren, ikke at objektet faktisk hører til der. Det er den klassiske signaturen på broken object level authorization: to kodestier som er uenige om hvem som eier hva.
Det hadde vært lett å skrive det ned som og gå videre. Jeg lot være — fordi steg 2 og 3 alene ikke skiller mellom to helt forskjellige forklaringer.
## Fellen: samme eier, to arbeidsområder
Detaljen som avgjorde alt: A1 og A2 var _begge mine egne_ arbeidsområder, under samme konto og samme organisasjon. Jeg testet ikke isolasjon mellom to kunder — jeg testet om jeg fikk lese mitt eget objekt gjennom en annen av mine egne arbeidsområde-headere.
Da jeg leste koden nøye, ga `200`-en mening uten at noen grense var brutt. Tilgangssjekken på oppslag-per-ID er en _eierskaps_-sjekk, ikke en arbeidsområde-sjekk. Jeg fikk objektet fordi jeg eide det; header-arbeidsområdet var irrelevant for eierskapskontrollen. Det eneste reelle avviket var at oppslag-per-ID og list-visningen tolker headeren ulikt for _egne_ objekter — en inkonsistens, ikke et brudd.
Forskjellen mellom «inkonsistens» og «cross-tenant-brudd» er hele funnet. Og den forskjellen kan man ikke lese seg til fra en `200`. Man må sende den samme forespørselen fra en konto som _ikke_ eier objektet — helst i en helt annen organisasjon. Det er den negative kontrollen §6 handler om, og her var den ikke triviell å skaffe.
## Den negative kontrollen som avgjorde alt
Jeg trengte en konto i en annen organisasjon, verifisert og med prøveperiode, slik at den nådde de samme kodestiene som A. Det var to porter i veien, og begge er verdt å vise, fordi de er grunnen til at agenten aldri kom frem til svaret på egen hånd.
Først prøvde jeg en gratis andre-konto. Den ble stoppet av en plan-gate _før_ objekt-oppslaget i det hele tatt ble evaluert:
```http
GET /v1/person/11111111-1111-4111-8111-111111111111 HTTP/1.1
Host: api.snadderas.no
x-workspace-id: bbbb0000-0000-4000-8000-0000000000b0
Cookie: access_token=
```
```http
HTTP/1.1 403 Forbidden
{"status":"error","message":"This feature requires the trial plan or higher."}
```
En nonexistent UUID (`00000000-…`) ga _samme_ 403 — bevis for at plan-gaten ligger foran oppslaget, og at en gratis konto aldri når autorisasjonslaget. Så prøvde jeg en fersk konto på et fremmed domene for å få prøveperiode, men da slo verifiserings-gaten inn:
```http
HTTP/1.1 403 Forbidden
{"status":"error","message":"Email verification required"}
```
Fellen var altså: relaterte e-poster (samme domene) fikk ikke ny prøveperiode, og urelaterte e-poster kunne jeg ikke verifisere fordi jeg ikke leste innboksen. Jeg løste det ved å registrere kontoen på et domene jeg selv kontrollerte, verifisere den via lenken jeg da mottok, og starte prøveperioden. Nå hadde jeg tenant D: egen organisasjon, verifisert, med prøveperiode — en ekte uavhengig leietaker.
Så kom den avgjørende forespørselen. Tenant D, mot As objekt-UUID, med Ds _egen_ arbeidsområde-header:
```http
GET /v1/person/11111111-1111-4111-8111-111111111111 HTTP/1.1
Host: api.snadderas.no
x-workspace-id: dddd0000-0000-4000-8000-0000000000d0
Cookie: access_token=
```
```http
HTTP/1.1 403 Forbidden
{"status":"error","message":"Only the resource owner can perform this action."}
```
`PATCH` og `DELETE` på samme UUID ga samme 403. Og en siste kontroll — D som forsøkte å bruke As arbeidsområde direkte — ga en _annen_ feilmelding, som bekreftet at det var to distinkte sjekker:
```http
HTTP/1.1 403 Forbidden
{"status":"error","message":"Not a member of this workspace."}
```
Isolasjonen holdt. `200`-en i det opprinnelige funnet var eierskap, ikke
tenant-lekkasje. En annen leietaker kan verken lese eller endre en annens
objekter — verken via header-triks eller direkte oppslag på UUID.
Tolkningen, linje for linje: den opprinnelige `200`-en kom fordi A eide objektet (eierskapssjekken passerte); header-arbeidsområdet påvirket bare list-filteret. Da en _annen_ eier ba om det samme, slo eierskapssjekken inn (`Only the resource owner`). Og da D forsøkte å påstå medlemskap i et arbeidsområde D ikke var med i, slo medlemskapssjekken inn (`Not a member`). Det finnes altså en fjerde sjekk — eierskap på objektet — som agenten aldri nådde, fordi den aldri kom forbi plan- og verifiserings-portene med en ekte andre-org-konto.
Funnet ble nedgradert fra «sannsynlig kritisk» til : oppslag-per-ID bør avgrenses til det oppgitte arbeidsområdet, så det stemmer med list-visningen. Mitigeringen er å legge til arbeidsområde-predikatet i selve spørringen og svare `404` ved avvik (ikke `403`, for å ikke bekrefte at UUID-en finnes i en annen kontekst):
```diff
-person = db.query(Person).filter(Person.id == person_id).first()
+person = db.query(Person).filter(
+ Person.id == person_id,
+ Person.workspace_id == request_workspace_id,
+).first()
if person is None:
raise HTTPException(status_code=404, detail="Person does not exist")
```
For to uker siden hadde jeg det motsatte utfallet på et lignende oppdrag, der [isolasjonen holdt men autorisasjonen sprakk](/blogg/2026-07-07-isolasjon-holdt-autorisasjon-sprakk). Samme klasse mekanisme, motsatt konklusjon. Den gangen fant jeg en reell grense som var borte; denne gangen så det _likere_ ut, men var det ikke. Det er nettopp derfor samme-eier-testen aldri kan stå alene — den ser identisk ut i begge tilfeller helt til du legger til en fremmed eier.
## Den tredje falske positiven: `role=admin` som ikke ga noe
Mens jeg var i profil-endepunktet, prøvde en agent en klassisk mass-assignment: send felter du ikke burde få sette. `PATCH /v1/user/me` godtok et klient-satt `role`-felt, og det _persisterte_.
**Forutsetninger:** innlogget som konto A.
1. Baseline — mitt eget `role` er tomt:
```http
GET /v1/user/me -> 200 {"data":{"role":null, ...}}
```
2. Sett `role` til `admin` via profiloppdatering:
```http
PATCH /v1/user/me HTTP/1.1
Host: api.snadderas.no
Content-Type: application/json
Cookie: access_token=
{"role":"admin","isVerified":true,"plan":"enterprise"}
```
```http
HTTP/1.1 200 OK
{"status":"success","data":{"role":"admin", ...},"message":"Profile updated successfully"}
```
Et nytt oppslag bekreftet at `role:"admin"` overlevde, også på tvers av en ny innlogging. På dette punktet ropte agenten «privilege escalation». Igjen: verdt et sekunds skepsis, ikke en rapportlinje.
3. **Negativ kontroll — gir `role=admin` faktisk noe?** Jeg gjorde nettopp de tingene en ekte admin-rolle skulle låst opp:
```text
GET /v1/person/ x-workspace-id: -> 403 "Not a member" (ingen bypass)
GET /v1/admin/users (på api.snadderas.no) -> 404 Not Found (ingen admin-ruter her)
```
Tolkningen: `role` er et profilfelt (tenk «tittel»), ikke en autorisasjonsrolle. Ingenting i tilgangslaget leser det. `plan` og `isVerified` i samme forespørsel ble dessuten ignorert — bare `role` ble ekko-et tilbake, og bare kosmetisk. Admin-flaten lever på `admin-api.snadderas.no` med en egen innlogging som avviser bruker-cookien helt. Dette er mass-assignment (OWASP API3), men uten privilegie-effekt: . Jeg satte feltet tilbake til `null` etterpå.
Mitigeringen er å ikke la server-styrte felter være skrivbare — fjern `role` fra input-modellen i stedet for å stole på at ingenting leser det:
```diff
class UserProfileUpdate(BaseModel):
name: str | None = None
bio: str | None = None
- role: str | None = None # server-styrt — skal ikke kunne settes av klienten
nickname: str | None = None
```
Tre funn inn, tre «kritiske» som ikke var det. Mønsteret er ikke tilfeldig: en agent som prøver alt, treffer mange ting som _ser ut_ som brudd, fordi den signaturen er lett å produsere og vanskelig å skille fra det ekte uten en negativ kontroll.
## Andre runde: en knekt dev-nøkkel som ikke ble en overtagelse
Da jeg reverserte skrivebordsappen offline dukket `dev-api.snadderas.no` opp, og en agent la merke til at dev — i motsetning til prod — returnerte access-tokenet direkte i responskroppen fra `/v1/auth/refresh`. Jeg fikk agenten til å kjøre det tokenet gjennom hashcat.
**Forutsetninger:** en registrert konto på `dev-api.snadderas.no` (for å få et gyldig token å analysere), `hashcat v6.2.6`, `rockyou.txt`.
```bash
# tokenet i dev-jwt.txt er en HS256-JWT (mode 16500)
hashcat -m 16500 dev-jwt.txt rockyou.txt -r rules/best64.rule
```
```text
Status...........: Cracked
# signeringsnøkkelen var en triviell ordbokstreng — jeg gjengir den ikke
```
Nøkkelen falt umiddelbart. En plassholder-hemmelighet i et levende miljø er et reelt funn — for dev, om enn isolert fra produksjon. Den fristende neste konklusjonen: med signeringsnøkkelen kan jeg forfalske hvilken som helst brukers token, altså kontoovertagelse.
Jeg testet det i stedet for å anta det. Jeg registrerte to dev-kontoer (angriper og «offer»), verifiserte begge, hentet et gyldig token fra hver, og forfalsket så et token med offerets `sub`, men angriperens egen `jti`, signert med den knekte nøkkelen.
1. Kontroll — forfalsk mitt _eget_ token med forlenget levetid:
```text
forged {sub: angriper, jti: angriper, exp: +10år} signert med dev-nøkkel
GET /v1/user/me -> 200 OK ("Dev PoC 1")
```
2. **Negativ kontroll** — bytt `sub` til offeret, behold min egen gyldige `jti`:
```text
forged {sub: offer, jti: angriper, exp: +10år} signert med dev-nøkkel
GET /v1/user/me -> 401 Unauthorized {"message":"Invalid token"}
```
3. Ytterligere kontroller — oppdiktet `jti` og `alg:none`:
```text
forged {sub: offer, jti: 00000000-...} -> 401 Invalid token
forged {alg: none, sub: offer} -> 401 Invalid token
```
Tolkningen: serveren binder `jti` til sesjonen og brukeren. Jeg kunne forfalske og forlenge _mine egne_ tokens (steg 1), men ikke bytte ut `sub` og bli noen andre (steg 2). Session-bindingen gjorde en svak nøkkel til et hygiene-funn, ikke et brudd. Jeg testet også om dev droppet eierskapssjekken fra forrige seksjon — `D2 → D1s notat via UUID` ga `404`, samme som prod. Prod-api brukte dessuten en helt annen nøkkel, som motsto både rockyou med regler og masker på et skikkelig GPU-kjør.
Mitigeringen er kjedelig og viktig: rullér nøkkelen, hent den fra en hemmelighetstjeneste, og forby plassholder-hemmeligheter i CI. Vurder asymmetrisk signering med `aud`/`iss` så en lekket nøkkel ikke kan gjenbrukes på tvers av tjenester:
```diff
-JWT_SECRET = os.getenv("JWT_SECRET", "changeme-dev")
+JWT_PRIVATE_KEY = load_secret("jwt-signing-key") # RS256, fra secrets manager
+JWT_AUDIENCE = "snadder-api"
```
## Den ene veien som faktisk kunne brutt isolasjonen
Her er det ærlige forbeholdet til «isolasjonen holdt»: det fantes én mekanisme som _ville_ omgått eierskapssjekken helt, og den er verdt å beskrive fordi den er grunnen til at hele konklusjonen hviler på én hemmelighet.
Appen bruker `itsdangerous` (Flask-stil signerte tokens) til e-postverifisering, passord-reset — og til **arbeidsområde-invitasjoner**. En join-link-token dekoder til noe sånt som `{type:"workspace_join_link", workspace_id, nonce}`, og valideringsendepunktet tar imot den uautentisert:
```http
GET /v1/workspaces/invites/join-link/validate?token= HTTP/1.1
Host: api.snadderas.no
```
Logikken er åpenbar når man ser den: hvis jeg kunne _forfalske_ en join-link for et vilkårlig `workspace_id`, ville jeg blitt medlem av et fremmed arbeidsområde — og da spiller eierskapssjekken ingen rolle, for da _er_ jeg innenfor. Det ville vært det reelle cross-tenant-bruddet. Alt henger på om `SECRET_KEY`-en som signerer disse tokenene er svak.
Så jeg angrep den. `itsdangerous` bruker HMAC-SHA1 med en salt-avledet nøkkel, ikke en ren hashcat-modus, så en agent skrev en liten dedikert cracker:
```bash
python3 itsd_crack.py --token --wordlist rockyou.txt \
--salts itsdangerous,email-verify,password-reset,verify,activate,snadder \
--deriv django-concat,concat,hmac,none
```
```text
[-] no hit
# rockyou (14,3M) × 18 salt-varianter × 4 nøkkel-avledninger — 0 treff
```
Nøkkelen motsto det hele. Den samme nøkkelen signerer også prod sine JWT-er, som falt like lite på et fullt GPU-kjør. Med andre ord: den ene døra som ville brutt isolasjonen var låst med en sterk, tilfeldig nøkkel — så nær-bommen forble en nær-bom.
Jeg tok det med som et -forhold likevel, fordi det er en _designavhengighet_ verdt å skrive ned: en symmetrisk `SECRET_KEY` uten en rotasjons- eller splitting-historie betyr at én lekkasje (env-variabel, logg, repo) gjenåpner nøyaktig denne veien. Anbefalingen ble en hemmelighets-skann av CI/infra og en plan for nøkkelrotasjon — ikke fordi jeg kom inn, men fordi marginen er tynnere enn den ser ut.
## Det som faktisk holdt vann
Testen var ikke tørr av den grunn. De reelle funnene lå i periferien, ikke i datagrensen.
### E-postforfalskning
Det mest konkrete. Domenet manglet SPF helt, og DMARC sto på `p=none`.
**Forutsetninger:** ingen — uautentisert DNS-oppslag, og en levering til en postkasse jeg selv kontrollerte, etter avtale med kunden.
1. Sjekk publisert e-postpolicy:
```bash
dig +short TXT _dmarc.snadderas.no
# "v=DMARC1; p=none; sp=none; rua=mailto:..."
dig +short TXT snadderas.no | grep -i spf
# (tomt — ingen SPF-post)
```
2. Lever en forfalsket melding med `swaks`, fra en `@snadderas.no`-avsender, til min egen postkasse:
```bash
swaks --to \
--from security-alert@snadderas.no \
--header "Subject: [autorisert test] spoofing-PoC"
```
```text
<- 250 2.6.0 ... Queued mail for delivery
```
3. **Kontroll / tolkning:** `p=none` betyr «overvåk, men ikke håndhev». Mottakende tjener aksepterte derfor forfalskningen (`250`) i stedet for å avvise den, og meldingen havnet i innboksen. Uten SPF finnes det heller ingen avsenderliste å feile mot. Det er ikke en tjener-bug — det er domenets egen policy som sier «slipp gjennom».
Mitigeringen er tre DNS-records og en gradvis innstramming (`none` → `quarantine` → `reject`) så man ser rapportene før man håndhever:
```text
snadderas.no. TXT "v=spf1 include:_spf.google.com -all"
_dmarc.snadderas.no. TXT "v=DMARC1; p=reject; sp=reject; adkim=s; aspf=s; rua=mailto:dmarc@snadderas.no"
# + DKIM-signering aktivert hos e-postleverandøren
```
, og et tiltak kunden eier fullt ut. Det samme mønsteret — appen holder, men e-posten åpner døren — har jeg skrevet om [før](/blogg/2026-06-06-viktigste-funnet-var-ikke-det-kunden-ba-om), og det gjentar seg oftere enn de fleste tror.
### CSRF på tilstandsendrende kall
Sesjonskaken er `SameSite=None`, som betyr at nettleseren sender den på kryss av nettsteder. Kombinert med at enkelte tilstandsendrende kall ikke sjekker `Origin` eller et CSRF-token, blir de angripbare fra en fremmed side.
**Forutsetninger:** et offer med en aktiv innlogget nettleser-sesjon.
1. En «simple request»-POST behandles uten Origin-/CSRF-kontroll:
```http
POST /v1/billing/start-trial HTTP/1.1
Host: api.snadderas.no
Origin: https://evil.example
Content-Type: text/plain
Cookie: access_token=
{}
```
```http
HTTP/1.1 200 OK
{"status":"success","message":"Trial ..."} # behandlet — ingen Origin/CSRF-avvisning
```
Kallet ble behandlet identisk med `Origin: https://evil.example`, uten `Origin`, og med `Content-Type: text/plain`.
2. **Negativ kontroll** — JSON-endepunkter er tilfeldig beskyttet, ikke av design:
```text
POST /v1/notes/new (Content-Type: application/json) -> krever CORS-preflight
-> en fremmed