JavaRush /Курси /Spring Security /CSRF у проєкті:

CSRF у проєкті: update і delete

Spring Security
Рівень 11 , Лекція 3
Відкрита

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-токен і сесія — це зв’язка, а не незалежні сутності.

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ