---
title: "«Alt er fikset», sa de: full retest av en MSP-portal"
date: 2026-07-11
description: "Oppfølger til container-pentesten: Poly sa alt var fikset. ~20 av 22 funn sto igjen — men kjernen var hardnet, og AI-svermen fant ingen vei inn til kronjuvelene."
tags: ["pentest", "azure", "container-apps", "ai", "security"]
url: https://erik.no/blogg/2026-07-11-alt-er-fikset-sa-de-retest-av-en-msp-portal
---

<Callout type="info">
  **FIKTIVT:** Alle firmanavn, personnavn, domener, e-postadresser, IP-adresser og
  identifikatorer i dette innlegget er oppdiktet eller sanert. Eventuell likhet med
  ekte virksomheter eller personer er tilfeldig.
</Callout>

For en stund tilbake skrev jeg [notatene fra en ekstern pentest av en Azure Container Apps-stack](/blogg/2026-05-28-notater-fra-en-ekstern-pentest-av-en-azure-container-apps-stack) — WAF-bypass via ACA sin default-FQDN, over-brede OIDC-scopes, DMARC `p=none`, og et «egenutviklet» CRM som viste seg å være en upatchet fork. Det var **Poly AS** (`polyas.no`). Siden har de rebrandet, bygget om, og fortalt kunden sin at «alle tidligere funn er fikset». Så jeg fikk komme tilbake og sjekke.

To ting hadde endret seg. Poly har gått fra én SaaS til å drive en **MSP-portal** — en governance-plattform som administrerer Azure på vegne av mange kundetenanter. Og jeg kjørte det med en helt annen motor: en AI-agent-sverm, bølge etter bølge, med adversariell verifisering.

Konklusjonen er todelt, og det er hele poenget med et retest:

1. **«Alt er fikset» er ikke sant.** Rundt 20 av 22 teknisk-verifiserbare funn fra forrige gang står fortsatt.
2. **Men kjernen er genuint hardnet.** Etter åtte runder med sverm fant jeg ingen uautentisert vei inn til kronjuvelene — kundedata, M365-tenanten, Azure. Hver sti dit er nå enten passord- eller operatør-gated.

Dette blir et langt innlegg. Jeg tar hele funn-katalogen — **29 distinkte funn (1 kritisk, 11 høye, 9 medium, 8 lave)** pluss et helt lag med positive kontroller — organisert per ressurs, med PoC og negativ kontroll for hvert. Hvis du bare vil ha metoden og de store linjene, hopp til «Hva jeg lærte» til slutt.

## Kontekst — arkitekturen og motoren

Estatet er nå en flerdomene-greie: en marketing-front, en governance-portal (`portal.polyas.no`) med en ASP.NET-backend, den samme CRM-forken fra sist, en Nextcloud, en monitor-hub, og et knippe workforce-apper. Portalen er kjernen: den er en MSP-kontrollflate som holder Azure-tilgang inn i hver eneste kundetenant.

Metodikk-notatet fra sist gjelder fortsatt — solo, men ikke manuelt — bare skrudd opp. Der jeg før lot én agent kjøre recon og plukket ut treffene, kjørte jeg denne gangen **multi-agent-workflows**: jeg dekomponerte oppdraget i angrepsklasser, fyrte av mange spesialist-agenter i parallell (én per mål × sårbarhetsklasse), og lot hvert kandidatfunn gå gjennom et eget **refute-steg** der 2–3 uavhengige skeptiker-agenter fikk i oppgave å *motbevise* det. Jeg styrte retningen, leste resultatene, tok scope- og severity-vurderingene, og skrev rapporten.

<Callout type="warning">
  20-agent-svermene krasjet prosessen tre ganger. Tunge agenter som laster ned
  flermegabyte-bundles og kjører mange verktøy samtidig sluker minne. Løsningen
  ble å holde hver bølge på 6–8 agenter, og å salvere en krasjet kjøring fra
  kjøre-journalen (som har hver agents returverdi) i stedet for å miste den.
  Automatisering er en kvalitet — men en sverm er en maskin som må dimensjoneres,
  ikke en magisk knapp.
</Callout>

## AI-svermen, og de 4789 «funnene» som ikke var funn

Første bølge inkluderte en autonom scanner-agent. Den kom tilbake med **4789 funn** på ett av målene: 4714 `info`, en håndfull `medium`, og tre `CRITICAL`. De tre kritiske var «Server-side template injection» på `/api/search`, `/api/feedback` og `/rest/track-order/{id}`.

Det er akkurat her en agent i loopen er farligst: den produserer plausible funn fort — og plausible *feil* like fort. `/rest/track-order` er en rute fra OWASP Juice Shop. Scanneren hadde matchet responser mot referanse-app-mønstre og ropt «SSTI» på det den så. Jobben min er ikke å tro på den. Jobben er å verifisere.

```http
GET /api/search?q=test HTTP/1.1
Host: polyas.no

HTTP/1.1 404 Not Found
x-matched-path: /[locale]/[...rest]
x-powered-by: Next.js
<!DOCTYPE html><html id="__next_error__">...   # Next.js catch-all 404

# /rest/track-order/1  -> 307 location: /no/rest/track-order/1   (i18n-middleware, bare språk-prefiks)
# Negativ kontroll: /zzz-finnes-ikke-42 -> 307 location: /no/zzz-finnes-ikke-42  (samme svar som «SSTI»-ruta)
```

Ingen template rendres noe sted. Det finnes ingen SSTI fordi det ikke finnes noe endepunkt — scanneren hallusinerte kritikaliteten på en soft-404. Jeg lot et lite skript merke alle tre som `false_positive`, sammen med noen hundre «Discovered path: /admin, /backup-db …» som var samme mønster: en Azure Static Web App og en Next.js-app som svarer `200`/`307` på *alt*. <Severity level="info" /> — 4389 auto-funn forkastet.

<Callout type="info">
  Dette er hele grunnen til refute-steget. En sverm som får jobbe fritt vil
  rapportere tusenvis av plausible ting. Verdien ligger ikke i funnene den
  genererer, men i verifiseringen som skiller de ekte fra de hallusinerte.
  «Verifiser mot innhold, ikke mot statuskode» ble et mantra: en Azure SWA som
  svarer `200` med `index.html` på hver eneste sti gjør at *hvert* naivt
  `.git/HEAD`- eller `appsettings.json`-treff blir en false positive helt til du
  ser på *body*.
</Callout>

## Hva de faktisk fikset — og hva de ikke fikset

Kundens påstand var «alt er fikset». Så jeg kjørte hvert eneste funn fra forrige rapport på nytt.

<Callout type="success">
  **Det de faktisk fikset (fortjent kreditt):** fire inaktive ex-ansatt-kontoer
  fjernet · overvåkings-stacken (Traefik/monitoring) brannmuret bort · Grafana
  patchet forbi CVE-2025-4123 · e-post/passord-selvregistrering i CRM-et skrudd av ·
  en Scaleway-hostet admin- og chat-tjeneste firewallet · marketing-siten migrert
  (gammel `.no` er nå bare en redirect-stub).
</Callout>

Men **rundt 20 av 22 teknisk-verifiserbare funn fra forrige gang sto fortsatt urørt**: ACA-appene nås fortsatt direkte på sin rå `*.azurecontainerapps.io`-FQDN, workforce-OIDC-appen ber fortsatt om altfor brede Graph-scopes, DMARC står fortsatt på `p=none`, CSP-headeren mangler fortsatt, helse-endepunkter lekker fortsatt topologi, de danglende CNAME-ene er der fortsatt, og Web3Forms-nøkkelen er fortsatt gjenbrukbar. «Fikset» betydde «vi bygget noe nytt og hardere ved siden av», ikke «vi lukket det du fant».

Resten av innlegget er katalogen.

## Funn-katalogen

### Bruddet: et hardkodet AI-token

Marketing-siten (`polyas.no`, Next.js) shipper et **server-API-Bearer-token hardkodet i en offentlig, world-readable JS-chunk**. Det tokenet er *eneste* auth foran AI-backend-rutene `POST /api/ai/generate` og `POST /api/ai/image`. Hvem som helst som leser sidekilden henter det ut og kaller endepunktene som en autentisert klient.

```bash
# 1. tokenet ligger i den offentlige chunken
curl -s https://polyas.no/_next/static/chunks/<chunk>.js | grep -oE 'Bearer [0-9a-f]{48}'

# 2. uten token               -> 401 {"message":"Unauthorized"}
# 3. feil token               -> 401 {"message":"Unauthorized"}
# 4. LEKKET token + tom body  -> 400 zod "Invalid discriminator value. Expected 'blogPost'"   (FORBI AUTH)
# 5. LEKKET token + gyldig body -> 200, full LLM-generert dokument   (kjørt én gang, autorisert)
```

`401 → 400`-flippen på den ene variabelen (tokenet) er selve auth-bypass-beviset: samme request som avvises uten token slipper forbi auth med det lekkede tokenet, og feiler først på body-validering.

**Impact + tak.** Gratis, uautentisert bruk av et betalt LLM-backend (OpenRouter → en Claude-modell): budsjett/kvote-misbruk, cost-DoS, og vilkårlig innholdsgenerering på et kundevendt domene (~1900 completion-tokens per kall, ingen rate-limit på endepunktet). Jeg presset foten hardt for eskalering, og **alt falt** — derfor er dette høyt, ikke kritisk:

- **Prompt-injection for system-prompt/secrets → NEGATIVT.** To forsøk; modellen nektet begge og genererte meta-innhold som avviser injeksjonen.
- **SSRF via `research`-featuren → DEFINITIVT NEGATIVT.** Testet med en egen `ngrok`-callback-server referert både i prompten og i `research.urls` → **null treff** (bare min egen self-test traff). Ingen angriper-kontrollerbar utgående request → ingen sky-metadata, ingen intern pivot.
- **Token-gjenbruk → SCOPET.** Virker *kun* på `/api/ai/*`; avvist (`401`) mot CRM, portal, hub og MCP-gateway, og er ikke et gyldig Sanity-token.

<Severity level="high" /> — ekte uautentisert tilgang til et betalt backend, men **bevist avgrenset** til LLM-misbruk. Fiks: revoker tokenet umiddelbart, proxy `/api/ai/*` serverside med per-bruker-auth + spend-cap.

### Kjeden mot M365-tenanten

Fra forrige post: total e-post-spoofbarhet + over-brede OIDC-scopes = en troverdig samtykke-phish. Den kjeden er fortsatt hel, ledd for ledd — og *det* er retestens farligste funn, selv om ingen enkeltledd er en exploit.

**E-post-spoofing (tre domener).** `polyas.no` står på DMARC `p=none/sp=none` (monitor-only, tross SPF `-all`/DKIM), og de to nyere brand-domenene har verken SPF, DMARC eller DKIM. Spoofet e-post ble akseptert for levering av Microsoft EOP (PoC til min egen testinnboks). <Severity level="medium" /> per domene.

**Brukerenumerering.** Entra-tenanten svarer på `GetCredentialType` uten throttling:

```bash
curl -s https://login.microsoftonline.com/common/GetCredentialType \
  -H 'Content-Type: application/json' -d '{"Username":"<bruker>@polyas.no"}' \
  | grep -o '"IfExistsResult":[0-9]'
# 0 = kontoen finnes ; 1 = fraværende   (ingen auth, ingen throttle)
```

En enkeltskudd-per-e-post-sveip (~1,2 s mellomrom) bekreftet **18 gyldige postbokser** uten throttling — en komplett bemanningsliste, pre-auth. <Severity level="medium" />

**B2B-gjestebro.** Tre `.no`-ansatte finnes som eksterne B2B-gjester i den tynne `.io`-app-tenanten (samme `IfExistsResult=0`-orakel mot `<bruker>_polyas.no#EXT#@polyas-io.onmicrosoft.com`). En ATO av en spoofbar `.no`-identitet rekker dermed inn i `.io`. <Severity level="medium" />

**Nyttelasten.** Workforce-appen ber om delegerte Graph-scopes langt utover behov: `Mail.ReadWrite`, `Mail.Send`, `Files.ReadWrite.All`, `Sites.ReadWrite.All`, `offline_access`. Ett samtykke = tenant-bred lese/skrive-tilgang til postbokser og SharePoint/OneDrive. Portal-appen ber i tillegg om Azure ARM (`management.azure.com/user_impersonation`). <Severity level="high" />

**Tenneren: device-code.** Det nye denne gangen — jeg bekreftet at device-code-flyten er skrudd på for begge de høyt-scopede appene. Det gjør samtykke-phishen MFA-forbigående:

```http
POST /organizations/oauth2/v2.0/devicecode
client_id=<workforce-app>&scope=<app>/.default offline_access

HTTP/1.1 200 OK
{"user_code":"<REDACTED>","verification_uri":"https://microsoft.com/devicecode",
 "expires_in":900,"interval":5, ...}
```

Endepunktet utsteder et levende `user_code`. Offeret logger inn på den *ekte* `microsoft.com/devicecode`, og angriperen holder tokenet pollingen returnerer. **Negativ kontroll / ærlighet:** her stoppet jeg. Ingen bruker ble phishet, ingen kode tastet, intet token fanget — leddet er operatør-gated. De fire kontoene kunden ga meg ble bekreftet gyldige og Managed, men verken sprayet eller phishet. Jeg beviste at *vektoren er åpen*, ikke at jeg gikk gjennom den.

<Callout type="danger">
  Den ærlige overskriften på hele retesten: den mest realistiske veien til full
  M365- og Azure-kompromittering er ikke en exploit — det er en samtykke-phish mot
  over-scopede apper med device-code på. Den kjeden flagget jeg forrige gang, og
  den er fortsatt åpen. Fiks: DMARC `p=reject`, minste-privilegium på Graph-scopene
  (dropp `*.ReadWrite.All`), samtykke-styring, og skru av device-code der den ikke
  trengs.
</Callout>

For ordens skyld: kunden kjører faktisk **tre distinkte Entra-tenanter** — `polyas.no` (primær M365), `polyas.io` (tynn app/gjeste-tenant) og `polyas.com` (egen NA-tenant) — mens `polyas.ai` ikke er et forvaltet identitetsdomene i det hele tatt (`GetCredentialType → DomainType=1`, unmanaged). Kartet er `info`-nivå intel, men det er ryggraden i cross-tenant-historien. <Severity level="info" />

### MSP-portalen: der kronjuvelen er kunde-hemmelighetene

Portalen er arkitektonisk den mest interessante delen. Jeg dumpet hele SPA-en og lot en agent lese den mens jeg pekte på det som betød noe. Forretningsmodellen ligger i klartekst: for hver kunde konsenter kunden Poly sin `TenantReader`-app til `Reader` + `Cost Management Reader` i sin egen Azure-tenant, og Poly lagrer per-kunde `tenantReaderClientId` **og `tenantReaderClientSecret`** (i Key Vault). Rollene er `Read < Admin < SuperAdmin`, og `SuperAdmin` styrer hele kunde-registeret.

**Den som får `SuperAdmin` får lesetilgang inn i hver eneste administrerte kundes Azure på én gang** — og portal-appens ARM-scope gjør at en sesjon potensielt rekker helt inn til Azure-ressursstyring. Det er kronjuvelen. Så alt handlet om en vei til `SuperAdmin` — eller til hemmelighetene den vokter.

**Secret read-back-jakten.** Hvis enroll-flyten *skriver* en hemmelighet, finnes det kanskje en søster som *leser* den? Jeg hentet dev-backendens uauthenticated `/swagger/v1/swagger.json` og lot et skript resolve hver responsstruktur:

```python
# AI-generert; jeg satte mønsteret det skulle lete etter
SECRET = re.compile(r'secret|credential|clientsecret|tenantreader|password|token')
for path, ops in swagger["paths"].items():
    for method, op in ops.items():
        hits = [f for f in resolve_response_schema_fields(op) if SECRET.search(f)]
        if hits: print(method.upper(), path, "->", hits)
# GET /api/admin/tenants  [200] -> ['tenantreaderclientid']     # eneste treff i hele API-et
```

**Negativ kontroll:** det eneste secret-lignende feltet noe endepunkt returnerer er `tenantReaderClientId` — *client-ID-en*, ikke secreten. `tenantReaderClientSecret` finnes i **ingen** responsstruktur, og det finnes ingen `rotate`/`reveal`/`test-connection`-rute. Secreten er write-only: inn i Key Vault ved enroll, aldri ut igjen via API-et. Fyrt med både lavpriv-token og fremmed-tenant-token → begge `403`. <Severity level="high" /> som **design-risiko** (blast radius), <Severity level="info" /> som utnyttbart hull i dag.

**X-TenantId-BOLA + et sterkt positivt funn.** SPA-en fester et **klient-satt `X-TenantId`-header** på hver dataforespørsel, og rolle-gatingen (`Read/AllTenants/Admin/SuperAdmin`) er håndhevet **kun klientside** (en `hl()`-sjekk i bundelen). Hvis backenden stoler på `X-TenantId` uten å sjekke det mot tokenets tenant, er det en autentisert cross-tenant-BOLA. Men før jeg kunne teste det med en sesjon, sjekket jeg om jeg kunne forfalske en:

```text
# Alle mot io-p-backendens /api/session/current:
alg:none forged            -> 401 "The signature is invalid"
utløpt RS256               -> 401 "The token expired at ..."
feil issuer                -> 401 "The issuer '(null)' is invalid"
feil audience (alle testet)-> 401 "audience ... is invalid"
```

Token-forgery er **umulig** — backenden gjør full standard JWT-validering mot Azure AD JWKS. Og X-TenantId-BOLA-en? Autorisasjonen håndheves serverside; en fremmed-tenant-token blir akseptert som *autentisert* men svarer `403` på hver dataruta. Den fulle historien om det aksepterte-men-avviste tokenet skrev jeg i [da cross-tenant-funnet ikke overlevde verifisering](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering). BOLA-vektoren er reell i kode (<Severity level="high" />), men **credential-gated** — den krever en ekte, provisjonert portal-sesjon jeg ikke fikk.

En siste portal-detalj: SWA-laget bruker Entras multi-tenant default-provider (`/.auth/login/aad → /common`), så *hvem som helst* med en Entra-konto kan autentisere på SWA-laget — men det gater ingenting bak seg (backenden re-validerer). <Severity level="low" />, auth-misconfig.

### CRM-forken: en kronjuvel-bug som er ekte i koden — men gated

CRM-forken bærer en klynge **Broken Object/Function Level Authorization**-defekter i det workspace-scopede `/api/v1/*`-API-et. Kildegjennomgang + live-probing bekreftet **syv instanser**. Mønsteret er alltid det samme: en handler tar en objekt-UUID fra requesten og glemmer å sjekke at objektet tilhører innloggerens workspace.

```js
// PATCH /api/v1/objects/{slug}/attributes
updateAttribute(attributeId, input)   // ingen workspace-sjekk på attributeId; {slug} brukes ikke
```

Fire slike i REST-laget (cross-workspace *write* av attributter/lister, en cross-workspace attribute *DELETE* der handleren filtrerer `WHERE id=id` og ignorerer `{slug}`), pluss tre i CRM-ets AI-chat-verktøy — `get_notes_for_record`, `list_list_entries`, `create_note` kalles alle uten `ctx.workspaceId`. En angripers egen AI-agent kan altså lese en annen tenants private notater på tvers, auto-eksekvert uten bekreftelse.

```bash
# Live-flate (uauth, beviser at ruta finnes og hva som gater den):
curl -s -o /dev/null -w "%{http_code}\n" -X PATCH \
  https://crm.polyas.no/api/v1/objects/people/attributes \
  -d '{"attributeId":"000...0","title":"probe"}'
# 401 Unauthorized

# Cross-tenant impact (gated — gjengitt, IKKE kjørt):
curl -X PATCH 'https://crm.polyas.no/api/v1/objects/people/attributes' \
  -b 'active-workspace-id=<A_ws>; <A_session>' \
  -d '{"attributeId":"<B_ATTR_UUID>","title":"pentest-canary"}'   # forventet 200, muterer B sin attributt
```

**Negativ kontroll / ærlighet:** jeg reproduserte *ikke* cross-tenant-skrivingen. Autentiseringen er lukket — e-post/passord-registrering og -innlogging er skrudd av serverside (`EMAIL_PASSWORD_DISABLED`), Microsoft-SSO er eneste vei inn, og den er tenant-pinnet. Credential-sprayen min bommet. Bug-en er *kode-bekreftet og live bak `401`*, men å demonstrere effekten krever én gyldig sesjon **og** en fremmed UUID. En tidligere hypotese om at workspace-LLM-nøkkelen kunne exfiltreres via CRM-et falt også: API-et returnerer bare et `keyPrefix`/`hasApiKey`, aldri hele nøkkelen. <Severity level="high" /> (kode-bekreftet, gated).

Samme CRM har tre mindre følgefunn: `/openapi.json` serveres offentlig og uautentisert (full API-flate, <Severity level="low" />); CSP-headeren mangler helt (<Severity level="medium" />); og `og:image`-metadataen peker på den *interne* ACA-FQDN-en i stedet for det brandede vertsnavnet:

```bash
curl -sk https://crm.polyas.no/ | grep -Eio 'og:image[^>]*azurecontainerapps\.io[^"]*'
# -> og:image content peker på <crm-app>.<env>.azurecontainerapps.io
```

Det publiserer bypass-primitivet (se neste seksjon) til enhver link-unfurler. <Severity level="medium" />

### Edge og infrastruktur

**ACA raw-FQDN-bypass (bypass-primitivet).** Hver brandede Poly-workload (crm, workforce, insights/Grafana, mcp, rag, tools, sandbox, hub) er en Azure Container App bak et vennlig `*.polyas.no`-domene. Den underliggende ACA-ingress-FQDN-en er direkte nåbar og svarer byte-identisk:

```bash
curl -sk -o /dev/null -w "%{http_code}\n" https://crm.polyas.no/api/auth/ok
curl -sk -o /dev/null -w "%{http_code}\n" \
  https://<crm-app>.<env>.norwayeast.azurecontainerapps.io/api/auth/ok
# begge -> 200 {"ok":true}, identisk body/headers/remote_ip
```

Enhver kant-kontroll bundet til det brandede navnet (WAF, Front Door-regler, geo/IP-policy) forbigås ved å be om rå-FQDN-en, og per-revisjon-FQDN-en lar en angriper pinne til en gammel, kanskje sårbar revisjon (`<app>--<revision>.<env>...`). <Severity level="high" /> — dette er primitivet som holder Grafana-/CRM-/MCP-flatene nåbare uansett kant-herding, og et ledd i M365-kjeden.

**Eksponert AKS API-server.** `traefik.polyas.io` presenterer et TLS-sertifikat som er et Kubernetes API-server-sertifikat (`CN=apiserver`, SAN `kubernetes.default.svc`, `IP 10.0.0.1`, `*.hcp.norwayeast.azmk8s.io`) — altså en AKS managed control-plane eksponert på kanten:

```bash
echo | openssl s_client -connect 203.0.113.10:443 -servername traefik.polyas.io 2>/dev/null \
  | openssl x509 -noout -subject -ext subjectAltName
# Subject: CN=apiserver ; SAN: ...azmk8s.io, kubernetes.default.svc, IP 10.0.0.1
```

**Negativ kontroll som ble til en positiv:** jeg rakk ikke fullføre anonymous-auth-sjekken (`/version`, `/api`, `/healthz`) — under testen tidsavbrøt **alle** TCP-connect mot host-IP-en, mens en samtidig kontroll mot portal-fronten holdt seg åpen. Ingressen **aktiv-blokkerte tester-IP-en min** (adaptiv filtrering). Det reduserer opportunistisk eksponering, men lukker ikke funnet: control-plane-en er cert-bekreftet på kanten og anonymous-auth-posituren er uprøvd. <Severity level="high" /> (åpen, anonymous-auth-sjekk utsatt til kunden løfter IP-blokken).

**Danglende CNAME-er.** `app.discovery.polyas.no` og `app.polyas.io` peker på slettede ACA/SWA-miljøer. Azures domene-validering blokkerer ekstern claim, så de er **ikke** takeover-bare — DNS-hygiene + latent risiko, ikke en subdomain-takeover. (`app.polyas.io` viste seg dessuten å være en *live* SWA med et brutt custom-domain-cert-binding — TLS-navn-mismatch, ikke dangling.) <Severity level="low" />

**Kontrollplan med brutt OIDC.** Workforce «Control Plane»-admin-appen (`admin.polyas.no` + dev-varianten) er internett-nåbar og redirecter til `/login`, men OIDC-en er i en brutt `provider_unknown`-tilstand:

```bash
curl -skD - -o /dev/null 'https://admin.polyas.no/login?provider=entra'
# 307 /login?error=provider_unknown   (hver provider-verdi feiler likt)
```

**Positiv kontroll:** fail-open-hypotesen ble testet og motbevist — den brutte auth-en feiler *lukket* (ingen fallback/lokal-auth-gren, ingen bypass). Funnet er dermed *internett-eksponering av en kontrollplan-flate med degradert auth*, ikke en auth-bypass. <Severity level="medium" />

**Marketing cache-deception + åpne SWA `/.auth`.** Den fortsatt-levende legacy-SWA-en (`www.polyas.no`) svarer `200` med `cache-control: public, max-age=30` på ikke-eksisterende stier (web-cache-deception), har backenden direkte nåbar på `*.azurestaticapps.net`, og har `/.auth/*`-flytene åpne:

```bash
curl -skI "https://www.polyas.no/nonexistent-$(date +%s).css" | grep -Ei 'HTTP/|cache-control'
# HTTP 200 ; cache-control: public, must-revalidate, max-age=30
curl -sk https://www.polyas.no/.auth/me            # {"clientPrincipal":null}
```

(Den nye Vercel-siten `polyas.no` gjør *ikke* dette — scoped kun til legacy-SWA.) <Severity level="medium" />

### Nextcloud

**Utdatert versjon.** `nc.polyas.io` kjører Nextcloud Server 32.0.3.2 — seks patcher bak, innenfor 2026-CVE-vinduet — og `status.php` lekker eksakt build. Alle aktuelle CVE-er krever imidlertid en gyldig credential (ingen unauth-vektor). <Severity level="medium" /> (gated).

**Pre-auth brukerenumerering via avatar.** `nc.polyas.io/index.php/avatar/{bruker}/{størrelse}` → `200` med ekte PNG for eksisterende brukere, `404` for ikke-eksisterende; ingen auth, CSRF eller rate-limit. Innebygd `admin` bekreftet. <Severity level="medium" />

**SAML-bypass til lokalt passord-backend.** `?direct=1` når det native passord-backendet forbi Entra-SAML-en, uten account-lockout (kun per-IP-delay). Kombinert med avatar-orakelet ga det en reell spray-flate — som jeg kjørte (lockout-bevisst, operatør-autorisert) og som **ikke** ga tilgang. <Severity level="low" /> for bypass-flaten; spray-negativet er en positiv kontroll på passordstyrken.

**`notify_push` uauth.** Push-endepunktet er nåbart uautentisert (unhandled-error / info-disclosure). <Severity level="low" />

En viktig positiv kontroll her: SAML-**forgeryen** ble motbevist live — en forfalsket/usignert `SAMLResponse` ble **avvist** (`user_saml` validerer signatur/issuer tross `WantAssertionsSigned=false`). Ingen pre-auth ATO.

### Sanity CMS

**Verdens-lesbart datasett.** Marketing-CMS-et hadde `production`-datasettet satt til public read. En anonym GROQ-spørring returnerte hele innholdet — inkludert et `siteSettings.contact`-objekt med en navngitt ansatts direkte mobil og e-post:

```bash
curl -sG 'https://<prosjekt-id>.api.sanity.io/v2021-06-07/data/query/production' \
  --data-urlencode 'query=*[_id=="siteSettings"][0]{contact}'
# 200 -> {"contact":{"contactPerson":{"name":"<REDACTED>","phone":"<REDACTED>", ...}}}
```

166 dokumenter totalt lesbare anonymt. <Severity level="medium" /> — reell PII. I tillegg er den anonyme real-time `listen`-endepunktet (SSE) åpent, så en angriper kan live-overvåke endringer i datasettet. <Severity level="low" />

### admin.workforce: uautentisert OAuth Dynamic Client Registration

`admin.polyas.no` er sin egen OAuth 2.0-autorisasjonsserver (adskilt fra Entra-innloggingen). `/authorize|token|jwks|userinfo` er alle login-gated, men `/api/oauth/register` er det ikke — og det angriper-kontrollerte `client_name` lagres **verbatim**:

```bash
curl -X POST https://admin.polyas.no/api/oauth/register -H 'Content-Type: application/json' \
  -d '{"client_name":"<img src=x onerror=alert(document.domain)>",
       "redirect_uris":["http://127.0.0.1:8765/cb"],
       "token_endpoint_auth_method":"none"}'
# 201  {"client_id":"<...>", "client_name":"<img src=x onerror=alert(document.domain)>", ...}
```

**Ærlighet om hva dette *ikke* er:** payloaden lagres uescaped, men jeg observerte aldri en render-sink som eksekverer den — så dette er en uautentisert client-registrering + lagret payload, *ikke* en bekreftet stored-XSS. Og `redirect_uri` er låst til loopback (`400 invalid_redirect_uri` ellers), så ingen ekstern token-fangst. <Severity level="low" /> (ville blitt high hvis en admin-konsoll rendrer `client_name` uescaped). Fiks: krev auth/initial-access-token på DCR; escape all client-metadata på output.

### Uautentiserte info-lekkasjer (resten av katalogen)

Ingen av disse ga en pivot alene, men de er ekte eksponering, og de fortjener PoC-ene:

- **Dev-backendens Swagger, helt åpen.** `<app>-dev.azurewebsites.net/swagger/v1/swagger.json` → `200`, ~148 KB, **74 endepunkter** inkludert admin-tenant-CRUD, `/api/Users`, `/api/Devices/export` og Azure Resource Graph-passthrough. **Negativ kontroll:** prod skjuler Swagger, og hvert av de 74 endepunktene svarer `401` (Kestrel) når det kalles — også `POST` med live KQL. Spec-disclosure, ikke kallbar admin-tilgang. <Severity level="medium" />
- **Grafana `/metrics` uauth.** `insights.workforce.polyas.no/metrics` → `200`, ~425 KB Prometheus-dump uten auth: eksakt build (11.6.14), konto-/admin-/service-account-navn, datasource- og alert-topologi. `/api/*` svarer korrekt `401` — bare `/metrics` slipper gaten. <Severity level="low" />
- **Helse-endepunkter.** `mcp.integration.workforce.polyas.no/health` lekker ACA-revisjon + K8s pod/replica; io-p-backendens `/health` lekker DB-avhengighet (`resource-db-check`); ACA `/health` lekker revisjon + pod-navn. Selve MCP-/data-flatene er gated (`401`). <Severity level="low" />
- **Hub staging-SPA + API-kart.** `hub.workforce.polyas.no` er en offentlig staging-frontend hvis bundle lekker staging-backend-origin + et fullt **102-endepunkters** admin/MCP/RAG-rutekart, og staging-backenden er internett-nåbar og CORS-whitelister prod-originen (miljø-bleed). Rutene er `401`-gated. <Severity level="low" />
- **`/api/auth/me` gjest-objekt.** Workforce-portalen returnerer et gjeste-identitetsobjekt til uautentiserte kallere i stedet for `401`: `200 {"displayName":null,"role":"TENANT_USER","allowedModules":[]}` — lekker identitetskontrakten. <Severity level="low" />
- **Atlassian-plattform-metadata (to tenanter).** `polyas-io.atlassian.net/rest/api/2/serverInfo` (+ `servicedeskapi/info`) svarer `200` anonymt med eksakt build-nummer, build-dato og SCM-changeset — men det er *Atlassians egen* delte Cloud-plattform-commit, ikke klientkode, og ikke tenant-konfigurerbart. JSM anonym kundetilgang er skrudd **av** (portaler `anonymousEnabled:false`). <Severity level="info" />
- **monitor-hub `/api/health`.** → `{"status":"degraded"}` uautentisert (operativ-tilstand). NextAuth-innloggingen på samme host har ingen rate-limit/lockout + timing-basert brukerenum — en spray-flate (ikke knekt). <Severity level="info" /> / <Severity level="low" />
- **Nøkler i bundelen.** Web3Forms-nøkkelen på kontaktskjemaet er ikke origin-bundet — jeg beviste gjenbruk med ett merket PoC-innlegg fra en fremmed origin (`200 {"success":true}`, relayet inn i Polys egen innboks). Og en Mapbox public-token (`pk.`) ligger i en `polyas.no`-chunk. <Severity level="low" />
- **DNS-hygiene.** Diverse manglende hardening (SPF/DMARC-gaps, `polyas.com` intel-only DNS-hygiene). <Severity level="low" />
- **Svake sikkerhets-headere.** `io-p`-backend, `polyas.no` og `admin.workforce` mangler/svak CSP (`unsafe-inline`) og øvrige security-headere, pluss `Request-Context` App-Insights-`appId`-lekkasje. <Severity level="low" />

### Agentisk infrastruktur: en helt ny angrepsflate

To av de mest interessante funnene handler ikke om web-appene, men om at Poly kjører **autonome AI-agenter** i produksjon.

**Det herreløse produktdomenet.** Agentene commiter til ekte GitHub-repoer som `<adjektiv>-<dyr>@polyas.dev`, og en intern token-broker + en GitHub-App orkestrerer dem. `polyas.dev` er ikke bare agent-navnerommet — det er produkt- og utviklerdomenet deres, med produkt-mail, OIDC-utsteder-tillit og TLS knyttet til seg. Og det er **uregistrert:**

```bash
curl -s https://rdap.org/domain/polyas.dev
# {"errorCode":404,"description":["polyas.dev not found"]}
curl -s -H 'accept: application/dns-json' "https://dns.google/resolve?name=polyas.dev&type=NS"
# {"Status":3, ...}   # 3 = NXDOMAIN
```

Den som registrerer det (rundt en tier per år) kan sette opp en catch-all MX og fange all e-post til `*@polyas.dev`: GitHub-verifiseringer og passord-reset for agent-kontoene, Entra B2B-invitasjoner, reset-mail for et hvilket som helst SaaS der en agent er registrert. Man kan verifisere `blaa-jaguar@polyas.dev` på en egen GitHub-konto og få GitHub til å re-attribuere agentens commits — og sende «som» en agent inn i menneske-review-loopen som merger PR-ene deres. Jeg fant live, uattribuerte agent-commits i offentlige repoer akkurat nå. Det er ikke en shell i seg selv — det er en *betingelse* som kan bli en fot innenfor — men det er det klareste og billigste funnet i hele rapporten. <Severity level="critical" />

**Den uautentiserte regnskaps-MCP-en.** Poly har forket en MCP-server som kobler agentene til regnskapssystemet sitt. I koden eksponerer forken en HTTP-transport (`POST /mcp`) **helt uten autentisering** — en unauthenticated request lager en sesjon, registrerer alle verktøyene, og serverer kallet. Serveren holder regnskaps-tokenene serverside:

```text
# lokal bygg av forken:
POST /mcp  {"jsonrpc":"2.0","method":"tools/list", ...}    # ingen Authorization-header
→ 200, 28 verktøy: create_invoice, create_voucher, create_employee, update_customer, ...
# mot den LIVE hosten:  POST /mcp → 401
```

**Negativ kontroll:** den live-deployerte MCP-hosten er en *annen*, gated gateway (`401` på alt), så dette er ikke utnyttbart mot produksjon i dag. Men forkens `railway.json`/Dockerfile defaulter til `MCP_TRANSPORT=http` uten auth — så *hvis* den noen gang deployes slik, driver en anonym angriper regnskapssystemet via serverens lagrede credentials. En klassisk confused deputy. <Severity level="high" /> (kode/residual). Det finnes også en design-only agentisk supply-chain-vei: indirekte prompt-injeksjon → broker-utstedt GitHub-token → tilkoblede kunde-repoer (`polyas.dev`/GitHub er utenfor scope, så flagget, ikke kjørt).

<Callout type="danger">
  Autonome agenter er en ny angrepsflate, og både identiteten deres og
  verktøy-transporten deres er en del av den. Hvis agentene dine har
  e-postidentiteter og commit-signaturer knyttet til et domene — eie det domenet.
  Hvis de snakker MCP — autentiser transporten før du registrerer verktøyene.
  Et herreløst agent-navnerom er subdomain-takeover for AI-alderen; en
  uautentisert MCP-transport er et RCE-lignende primitiv for den.
</Callout>

### Da jeg ba svermen finne på nye angrep

Da hver *utførende* bølge endte i «hardened», byttet jeg modus: en sverm som skulle **generere nye angrepsvinkler** — seks kreative «linser», hver matet med hele den kjente tilstanden *og* listen over det som allerede var motbevist, slik at de bare produserte *nye* ideer. 56 ideer inn, en rangert backlog ut. Toppen ble testet manuelt, og nesten alt falt — men måten det falt på er lærerikt.

**Dependency confusion.** CRM-forken er en pnpm-monorepo med interne pakker `@crmcore/shared` og `@crmcore/web`, begge uclaimet på npm (`404`). Ser farlig ut — helt til pinningen:

```bash
grep -r '"@crmcore/shared"' apps/*/package.json
# apps/web/package.json:  "@crmcore/shared": "workspace:*"    -> resolves lokalt, aldri fra npm
```

Uten en `.npmrc` som faller tilbake til public registry er det ingen confusion-vei. Forkastet.

**Claim-forvirring i rollen.** Mapper backenden rollen din fra et angriper-settbart *string*-claim i stedet for en immutabel GUID? Jeg er `Global Administrator` i *min egen* tenant (`Tjall AS`, `tjallas.no`) — `wids`-claimet mitt inneholder rolle-template-ID-en for Global Admin (`62e90394-69f5-4237-9190-012177145e10`). Hvis Poly sin backend naivt sjekket «er dette en Global Admin?» uten å sjekke *hvilken* tenant, ville min utenfrakonto fått `SuperAdmin`:

```text
# dekod portal-API-tokenet (utstedt til min egen tjallas.no-konto):
{'aud': '<portal-app>', 'tid': '<min-tenant>', 'scp': 'access_as_user',
 'roles': None, 'wids': None, 'groups': None}
# fyrt mot backenden:
GET /api/Session/Current -> 401 ;  GET /api/Applications -> 403 ;  GET /api/admin/tenants -> 403
```

**Negativ kontroll er hele funnet.** Portal-API-tokenet bærer *ingen* `roles`/`wids`/`groups`. `wids: Global Admin` lever bare i Graph-tokenet mitt, i min egen tenant — det når aldri Poly sin API. Backenden resolver rollen serverside fra Polys *egen* katalog. En Global Admin i en vilkårlig fremmed tenant er en nobody her. <Severity level="info" /> — motbevist, positiv kontroll.

**IMDS-SSRF via LLM-verktøykanalen** (rank-5-ideen): bruk det hardkodede AI-tokenet til å tvinge et funksjonskall som henter `http://169.254.169.254/metadata/identity/...` og stjeler managed-identity-tokenet. Den var kreativ, men forutsatte at `research`-fetcheren egresserte angriper-kontrollert — som allerede var motbevist (null ngrok-treff). Falt før den kom i gang. **Secret read-back** (rank 2) og **io-d delt datastore** (rank 6) — begge testet og motbevist over (`write-only` / `401`).

## Det som holdt — positive kontroller

Dette er kanskje den viktigste delen av en «proven-hardened»-rapport: bevis på hva som *virker*. Krediter kunden for hvert av disse — jeg dyttet på dem alle:

- **App-autentisering:** CRM/workforce/sandbox/portal/Grafana er alle Entra-SSO eller sunn-JWT; ingen uautentisert fot på noen app-API. Forfalskede session-cookies gir bare app-skallet (dataflatene re-validerer kryptografisk).
- **Token-forgery umulig:** `alg:none`, RS256→HS256-confusion, utløpt, feil issuer/audience — alle `401` mot Azure AD JWKS.
- **CRM-SSO tenant-pinnet:** e-post/passord-innlogging av; Microsoft-SSO pinnet til hjemme-tenanten → en utenfrakonto kommer ikke inn.
- **SAML-signering validert:** forfalsket `SAMLResponse` avvist tross `WantAssertionsSigned=false`.
- **Cross-tenant string-claim-mapping:** motbevist (over).
- **Passord:** low-and-slow spray mot Entra + monitor-hub + sandbox + Nextcloud = rene bom (ingen svake passord); Entra-enum-beskyttelse aktiv (den auto-oppblåste enum-listen ble forkastet som FP).
- **Framework/CVE:** Next.js patchet (CVE-2025-29927 `x-middleware-subrequest` negativ på alle fire apper); Grafana CVE-2025-4123/2024-9264/2021-43798 patchet/gated; request-smuggling + cache-poisoning motbevist over heterogene kanter.
- **Deploy-hygiene:** ingen `.git`/`.env`/sourcemaps/backups eksponert (alle «200s» var Azure-SWA soft-200s som serverte `index.html`, verifisert mot body); ingen committed secrets i GitHub-org-skann.
- **MCP-integrasjonsgateway:** fail-closed (`401` på 40+ prober); Scaleway-kanten default-drop, ingen bypass; io-p-backendens authN-vegg robust (CORS exact-match).
- **AI-endepunktet:** prompt-injection, `research`/`image`-SSRF (ngrok-verifisert), system-prompt-lekkasje — alt negativt.
- **Dev-creds:** `dev@local.test / devpassword123` funnet i CRM-bundelen var **inerte** på prod (`EMAIL_PASSWORD_DISABLED`; ingen dev-CRM-instans finnes).
- **Device-code:** stoppet ved bevist vektor — ingen live token-fangst.

## Hva jeg lærte

- **En sverm produserer plausible funn og plausible feil i samme tempo.** 4789 inn, en håndfull ekte ut. Verdien er ikke genereringen — det er refute-steget. Kjører du agenter i loopen uten en skeptiker som defaulter til «ikke ekte», shipper du hallusinasjoner.
- **«Fikset» må verifiseres, funn for funn.** Kunden mente det ærlig — de *hadde* bygget noe nytt og hardere. Men 20 av 22 gamle funn sto urørt ved siden av. Et retest er ikke å spørre om noe er fikset; det er å kjøre hvert funn på nytt.
- **Hardnet der det teller er et ekte, verdifullt resultat** — selv når kanten lekker. Kjernen holdt mot hele flåten. Derfor krediterer rapporten de positive kontrollene like eksplisitt som funnene.
- **Den farligste kjeden er sjelden en exploit.** Her er det en samtykke-phish mot over-scopede apper — menneskelig, ikke teknisk, og uendret siden sist.
- **Agentisk infrastruktur er en egen angrepsflate.** De to mest handlingsrettede funnene var et uregistrert domene og en MCP-transport uten auth — ingen av dem en tradisjonell web-bug.
- **Alvorlighet er en funksjon av arkitektur, ikke bare av bugs.** MSP-portalens `SuperAdmin`-konsentrasjon er den største risikoen i estatet, og det er ikke en sårbarhet — det er et designvalg om blast radius.
- **En sverm må dimensjoneres.** Tre krasj lærte meg å holde bølgene små og alltid kunne salvere fra journalen.

## Det jeg ikke fikk — og veien videre

Trådene som gjenstår er alle operatør-styrte eller gated:

- **CRM-ets cross-workspace-BOLA** (syv instanser) krever en gyldig sesjon *og* en fremmed UUID — SSO-en er tenant-pinnet, så en utenfrakonto kommer ikke inn. Dekkes bare av et authenticated- eller assumed-breach-oppdrag.
- **`SuperAdmin`-veien** finnes fortsatt der ute, hypotetisk. Jeg fant den ikke på åtte runder — nyttig informasjon, men ikke det samme som at den ikke finnes.
- **AKS API-server-cert-en:** anonymous-auth-sjekken rakk jeg ikke — hosten auto-blokkerte tester-IP-en. Åpen tråd, flagget for en runde der kunden løfter blokken.
- **`polyas.dev`-kjeden** kan bare tas videre ved å faktisk registrere domenet og se hva som lander i catch-all-en — en forretnings- og scope-beslutning, ikke en teknisk en. Det er også rapportens #1 anbefaling: registrer domenet før noen andre gjør det.

En ting til fra sist gang som fortsatt gjelder: en ekstern retest bekrefter at grensene holder utenfra, men den ser ikke det som ligger bak login. I en MSP-portal, der `SuperAdmin` er hele ballspillet, er det nettopp der neste oppdrag bør begynne.
