passordendringen som logget meg ut av min egen sesjon

9. september 2026 meta gosecurity

Jeg bygde et selvbetjent skjema for passordendring, testet det som en vanlig bruker ville gjort, og det logget meg ut av akkurat den sesjonen jeg satt i mens jeg skrev det nye passordet. Ikke en annen enhet, ikke en foreldet sesjon et sted. Meg, akkurat nå, på fanen jeg aktivt brukte til å endre mitt eget passord.

hva funksjonen skal gjøre

Innloggede brukere kan endre sitt eget passord fra en fane-inndelt /profile-side, under Sikkerhet. Skriv nåværende passord for å bevise at det er deg, skriv et nytt, ferdig. Det opplagte sikkerhetskravet bak det skjemaet er: hvis noen andres sesjon eller token fortsatt flyter rundt der ute, skal en passordendring drepe den. Det er ikke valgfritt, det er hele poenget med å la en passordendring skje i utgangspunktet, hvis den ikke invaliderte gamle sesjoner ville et stjålet token bare overlevd rett gjennom "fiksen."

designet som virket rimelig

Min første instinkt var "unntatt-nåværende." Trekk tilbake hvert refresh-token for denne kontoen bortsett fra det som tilhører sesjonen som gjør akkurat denne forespørselen, siden det antakelig er den legitime, personen beviser jo at de kjenner nåværende passord tross alt. For å gjøre det trenger handleren å vite hvilket refresh-token som tilhører denne sesjonen, slik at den kan hoppe over den. Det opplagte stedet å finne det er refresh_token-cookien som følger med på forespørselen:

// first attempt: read the current session's own refresh cookie so we
// know which one to spare
currentToken := r.CookieValue("refresh_token")
if err := qtx.RevokeAllUserRefreshTokensExcept(ctx, userID, currentToken); err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
	return
}

Bortsett fra at cookien ikke er scopet slik jeg halvveis hadde antatt. Refresh-cookies her er satt med Path=/auth:

func (s *Server) newRefreshCookie(rawToken string, maxAge time.Duration) *http.Cookie {
	return &http.Cookie{
		Name:     "refresh_token",
		Value:    rawToken,
		Path:     "/auth",
		HttpOnly: true,
		Secure:   s.CookieSecure,
		SameSite: http.SameSiteStrictMode,
		MaxAge:   int(maxAge.Seconds()),
	}
}

Path=/auth betyr at nettleseren bare fester den cookien til forespørsler hvis sti faktisk starter med /auth. En skjemainnsending fra /profile/security er ikke en av dem. Så i det øyeblikket unntatt-nåværende-logikken min prøvde å lese refresh_token fra den innkommende forespørselen for å finne ut hvilken sesjon som skulle spares, fikk den ingenting, hver eneste gang, for hver eneste legitime bruker, fordi nettleseren korrekt aldri sendte den dit i utgangspunktet. Min fail-safe for "kunne ikke identifisere nåværende sesjon" var å falle tilbake til å trekke tilbake alt, som er en rimelig fail-safe isolert sett. Det betydde bare at fallbacken utløste ved bokstavelig talt hver eneste ekte passordendring, siden happy-pathen den skulle være et unntak fra aldri faktisk skjedde.

Jeg fant dette ved å teste funksjonen ende til ende i stedet for bare å enhetsteste handleren isolert, som ville gitt den en cookie en ekte nettleser aldri ville sendt.

den faktiske fiksen

Dropp ideen om å spare én sesjon ved å lese en cookie som strukturelt ikke kan være der. Trekk tilbake alt, ingen unntak, og myntslå deretter umiddelbart et helt nytt refresh-token for sesjonen som gjør forespørselen og gi det tilbake i samme respons via Set-Cookie. Brukeren forblir innlogget, bare på et ferskt myntslått token i stedet for det gamle, og hver annen sesjon, de som kan tilhøre noen som stjal et token, blir slettet sammen med det:

if err := qtx.RevokeAllUserRefreshTokens(ctx, userID); err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to revoke sessions")
	return
}

rawRefreshToken, _, err := s.issueRefreshTokenWith(ctx, qtx, userID)
if err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to issue refresh token")
	return
}

if err := tx.Commit(ctx); err != nil {
	writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to commit transaction")
	return
}

http.SetCookie(w, s.newRefreshCookie(rawRefreshToken, s.RefreshTTL))
w.WriteHeader(http.StatusNoContent)

Trekk tilbake alt, myntslå ett ferskt token, sett det på vei ut, alt inni én transaksjon slik at det ikke er noe vindu der hver sesjon er død og ingen ny finnes ennå. Enklere enn unntatt-nåværende-versjonen og den avhenger ikke av en cookie nettleseren aldri kom til å sende til den ruten uansett. Noen ganger er fiksen for "unntakslogikken min fungerer ikke" å slette unntaket, ikke å debugge det.

å bevise det med en andre sesjon, ikke bare å lese koden

Jeg stolte ikke på meg selv nok til bare å lese den nye versjonen og kalle det fikset, gitt hvor selvsikkert feil den første versjonen hadde føltes mens jeg skrev den. Den faktiske testen åpner to sesjoner mot den ekte kjørende stacken, registrerer en bruker, logger inn en "nåværende" sesjon og henter refresh-cookien dens, logger deretter inn en andre, separat sesjon for samme konto og henter den cookien også. Endre passordet gjennom den nåværende sesjonen, bekreft deretter to ting direkte mot de ekte endepunktene: den nåværende sesjonens nylig roterte cookie utveksles fortsatt suksessfullt mot et ferskt access-token på /auth/refresh, og den andre sesjonens gamle cookie blir avvist utvetydig. Begge påstandene må holde samtidig for at fiksen faktisk skal være riktig, en versjon som ved et uhell trakk tilbake alt inkludert det nye tokenet ville feile den første, og en versjon som glemte å trekke tilbake andre sesjoner i det hele tatt ville feile den andre stille, og se fin ut fra den nåværende sesjonens synspunkt mens den lot hver eneste andre stå vidåpen.

den mindre fiksen som fulgte med

Den samme gjennomgangen som fanget dette fanget også en beslektet, stillere bug i rate-limiteren for innlogging og passordendring, som hadde vært nøkket på frontend-containerens egen adresse i stedet for den faktiske besøkendes, i akkurat samme form for feil som en rate-limiter ett hopp bortover i blog-tjenesten (fortjener sitt eget innlegg, siden fiksen endte med å bo i reverse-proxyen for begge). Ingen av dem var hovedbuggen jeg gikk inn med. Begge kom ut av samme disiplin: å spore hva en forespørsel faktisk bærer ved hvert hopp, cookie-sti inkludert, i stedet for å stole på at kode som kompilerer og består en smal enhetstest gjør det den ser ut til å gjøre når ekte nettlesere og ekte proxyer er involvert.

delen som fikk det til å klikke

Frontend-siden av dette forble nesten kjedelig i sammenligning, den stille sesjon-refresh-logikken i hooks.server.ts leser allerede hvilken refresh_token-cookie som for øyeblikket finnes og bytter den mot et ferskt access-token når det korte tokenet utløper:

const refreshToken = event.cookies.get('refresh_token');
if (refreshToken) {
	const response = await event.fetch('/auth/refresh', { method: 'POST' });
	...
}

Fordi den nye refresh-cookien blir satt i akkurat samme respons som selve passordendringen, holder nettleseren allerede den nye innen denne kodestien kjører igjen. Ikke noe eget re-innlogging-steg, ingen "du har blitt logget ut, vennligst logg inn igjen"-melding, sesjonen fortsetter bare stille på et nytt token under brukeren uten at de merker at noe endret seg. Verifiserte det ordentlig også, ikke bare ved å lese koden: endret passordet gjennom det ekte skjemaet bak Caddy, bekreftet at den nåværende sesjonens roterte cookie fortsatt fungerte på neste forespørsel, og bekreftet at en andre, separat sesjons gamle refresh-token ble avvist. Enkelt-ord-versjonen av lærdommen: en cookies Path er ikke en formalitet, det er en faktisk tilgangsgrense, og en sikkerhets-fallback som stille utløser på hver forespørsel i stedet for den sjeldne den er ment for, er ikke en fallback, det er den ekte oppførselen i forkledning.

0 kommentarer

Logg inn for å kommentere.

Logg inn

Glemt passord?

Ingen konto?