ThreadLocal: правильна ідіома, неправильна епоха
Java EE Servlet-контейнери, приблизно 1999 рік: один потік на запит. Потік обробляє рівно один запит від початку до кінця, а потім завершується. ThreadLocal зберігає значення, прив’язане до поточного потоку. За моделі «один потік — один запит» значення, збережене в ThreadLocal, належить саме одному запиту. Ідіома: правильна.
Пули потоків змінили контракт. Потік обробляє запит A, зберігає principal A у ThreadLocal, завершує запит A та повертається до пулу. Пули потоків не скидають стан потоку. ThreadLocal.remove() очищує стан, але його виклик потребує явної дисципліни. Коли дисципліна відсутня, запит B виконується на тому ж потоці та зчитує principal A з ThreadLocal.
5-етапний витік:
1. Приходить запит A. Сервер призначає Thread-7.
2. Thread-7 викликає ThreadLocal.set(principal_A) на початку запиту.
3. Запит A завершується. Thread-7 повертається до пулу. ThreadLocal.remove() не викликано.
4. Приходить запит B. Сервер призначає Thread-7 (повторне використання з пулу).
5. Thread-7 викликає ThreadLocal.get(): повертає principal_A. Запит B виконується під неправильною ідентичністю.
Чому тести цього не виявляють
Юніт-тести виконуються ізольовано: немає пулу потоків, немає повторного використання. Інтеграційні тести використовують свіжі потоки або скидають стан між тестами. Навантажувальні тести прогріваються з правильними користувачами та низькою конкуренцією. Дефект проявляється лише за повторного використання потоків з пулу при накладених запитах — умові, яка виникає в продакшені за звичайного навантаження, а не в жодній тестовій конфігурації, яка б це перевіряла.
Наслідки для безпеки
Принципал користувача A «протікає» в запит користувача B. Не падіння. Не виняток. Тихе порушення межі безпеки: користувач B виконує дії від імені A, читає дані A або обходить дозволи B. Система не видає помилки. Логи показують, що запит B авторизовано. Усе виглядає коректно.
П’ять кроків
П’ять кроків витоку ThreadLocal важливі саме тому, що дефект виникає не в момент виконання неправильного коду. Він виникає раніше — через відсутність кроку очищення.
Значення, прив’язані до області видимості
ThreadLocal прив’язує значення до потоку. Потік живе довше за запит. Невідповідність.
Значення, прив’язані до області видимості, прив’язують значення до одиниці роботи. Коли одиниця роботи завершується, значення завершується разом із нею. Без явного очищення. Без remove(), щоб забути.
Java 21: ScopedValue
// ThreadLocal (носій ДЕФЕКТУ)
static final ThreadLocal<Principal> PRINCIPAL = new ThreadLocal<>();
PRINCIPAL.set(principal); // встановлюється на початку запиту
// ... обробка запиту ...
PRINCIPAL.remove(); // ОБОВ’ЯЗКОВО викликати; часто забувають
// ScopedValue (ПРАВИЛЬНИЙ носій)
static final ScopedValue<Principal> PRINCIPAL = ScopedValue.newInstance();
ScopedValue.where(PRINCIPAL, principal).run(() -> {
// ... обробка запиту ...
// значення автоматично зникає після повернення run()
});
Go: context.Context
// context.Context явно передає значення; область = ланцюжок викликів функцій
ctx := context.WithValue(r.Context(), principalKey, principal)
handleRequest(ctx) // ctx передається явно; зникає після повернення функції
Python asyncio: contextvars.ContextVar
# ContextVar, обмежений кожною асинхронною задачею
PRINCIPAL: ContextVar[str] = ContextVar('principal')
token = PRINCIPAL.set(principal) # встановити лише для цієї задачі
# ... обробка задачі ...
PRINCIPAL.reset(token) # або: область завершується разом із задачею
Властивість, яку вони мають спільну: час життя збігається з одиницею роботи. Коли запит завершується (run() повертає результат, функція повертає результат, задача завершується), значення завершується. Немає потреби в очищенні, яке можна забути. Немає пулу, який можна пошкодити.
Identify & Replace
Java EE-застосунок зберігає ID орендаря в ThreadLocal на початку запиту. Під високим навантаженням ID орендаря A з’являється в запитах орендаря B. Запити орендаря B повертають дані орендаря A. Винятків не виникає. Дефект проявляється лише під час навантажувального тестування в продакшені.