---
title: "Da cross-tenant-funnet ikke overlevde verifisering"
date: 2026-07-08
description: "Et oppslag så ut som et cross-tenant-brudd i en multi-tenant SaaS. Hele reproduksjonskjeden, de negative kontrollene som avgjorde alvorlighetsgraden, og tre funn jeg forkastet."
tags: ["pentest", "api-security", "multi-tenant", "ai", "security"]
url: https://erik.no/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering
---

<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>

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=<REDACTED>
```

```http
HTTP/1.1 200 OK
Content-Type: application/json

{"status":"success","data":{"id":"1111...","name":"Kari Nordmann",
"email":"<REDACTED>"},"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=<REDACTED>
```

```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 <Severity level="critical" /> 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=<REDACTED>
```

```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=<REDACTED>
```

```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."}
```

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

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 <Severity level="low" />: 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=<REDACTED>

{"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/<Bs objekt>  x-workspace-id: <fremmed ws>   ->  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: <Severity level="low" />. 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 — <Severity level="high" /> 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=<itsdangerous-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 <verify-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 <Severity level="info" />-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 <min-egen-postkasse> \
      --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
```

<Severity level="medium" />, 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=<REDACTED>

{}
```

```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 <form> kan ikke sette den headeren
                                                         -> beskyttet i praksis
```

Tolkningen: angrepsflaten er nettopp «simple requests» — kall uten kropp, med URL-parametere, eller `text/plain`. JSON-kropp- og `DELETE`-ruter slipper unna fordi de utløser en preflight en angripers skjema ikke kan tilfredsstille — men det er en bivirkning av CORS, ikke en bevisst CSRF-forsvar. <Severity level="medium" />. Mitigeringen er å validere `Origin`/`Referer` eller kreve et bundet CSRF-token på alle tilstandsendringer, og helst sette cookien til `SameSite=Lax`.

### E-postverifisering ikke håndhevet ved innlogging

En fersk konto fikk full API-sesjon før adressen var bekreftet.

**Forutsetninger:** ingen.

1. Registrer en ny konto:

```http
POST /v1/auth/register HTTP/1.1
Host: api.snadderas.no
Content-Type: application/json

{"email":"ny@example.org","password":"<REDACTED>","fullName":"Test Testesen"}
```

```http
HTTP/1.1 200 OK
{"status":"success","message":"Registration successful. Please check your email to verify your account."}
```

2. Logg inn uten å verifisere:

```http
POST /v1/auth/login HTTP/1.1
Host: api.snadderas.no
Content-Type: application/json

{"email":"ny@example.org","password":"<REDACTED>"}
```

```http
HTTP/1.1 200 OK
Set-Cookie: access_token=<REDACTED>; HttpOnly; Secure; SameSite=None

{"status":"success","data":{"user":{"email_verified":false},
"workspace":{"id":"..."}}}
```

`email_verified:false` sammen med en gyldig sesjon og et opprettet arbeidsområde er hele funnet. Datalaget krever riktignok verifisering (det er grunnen til at det er <Severity level="medium" /> og ikke høyere), men innlogging og provisjonering skjer før. Mitigeringen er å gate innlogging på `email_verified` — eller å ikke opprette sesjon/arbeidsområde før adressen er bekreftet. Verdt å merke seg: nettopp denne svakheten var det som lot meg skaffe tenant D over — den ene gaten som var åpen hjalp meg forbi en annen.

### Brukeropptelling

Registrering skiller åpent mellom eksisterende og nye adresser:

```http
POST /v1/auth/register  {"email":"finnes@snadderas.no", ...}  -> 400 "Email already registered"
POST /v1/auth/register  {"email":"ny@example.org", ...}       -> 200 "Registration successful"
```

Det samme gjentok seg i to andre orakler: booking-slug-oppslag ga distinkte 404-tekster for en reell versus en ikke-eksisterende bruker, og passord-reset brukte målbart lengre tid for en eksisterende konto (bcrypt + token-generering) enn for en ukjent. <Severity level="low" />. Mitigering: uniforme svar på registrering og reset, og en konstant-tid-sti for eksistens-sjekken.

### Åpen API-dokumentasjon og en for vid CORS

`/openapi.json`, `/docs` og `/redoc` svarte `200` uautentisert på alle tre backendene — inkludert admin-api, som dermed publiserer hele sitt privilegerte rutekart (53 ruter). Det var også det som gjorde recon triviell: agenten trengte ikke gjette endepunkter, den leste dem. <Severity level="medium" /> for admin-flaten spesielt.

CORS på admin-api reflekterte i tillegg bruker-appens opphav med legitimasjon:

```http
Origin: https://app.snadderas.no   ->  Access-Control-Allow-Origin: https://app.snadderas.no
                                        Access-Control-Allow-Credentials: true
```

**Negativ kontroll:** vilkårlige, `null`, suffiks- (`app.snadderas.no.evil.com`) og falske-subdomene-opphav ga _ingen_ `Access-Control-Allow-Origin` — allowlisten er ellers stram. Problemet er at admin-api stoler på den lavere-privilegerte bruker-appens opphav; med en XSS i `app.snadderas.no` kunne det gitt legitimerte kall mot admin-flaten. Mitigering: begrens admin-api til kun admin-domenet, og steng dokumentasjonsendepunktene i produksjon.

### En rask notis om skrivebordsappen

Den offline-reverserte Electron-appen hadde ett forhold verdt å nevne: deep link-handleren for `snadder://auth/callback` tok imot innloggings-tokens uten binding til en state/PKCE-verdi. En nettside offeret besøker kan dermed navigere til `snadder://auth/callback?access_token=…` og tvinge appen inn i en angripers konto — hvorpå offerets møtenotater synkes til angriperen. I tillegg sto flere Electron-«fuses» usikkert (bl.a. `RunAsNode` på og manglende ASAR-integritet), som letter lokal tukling på en allerede kompromittert maskin. <Severity level="medium" /> for deep link-en. Mitigeringen er å binde callbacken til en klientgenerert nonce og levere tokens via en lokal kanal, ikke via URL-en. Dette er statisk analyse, ikke en request/response-PoC, så jeg lar den ligge her og holder detaljene i rapporten.

## Verktøy og hvem som kjørte hva

Jeg prøver å være presis på dette, fordi «jeg testet grundig» ikke sier noe.

- **Endepunkt-kartlegging:** `/openapi.json` lå åpent på alle tre backendene. En agent parset spesifikasjonen (178 ruter på prod, 53 på admin) og flagget ID-baserte oppslag og URL-tagende felter. AI-generert; jeg valgte hva som var verdt å forfølge.
- **API-testing:** rå HTTP med håndsatte `x-workspace-id`-headere for de avgjørende oppslagene, kjørt både manuelt av meg og av agentene. Request/response-parene over er saniterte utdrag av den faktiske trafikken.
- **Mass-assignment:** en agent muterte `PATCH`-body-er systematisk (`role`, `plan`, `isVerified`, `id`, `ownerId`); jeg definerte hvilke felter som var interessante og kjørte de negative kontrollene som avgjorde om noe faktisk låste opp.
- **Knekking:** `hashcat -m 16500` for dev-nøkkelen; en egen `itsdangerous`-cracker (HMAC-SHA1 med salt-avledninger) for `SECRET_KEY`-en — den motsto rockyou på tvers av salt og avledning (negativt funn, men det viktigste negative funnet i testen). Agenten kjørte, jeg tolket.
- **Token-forfalskning:** et lite Python-script som bygde og signerte JWT-er for de tre kontrollene i dev-seksjonen. AI-generert, jeg definerte testmatrisen (egen `sub`, fremmed `sub`, `alg:none`).
- **E-post:** `dig` for DMARC/SPF og `swaks` for selve leverings-PoC-en — den satt jeg på selv, siden den sender noe utover.
- **Skrivebordsapp:** `@electron/asar` for å pakke ut appen og lese hovedprosessen og fuse-konfigurasjonen offline.
- **SSRF:** en `interactsh`-kanal mot alle felt som tok en URL. Null tilbakekall fra målet — et negativt funn jeg tok med fordi fraværet av en henteprimitiv i seg selv er verdt å dokumentere.

AI-en genererte scriptene, kjørte sweepene og foreslo neste steg — inkludert de tre «kritiske» rapportene som viste seg feil. Jeg satte scope og de etiske grensene, tok prioriteringene, skaffet den ekte andre-org-kontoen som avgjorde saken, angrep den ene nøkkelen som faktisk betydde noe, og skrev rapporten. Arbeidsmåten er rask nettopp fordi den er villig til å foreslå ti ting; verdien ligger i å motbevise de ni som ikke holder.

## Hva jeg lærte

- **Én `200` er ikke et funn før en konto som ikke eier objektet får den samme.** Den negative kontrollen _er_ alvorlighetsgraden. Uten den har du bare vist at et endepunkt returnerte `200`.
- **Test tilgangskontroll med en ekte, uavhengig leietaker — også når det er tungvint.** Samme-eier-testen ser identisk ut enten grensen holder eller ikke. Her måtte jeg forbi en plan-gate og en verifiserings-gate for å få en gyldig andre-org-konto; å hoppe over det ville gitt feil konklusjon.
- **Et felt som lar seg sette er ikke en privilegie-eskalering før noe faktisk leser det.** `role=admin` persisterte og betydde ingenting — mass-assignment, ikke privesc. Sjekk alltid _effekten_, ikke bare at skrivingen gikk gjennom.
- **En knekt nøkkel er ikke en overtagelse før tokenet faktisk gir deg tilgang du ikke skulle hatt.** Session-binding på `jti` gjorde en triviell dev-nøkkel til et hygiene-funn, ikke et brudd.
- **Angrip eksplisitt det ene som _ville_ brutt isolasjonen.** Her var det join-link-signeringen. At nøkkelen holdt er en del av konklusjonen — men bare fordi jeg forsøkte å knekke den, ikke antok at den var trygg.
- **«Isolasjonen holdt» er et reelt leveranse-resultat.** Men det er bare troverdig hvis du kan vise at du seriøst prøvde å bryte den — og var villig til å nedgradere dine tre beste funn da de ikke holdt.
