# 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
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. . 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":"","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":""} ``` ```http HTTP/1.1 200 OK Set-Cookie: access_token=; 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 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. . 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. 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. 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.