# Tre Next.js-CVE-er som ikke traff, og kontaktskjemaet som gjorde det
URL: https://erik.no/blogg/2026-07-14-nextjs-middleware-cve-som-ikke-traff
Publisert: 2026-07-14 | Tags: pentest, nextjs, xss, 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.
Det flotte funnet ble aldri noe funn. Jeg hadde tre publiserte middleware-bypass-CVE-er
for Next.js, en app som på papiret satt midt i det sårbare vinduet, og en PoC-samling som
kjørte rett ut av boksen. Alle tre bomma. Det som til slutt ga fotfeste var det kjedeligste
tenkelige: et kontaktskjema som sendte feltene sine usanert videre til et internt adminpanel.
Poenget med innlegget er det mellomrommet. En CVE er en egenskap ved en *kodesti*, ikke ved
et versjonsnummer — og forskjellen på om den treffer deg eller ikke kan være om utvikleren
skrev `export const config = { matcher }` eller lot være. Her er hele kjeden, inkludert
hvorfor de tre «riktige» angrepene ble avvist, og hvorfor den ene boringen fungerte.
## Kontekst
Kunden var Trollmyr AS (`trollmyras.no`), en liten norsk skreddersøm-shop som bygger
admin-systemer for andre bedrifter. Scope var uautentisert black-box mot markedsnettstedet
og et tilhørende admin-CRM. Ingen konto utlevert — jeg skulle inn utenfra hvis jeg kom inn
i det hele tatt.
Jeg kjører denne typen oppdrag med AI i loopen: agenter gjør recon, endepunkt-kartlegging
og førsterunde-testing i parallell, jeg styrer scope, tar vurderingene og skiller ekte funn
fra plausibelt støy. Den siste biten er hele jobben — en agent produserer troverdige funn
fort, og troverdige feil like fort. Verifiseringen er der verdien ligger, akkurat som i
[da et cross-tenant-funn ikke overlevde verifisering](/blogg/2026-07-08-da-cross-tenant-funnet-ikke-overlevde-verifisering).
## Appen bak en rewrite
Markedssiden var en Next.js-app på Vercel, og den ga lite. Ingen subdomener løste opp
(`crt.sh` + en liten brute-sweep ga bare `www`), ingen source maps, ingen hemmeligheter i
bundlene. En pen, tynn brosjyre.
Men repoet til markedssiden lå åpent på GitHub, og `next.config.ts` fortalte hvor det
interessante var:
```ts
// next.config.ts — markedssiden
async rewrites() {
return [
{ source: "/admin", destination: `${process.env.CRM_URL}/admin` },
{ source: "/admin/:path*", destination: `${process.env.CRM_URL}/admin/:path*` },
];
}
```
CRM-et var en *egen* Next.js-app, montert på markedssiden under `/admin` via en
multi-zone-rewrite. Jeg ba en agent lese resten av repoet og de tilhørende MD-dokumentene;
der lå både endepunkt-navnene på CRM-siden og en intern `SIKKERHET.md` som i praksis var
utviklerens egen liste over ting de var redde for (IDOR på sekvensielle ID-er, mass-assignment
av et `erAdmin`-flagg). Nyttig kart.
`GET /admin` svarte `307` til `/admin/login`. Innloggingen var et vanlig skjema med felt for
e-post, passord og et valgfritt TOTP-felt. Uautentiserte kall mot `/admin/api/*` ble
redirigert til login. Det betød én ting: **det satt en Next.js middleware og gjorde
autorisasjonen.** Og middleware-basert auth i App Router er akkurat det de nye CVE-ene sikter
på.
## Tre CVE-er som skulle omgå innloggingen
Jeg lot `cve`-oppslaget triagere versjonen (markedssiden kjørte Next.js 16.2, altså under
16.2.5 — i vinduet). Tre kandidater pekte seg ut, og det finnes en offentlig PoC-samling for
akkurat dette settet: [`dwisiswant0/next-16.2.4-pocs`](https://github.com/dwisiswant0/next-16.2.4-pocs).
Jeg kjørte dem mot CRM-et, én etter én.
**Forutsetninger for alle tre:** helt uautentisert. Baseline er at den kanoniske stien
redirigerer — altså at middleware faktisk fyrer:
```http
GET /admin HTTP/1.1
Host: trollmyras.no
HTTP/1.1 307 Temporary Redirect
location: /admin/login?next=%2F
```
### CVE-2025-29927 — `x-middleware-subrequest`
Fjorårsklassikeren: en header som lurer Next til å tro at kallet er en intern subrequest og
hoppe over middleware. Patchet for lengst, men billig å prøve:
```http
GET /admin HTTP/1.1
x-middleware-subrequest: middleware:middleware:middleware:middleware:middleware
HTTP/1.1 307 Temporary Redirect
location: /admin/login?next=%2F
```
Fortsatt `307`. Patchet, som forventet på en 16.2-app.
### CVE-2026-44575 — segment-prefetch via `.rsc`
Denne var jeg mer spent på. App Router serverer en rute i flere «transport-varianter» — den
vanlige HTML-en, en `.rsc`-flight-payload, og en segment-prefetch-variant. Sårbarheten er at
den kompilerte matcher-regexen (før 16.2.5) bare matchet den kanoniske stien og en gammel
Pages-Router-datavariant, *ikke* `.rsc`- og `.segment.rsc`-formene. Alle løser til samme side
på serveren — så hvis middleware hoppes over for `.rsc`-varianten, får du sidens innhold uten
auth-sjekken. CRM-et sendte til og med `vary: …next-router-segment-prefetch`, så
segment-prefetch var påslått. Tekstbok.
```http
GET /admin.rsc HTTP/1.1
Host: trollmyras.no
RSC: 1
Next-Router-Prefetch: 1
Accept: text/x-component
```
```http
HTTP/1.1 307 Temporary Redirect
location: /admin/login?next=%2F
```
Og segment-varianten:
```http
GET /admin.segments/$c$children/__PAGE__.segment.rsc HTTP/1.1
RSC: 1
Next-Router-Segment-Prefetch: /__PAGE__
Accept: text/x-component
```
`307` igjen. Jeg prøvde variantene mot `/admin/kunder`, `/admin/faktura`, `/admin/nettside-leads`
— alle redirigerte til login. På en sårbar app skulle minst én av disse ha returnert `200`
med `content-type: text/x-component` og selve RSC-payloaden. Ingen gjorde det.
### CVE-2026-44574 — `nxtP`-parameter-injeksjon
Siste forsøk: Next representerer matchede dynamiske segmenter internt med reserverte
query-parametre (`nxtP`). Trikset er å treffe en *offentlig* sti, men henge på en
`nxtP`-parameter som får App Router til å rendre en beskyttet dynamisk rute — mens middleware
fortsatt ser den offentlige stien.
```http
GET /admin/login?nxtPslug=kunder HTTP/1.1
Host: trollmyras.no
HTTP/1.1 200 OK
content-type: text/html
```
`200`, men på et blunk så det ut som treff. Det var det ikke: svaret var nøyaktig
`login`-siden (samme byte-størrelse), ikke `kunder`-innholdet. `nxtP`-parameteren ble ignorert
— ruteren rendret ikke den beskyttede siden. Den dobbeltkodede sti-varianten
(`/admin/api/…%252f..%252fkunder`) ga bare `404`. Ingen bypass.
### Hvorfor ingen av dem traff
Her er innsikten jeg satt igjen med, og den gjelder de fleste av disse Next.js
middleware-CVE-ene: **de utnytter et hull i den kompilerte `config.matcher`-regexen. Det
hullet finnes bare hvis middleware kjører betinget via en matcher.**
Jeg hadde ikke CRM-ets kildekode — den lå i et privat repo — men oppførselen avslørte
mønsteret. Et par sonderinger gjorde det tydelig: `/admin/api/leadsx` ga `404` (behandlet som
offentlig), mens `/admin/api/xleads` ga `307` (beskyttet). Det er signaturen til en
`startsWith`-sjekk i selve middleware-kroppen, ikke en matcher. Rekonstruert så CRM-et omtrent
slik ut:
```ts
// Rekonstruert fra oppførsel — IKKE kundens kildekode.
// Merk: ingen `export const config = { matcher }`.
const PUBLIC = ["/admin/login", "/admin/api/auth", "/admin/api/nettside-leads/inn"];
export function middleware(req: NextRequest) {
const p = req.nextUrl.pathname;
if (PUBLIC.some((pre) => p.startsWith(pre))) return NextResponse.next();
if (!harGyldigSesjon(req)) {
return NextResponse.redirect(new URL(`/admin/login?next=${p}`, req.url));
}
return NextResponse.next();
}
```
Kontrasten er den sårbare stilen:
```ts
// Den sårbare stilen: middleware kjører BARE for det matcheren treffer.
export const config = { matcher: "/admin/:path*" };
export function middleware(req: NextRequest) { /* auth */ }
```
Med en matcher er det matcher-regexen som avgjør *om* middleware i det hele tatt fyrer for en
gitt URL — og det var den regexen som glemte `.rsc`-suffiksene. Uten matcher kjører middleware
for *alt*, inkludert `/admin.rsc` og `/admin.segments/…`, og `startsWith`-sjekken i kroppen
redirigerer dem på lik linje med den kanoniske stien. CVE-en har rett og slett ingen flate å
bite i. Deny-by-default global middleware er tregere (den kjører på hver eneste request), men
den var immun mot hele klassen.
Det er verdt å sette pris på: `deny-by-default` + `startsWith`-eksemptering er ikke det
«riktige» mønsteret i Next-dokumentasjonen, men det gjorde appen upåvirket av tre publiserte
auth-bypass-CVE-er. Perimeteret her var reelt godt bygget — jeg prøvde også credential-spray
(møtte fungerende rate-limiting) og secret-skannet ti av utviklerens offentlige repo-er med
full git-historikk uten å finne en eneste CRM-nøkkel. Det fortjener å nevnes like høyt som
funnene.
## Det kjedelige som virket: lagret XSS via kontaktskjemaet
Med fronten stengt gikk jeg tilbake til den ene dynamiske flaten markedssiden faktisk hadde:
kontaktskjemaet. Kildekoden viste at `/api/contact` ikke sendte e-post i det hele tatt — den
videresendte feltene *usanert* rett inn i CRM-ets lead-mottak, som lagret dem og gjengav dem i
adminpanelets lead-oversikt.
Det er den klassiske site→intern-panel-broen: en uautentisert besøkende skriver inn i en boks
på en offentlig side, og teksten havner urørt i en flate en innlogget ansatt åpner.
**Forutsetninger:** helt uautentisert. Feltene `name`, `firma` og `message` videresendes
usanert til CRM-ets `NettsideLead`-tabell og rendres i admin-visningen.
1. Send inn et lead med en markert nyttelast i navnefeltet:
```http
POST /api/contact HTTP/1.1
Host: trollmyras.no
Content-Type: application/json
{"service_type":"skreddersydd-system","name":"PENTEST
>",
"email":"pentest@example.net","phone":"99999999","message":"PENTEST-markør"}
```
```http
HTTP/1.1 200 OK
{"ok":true}
```
`200` her betyr at CRM-et faktisk tok imot og lagret leadet (ruten returnerer `500` hvis
mottaket avviser). Payloaden ligger nå i lead-tabellen og kjører i den innloggede
admin-sesjonen idet en ansatt åpner lead-oversikten. Nyttelasten var en benign, tydelig merket
markør — aldri en fungerende exploit-streng.
2. **Bevis — og en ærlig begrensning.** Dette er en *blind* lagret XSS: jeg har ingen
admin-sesjon selv, så jeg ser ikke at den kjører direkte. To ting bekreftet den likevel.
For det første viste kildekoden at feltene aldri saneres, verken ved mottak eller
gjengivelse. For det andre — og dette er det fine — varslet kundens *egen* overvåking om at
det skjedde et same-origin-kall mot deres kundedata-endepunkt i det leadet ble åpnet.
Nettleser-callbacken min ut av origin kom derimot aldri fram. Den mest sannsynlige grunnen
er en CSP i adminpanelet som blokkerte utgående kall — noe som *også* betyr at ingen
kundedata forlot miljøet. Deteksjonen deres funket, med andre ord, og saneringen manglet.
.
**Mitigering.** To lag: saner input ved mottak *og* kontekst-escape ved gjengivelse i CRM-et
(f.eks. DOMPurify for HTML), og innfør en streng CSP i adminpanelet som forsvar i dybden. Den
ene av dem alene hadde stoppet dette; begge er billig.
En kort note om en blindvei, siden undersøkelsen er poenget: CRM-et hadde også en
firma-søk-proxy som tok fritekst fra brukeren og slo opp mot et offentlig register serverside.
Det luktet SSRF/injeksjon, og en agent flagget det tidlig. Det holdt ikke — inputen ble kjørt
gjennom `encodeURIComponent` før den ble til et søkeparameter, så den ble aldri annet enn en
søkestreng. Jeg nedgraderte det til ikke-funn. En `200` fra et proxy-endepunkt er ikke et hull
før du har vist at input faktisk styrer noe farlig.
## Verktøy og hvem kjørte hva
- **CVE-triage:** et `cve`-oppslag (KEV/EPSS/PoC-tilgjengelighet) mot den fingeravtrykkede
Next-versjonen. AI-kjørt; jeg valgte hvilke tre som var verdt å prøve.
- **Bypass-PoC-er:** [`dwisiswant0/next-16.2.4-pocs`](https://github.com/dwisiswant0/next-16.2.4-pocs),
kjørt mot `/admin`-ruter. Jeg leste requestene før jeg fyrte dem, og tilpasset dem til
multi-zone-oppsettet.
- **Secret-skanning:** `gitleaks` + `trufflehog` over ti offentlige repo-er med full
git-historikk. Null CRM-hemmeligheter. (Det eneste treffet var en Supabase `anon`-nøkkel i
et urelatert sideprosjekt — offentlig-ved-design, utenfor scope, flagget og ikke brukt.)
- **Kildegjennomgang:** `semgrep` mot markedssidens kode, pluss agent-lesing av MD-dokumentene
som avslørte CRM-arkitekturen.
- **Out-of-band:** her ble det operasjonelt teit en stund. Miljøet mitt drepte enhver
vedvarende lytter (egen HTTP-listener, `ngrok`, `interactsh-client` — alle ble kastet med det
samme). Løsningen var udramatisk: en hosted request-bin jeg pollet med enkeltstående
`curl`-kall i stedet for en daemon.
- **Meg:** scope, prioritering, verifiseringen som nedgraderte firma-søk-funnet og hevet
XSS-en, og den endelige vurderingen av at perimeteret faktisk var solid. Agentene fant
kandidater; jeg avgjorde hva som var ekte.
## Hva jeg lærte
- **En CVE er en egenskap ved en kodesti, ikke et versjonsnummer.** Appen satt midt i det
sårbare versjonsvinduet og var likevel ikke sårbar. Test angrepet; ikke konkluder fra
`package.json`.
- **Matcher vs. global middleware avgjør hele klassen.** De fleste Next.js
middleware-bypass-CVE-ene utnytter et hull i `config.matcher`-regexen. En deny-by-default
middleware uten matcher, som sjekker stien i kroppen, har ingen flate for dem — tregere, men
immun.
- **Blindt XSS bekreftes ofte av motpartens deteksjon.** At jeg ikke fikk callbacken tilbake
var ikke en fiasko — det var en CSP som gjorde jobben sin, og som samtidig beviste at ingen
data lekket. Deres overvåking bekreftet resten.
- **De kjedelige buggene vinner.** Tre fine CVE-er bomma; usanert input i en intern visning
gjorde ikke. Den flaten et menneske åpner er nesten alltid mer interessant enn den flashy
protokoll-detaljen.
- **Nevn det som er bygget bra.** Et solid perimeter er et resultat, ikke et fravær av
resultater — samme lærdom som da [en solid app ble nøytralisert helt til origin-serveren
gikk utenom Cloudflare](/blogg/2026-06-03-solid-app-origin-omgar-cloudflare). Her holdt både
auth, rate-limiting og hemmelighold. Det ene som sviktet, sviktet skikkelig.