ThreadLocal: Korrekt idiom, fel era
Java EE Servlet-containrar, cirka 1999: en tråd per request. En tråd hanterar exakt en request från start till slut, sedan avslutas den. ThreadLocal lagrar ett värde knutet till den aktuella tråden. Med en-tråd-per-request tillhör ett värde som lagras i ThreadLocal exakt en request. Idiomet: korrekt.
Trådpooler ändrade kontraktet. En tråd hanterar request A, lagrar principal A i ThreadLocal, avslutar request A och återgår till poolen. Trådpooler återställer inte trådtillstånd. ThreadLocal.remove() städar upp, men att anropa det kräver explicit disciplin. När disciplinen brister kör request B på samma tråd och läser principal A i ThreadLocal.
5-stegs-läckan:
1. Request A anländer. Servern tilldelar Thread-7.
2. Thread-7 anropar ThreadLocal.set(principal_A) när begäran startar.
3. Begäran A slutförs. Thread-7 återgår till trådpoolen. ThreadLocal.remove() anropas inte.
4. Begäran B anländer. Servern tilldelar Thread-7 (återanvändning av trådpool).
5. Thread-7 läser ThreadLocal.get(): returnerar principal_A. Begäran B körs under fel identitet.
Varför tester missar det
Enhetstester körs isolerat: ingen trådpool, ingen återanvändning. Integrationstester använder nya trådar eller återställer tillstånd mellan tester. Belastningstester värms upp med korrekta användare och låg samtidighet. Felet uppstår endast vid återanvändning av trådpool med överlappande begäran, ett tillstånd som förekommer i produktion under normal trafik, inte i någon testkonfiguration som söker efter det.
Säkerhetskonsekvensen
Användare A:s principal läcker in i användare B:s begäran. Inte en krasch. Inte ett undantag. En tyst säkerhetsgränsöverträdelse: användare B utför åtgärder som användare A, läser användare A:s data eller kringgår användare B:s behörigheter. Systemet genererar inget fel. Loggar visar att begäran B godkändes. Allt ser korrekt ut.
De fem stegen
De fem stegen i en ThreadLocal-läcka spelar exakt roll: felet inträffar inte när den felaktiga koden körs. Det inträffar tidigare, i avsaknad av ett städsteg.
Scope-anknutna värden
ThreadLocal kopplar ett värde till en tråd. En tråd lever längre än en request. Felaktig livstid.
Scope-attached values kopplar ett värde till en arbetsenhet. När arbetsenheten avslutas, försvinner värdet med den. Ingen explicit cleanup. Inget remove() att glömma.
Java 21: ScopedValue
// ThreadLocal (DEFECT carrier)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // sätts vid request start
// ... request-hantering ...
PRINCIPAL.remove(); // MÅSTE anropas; glöms ofta bort
// ScopedValue (KORREKT bärare)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... hantering av begäran ...
// värdet tas automatiskt bort när run() returnerar
});
Go: context.Context
// context.Context bär värden explicit; scope = funktionsanropskedja
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx skickas explicit; försvinner när funktionen returnerar
Python asyncio: contextvars.ContextVar
# ContextVar är begränsad till varje async task
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # sätts endast för denna task
# ... hantering av task ...
PRINCIPAL.reset(token) # eller: scope avslutas med task
Det gemensamma för dessa: livslängden matchar arbetsenheten. När requesten avslutas (run() returnerar, funktionen returnerar, tasken slutförs), avslutas värdet. Ingen cleanup att glömma. Ingen pool att förstöra.
Identifiera & Ersätt
En Java EE-applikation lagrar tenant-ID i en ThreadLocal vid begärans start. Under hög belastning dyker tenant A:s ID upp i begäran från tenant B. Tenant B:s frågor returnerar tenant A:s data. Inget undantag kastas. Felet syns bara vid produktionslasttestning.