1. CSRF у проєкті: карта операцій
Коли чуєш «CSRF», дуже хочеться одразу відкрити SecurityFilterChain і почати смикати .csrf(...), наче важелі в кабіні літака. Але в проєкті порядок має бути інший: спочатку треба зрозуміти, які операції справді змінюють стан, а вже потім радіти, що Spring Security перевіряє токен. Інакше є ризик зробити «тиху» зміну стану там, де CSRF узагалі не спрацьовує.
З погляду домену нашого проєкту стан змінюється цілком буденно: профіль користувача оновлюється, текст чорнетки змінюється, чорнетка видаляється, чорнетка переходить із DRAFT у SUBMITTED. Це звичайні дії, і саме тут CSRF важливий. Важливо усвідомити: CSRF «прив’язаний» не до URI як рядка, а до сенсу операції. І найпростіший індикатор цього сенсу — HTTP-метод.
У нашому проєкті «чесність» дизайну виглядає так: GET має бути читанням, PATCH/POST/DELETE — зміною. Якщо ми «для зручності» видаляємо чорнетку через GET, то власноруч вибиваємо фундамент з-під CSRF-моделі. Це як прикрутити ремінь безпеки до сидіння на липучку: ніби він є, але в критичний момент перетворюється на декорацію.
2. Міні-аудит API: читання і запис
Коли проєкт росте, новачок часто потрапляє в типову пастку: дивитися на кінцеві точки по одній. У результаті CSRF увімкнено, але «чомусь» ламається то профіль, то чорнетки, то submit. Вихід простий і нудний, а отже правильний: зробити короткий аудит і явно розділити операції на read і write. І так, це той випадок, коли таблиця корисніша за героїзм.
Нижче — мінімальна карта для нашої поточної session-based моделі з form login. Тут уже корисно звести API в одну карту read/write, а не розглядати одну випадкову кінцеву точку:
| Зона | Кінцева точка | Метод | Що відбувається за змістом | CSRF перевіряється? |
|---|---|---|---|---|
| public | /api/public/articles | GET | читаємо опубліковані статті | ні (безпечний метод) |
| public | /api/public/articles/{slug} | GET | читаємо статтю | ні |
| me | /api/me | GET | читаємо «хто я» | ні |
| me | /api/me/profile | GET | читаємо профіль | ні |
| me | /api/me/profile | PATCH | оновлюємо профіль | так |
| drafts | /api/drafts | GET | читаємо список чорнеток | ні |
| drafts | /api/drafts | POST | створюємо чорнетку | так |
| drafts | /api/drafts/{id} | GET | читаємо одну чорнетку | ні |
| drafts | /api/drafts/{id} | PATCH | оновлюємо чорнетку | так |
| drafts | /api/drafts/{id} | DELETE | видаляємо чорнетку | так |
| drafts | /api/drafts/{id}/submit | POST | переводимо чорнетку в SUBMITTED | так |
Зверніть увагу на одну деталь, яку мозок новачка інколи пропускає: POST /api/drafts/{id}/submit — це не «якийсь службовий POST», а цілком справжня зміна стану. Ми змінюємо статус доменного об’єкта, а отже й стан застосунку. І CSRF тут потрібен з тієї самої причини, що й для PATCH або DELETE.
І навпаки: GET /api/drafts/{id} може бути дуже чутливим із погляду приватності (чужу чорнетку читати не можна), але це не кейс для CSRF, бо він не змінює стан. Це інша вісь безпеки (authorization), і ми не змішуємо її з CSRF.
3. Код: контролери і CSRF-фільтр
Контролери: чесні HTTP-методи
Далі переходимо до коду. Тут важлива думка: CSRF-перевірка не має жити в контролері. Контролер повинен бути чесним у сенсі HTTP і домену, а CSRF нехай спокійно перевіряє CsrfFilter ще до того, як ми взагалі потрапимо в @RestController.
Почнемо з профілю. Нам потрібно, щоб один і той самий ресурс можна було читати й змінювати різними методами.
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/me/profile")
public class ProfileController {
@GetMapping
public ProfileDto get() {
// Читання стану: CSRF тут не потрібен, бо це безпечний метод.
return new ProfileDto("Neo", "Я знаю CSRF"); // демонстраційні дані
}
@PatchMapping
public ProfileDto update(@RequestBody UpdateProfileRequest request) {
// Зміна стану: CSRF обов’язковий, фільтр перевірить токен ДО входу сюди.
return new ProfileDto(request.displayName(), request.bio());
}
}
І DTO у форматі, який не лякає новачка (і дуже дружить із JSON):
package com.example.securecontent.profile;
// record зручно збігається за формою з JSON-об’єктом: поля -> ключі, конструктор -> обов’язкові дані.
public record UpdateProfileRequest(String displayName, String bio) { }
public record ProfileDto(String displayName, String bio) { }
Чорнетки — та сама логіка, тільки операцій більше. Важливо не намагатися «економити» на URL і не перетворювати все на POST /api/drafts/doSomething.
package com.example.securecontent.content;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/drafts")
public class DraftController {
@PatchMapping("/{id}")
public void update(@PathVariable long id, @RequestBody UpdateDraftRequest req) {
// Зміна стану: оновлюємо поля чорнетки (title/body).
// Важливо: жодних «зручних» GET-операцій для update.
}
@DeleteMapping("/{id}")
public void delete(@PathVariable long id) {
// Зміна стану: видаляємо чорнетку.
// Якби це був GET, у звичайній конфігурації Spring Security
// CSRF-перевірка не виконувалася б, тому що фреймворк виходить
// з того, що GET залишається читанням.
}
}
DTO для оновлення чорнетки теж можна тримати компактним:
package com.example.securecontent.content;
// Мінімальний контракт для PATCH: лише те, що реально оновлюємо.
public record UpdateDraftRequest(String title, String body) { }
Найважливіше в цьому розділі — не самі методи, а дисципліна: GET не повинен змінювати стан. Якщо ви колись додасте «зручну» кінцеву точку @GetMapping("/{id}/delete"), ви не просто порушите REST-етикет. Ви створите дірку, де CSRF-механіка у звичайній конфігурації Spring Security не працюватиме, бо GET для фреймворку — безпечний метод.
Де спрацьовує CSRF у ланцюжку
CSRF часто плутають із «валідацією в контролері», бо новачок сприймає контролери як «вхід у систему». Але в Spring Security вхід у систему — це фільтри. Отже, CSRF перевіряється до контролера, приблизно там само, де ми вже бачили відновлення SecurityContext.
Якщо намалювати це дуже спрощено, вийде така схема:
flowchart TD
A["Браузер"] -->|cookie JSESSIONID| B["Ланцюжок фільтрів безпеки"]
A -->|заголовок X-CSRF-TOKEN| B
B --> C["CsrfFilter: порівнює очікуване з фактичним"]
C -->|ОК| D["Фільтр авторизації"]
D -->|ОК| E["DispatcherServlet"]
E --> F["@RestController"]
C -->|Помилка| X["403 (контролер не виконується)"]
За замовчуванням Spring Security робить цю перевірку саме для небезпечних методів і пропускає GET, HEAD, TRACE, OPTIONS. Тому чесні HTTP-методи тут — не формальність, а опора для моделі безпеки.
Важлива психологічна користь цієї схеми: вона пояснює, чому під час CSRF-відмови ви можете не побачити логів свого контролера чи сервісу. Вони просто не запускаються. І це нормально: захисний шар спрацював раніше.
Ще один тонкий момент: CSRF і authorization не конкурують. Вони відповідають на різні запитання. Authorization відповідає: «чи можна цьому користувачеві». CSRF відповідає: «чи схоже, що запит надійшов із коректного браузерного сценарію, а не зі сторінки зловмисника». Якщо відповідь на будь-яке з цих запитань — «ні», запит має завершитися ще до бізнес-коду. Іноді це виглядає жорстко, зате все передбачувано.
4. Практика: PATCH профілю з токеном
Давайте тепер зробимо те, заради чого й існує ця лекція: візьмемо один endpoint, який змінює стан, і подивимося на нього без містики. Як приклад ідеально підходить PATCH /api/me/profile, бо він дуже життєвий: користувач змінює displayName і bio.
Уявімо звичайну для сесійного застосунку ситуацію: застосунок запущено, formLogin увімкнено, користувач увійшов, у браузері з’явилася cookie JSESSIONID. Тепер ми хочемо надіслати PATCH-запит. Для цього нам потрібні дві речі: зберегти цю сесію (cookie) та надіслати CSRF-токен.
Для навчального налагодження зручно мати тимчасову debug-кінцеву точку, доступну лише локально. У продуктовому сценарії браузеру це не потрібно: форму і token він збирає сам. Але для curl або Postman корисно тимчасово розширити вже знайому debug-кінцеву точку й додати туди саме значення токена. Це не «фіча» продукту, а інструмент, щоб вручну побачити зв’язок token + session. Таку кінцеву точку краще тримати під профілем local або ховати за /debug/**.
package com.example.securecontent.security.debug;
import java.util.Map;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
public class CsrfDebugController {
@GetMapping("/debug/csrf")
Map<String, String> csrf(CsrfToken token) {
// Лише для локального середовища: для ручної діагностики показуємо не лише назви,
// а й саме значення token. Це не частина продуктового API.
return Map.of(
"headerName", token.getHeaderName(),
"parameterName", token.getParameterName(),
"token", token.getToken()
);
}
}
Тепер сценарій руками виглядає так (і так, це той рідкісний випадок, коли curl допомагає зрозуміти Spring Security швидше, ніж 40 хвилин філософії):
# 1) У браузері входимо через /login (створюємо сесію)
# 2) У тому самому браузері відкриваємо /debug/csrf, доступний лише локально, і копіюємо token із JSON.
# Паралельно в DevTools дивимося на cookie JSESSIONID і копіюємо її значення.
# 3) Виконуємо PATCH із cookie + CSRF header
curl -i \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X PATCH \
-d '{"displayName":"Trinity","bio":"PATCH — це моє кардіо"}' \
http://localhost:8080/api/me/profile
Якщо ви приберете заголовок X-CSRF-TOKEN, але залишите cookie, побачите «злу» відмову. І це дуже показово: сервер упізнав користувача за session cookie, але не отримав підтвердження коректного браузерного сценарію. Саме в цьому й полягає сенс CSRF.
А якщо ви приберете cookie, але залишите token, теж буде відмова, тому що token у нашій поточній моделі живе в HttpSession, і без session-контексту він перетворюється на просто випадковий рядок. CSRF-токен не заміщує автентифікацію, а доповнює її.
5. Чорнетки: PATCH, DELETE, submit
Після профілю чорнетки зазвичай збивають новачка з пантелику з двох причин. По-перше, їх більше. По-друге, серед них є операції, які схожі на робочий процес (submit), і їх хочеться сприймати як виняток із правил. Насправді тут усе буденно й правильно: якщо операція змінює стан домену, CSRF стосується її так само, як update/delete.
Візьмемо оновлення чорнетки:
# PATCH змінює стан -> потрібні CSRF token і та сама сесія (cookie)
curl -i \
-H "Content-Type: application/json" \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X PATCH \
-d '{"title":"Новий заголовок","body":"Новий текст"}' \
http://localhost:8080/api/drafts/10
Видалення чорнетки — те саме, тільки без body:
# DELETE змінює стан -> також потребує CSRF token
curl -i \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X DELETE \
http://localhost:8080/api/drafts/10
І тепер submit. Дуже хочеться зробити його «як посилання», бо «це ж просто дія». Але з погляду безпеки submit — зміна стану, а отже він має залишатися unsafe method.
# submit змінює статус доменного об’єкта -> це теж зміна стану
curl -i \
-H "X-CSRF-TOKEN: PASTE_TOKEN_HERE" \
-H "Cookie: JSESSIONID=PASTE_SESSION_ID_HERE" \
-X POST \
http://localhost:8080/api/drafts/10/submit
Якщо у вас усе це починає працювати з token, а без token — ні, то ви зробили важливу річ: переконалися, що CSRF у проєкті — не міфічна магія Spring, а передбачуване правило «небезпечні методи потребують додаткового доказу коректного сценарію».
6. Правила дизайну для CSRF
У навчальному проєкті особливо легко випадково зламати CSRF-модель, бо хочеться швидше побачити результат, а не тримати дисципліну дизайну API. Але CSRF якраз любить дисципліну: він побудований на припущенні, що safe methods залишаються безпечними. Тому нам потрібні прості, майже побутові правила, які тримають проєкт в адекватному стані.
Корисно мислити не налаштуваннями Spring Security, а такою парою «поганий/хороший» дизайн (і так, це нудно — отже, працюватиме):
| Задача | Поганий варіант | Хороший варіант |
|---|---|---|
| Видалити чорнетку | GET /api/drafts/{id}/delete | DELETE /api/drafts/{id} |
| Надіслати на модерацію | GET /api/drafts/{id}/submit | POST /api/drafts/{id}/submit |
| Оновити профіль | POST /api/me/profile/update | PATCH /api/me/profile |
У хороших варіантах захист CSRF стає природним. Spring Security і браузерна модель допомагають вам, бо ви не йдете проти смислу HTTP. У поганих варіантах ви самі прибираєте ті ознаки, за якими захист має спрацьовувати. А потім починається фольклор: «Spring Security знову щось чудить». Ні, це не Spring. Це ми.
І тут же важлива дисципліна мислення: request-level авторизація і CSRF — це два шари. Навіть якщо PATCH /api/drafts/{id} доступний лише автентифікованому користувачу, CSRF усе одно потрібен, бо він відповідає на інше запитання. Не «чи може», а «чи точно він сам натиснув кнопку, а не його браузер слухняно виконав чужу команду».
7. Типові помилки під час CSRF
У цій темі легко впасти в два крайні стани: або «я все зрозумів, CSRF — це токен» (і потім застрягнути на першому ж 403), або «та ну його, вимкну й поїду далі» (і сформувати в голові дуже небезпечну звичку). Тому помилки тут корисно проговорювати не як страшилки, а як нормальні, майже неминучі граблі.
Помилка №1: ховати зміну стану за GET, бо «так зручніше клацати посиланням».
Зручніше — так, але рівно до моменту, коли ви розумієте, що CSRF-захист не спрацьовує за означенням, бо GET у моделі HTTP має бути безпечним методом. У нашому проєкті видалення і submit мають залишатися DELETE і POST, навіть якщо це здається «зайвою складністю».
Помилка №2: думати, що CSRF прив’язаний до URI, а не до операції.
Новачок бачить /api/me/profile і вважає, що «цей URL або CSRF-ний, або ні». На практиці CSRF стосується PATCH /api/me/profile, але не стосується GET /api/me/profile. Це один і той самий URI, але дві різні операції. Якщо це прийняти, половина плутанини зникає.
Помилка №3: дивитися на кінцеві точки по одній, а не як на карту дій read/write.
Коли у вас п’ять контролерів і двадцять методів, мозок починає «оптимізувати» й забуває про submit, delete, update. Табличка аудиту (як у середині лекції) банально економить нерви: видно, де є зміни стану, а отже де CSRF обов’язково має бути.
Помилка №4: плутати CSRF-відмову з проблемою ролей або логіну.
CSRF-відмова може виглядати як 403, і це автоматично тягне думку «мені бракує ролі». Але CSRF — не про роль. Це про те, що запит не підтвердив коректний сценарій. Саме тому важливо спочатку перевіряти cookie session і token, а вже потім дивитися на hasRole(...).
Помилка №5: випадково «загубити сесію», а потім дивуватися, що token «раптом перестав працювати».
Token у нашому базовому сценарії живе в HttpSession. Якщо сесія змінилася або спливла, очікуване значення зникає, і наступний запит, що змінює стан, падає. Тут корисно пам’ятати: CSRF-токен і сесія — це зв’язка, а не незалежні сутності.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ