JavaRush /Курси /Spring Security /CSRF: вхід, вихід і дані

CSRF: вхід, вихід і дані

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

1. CSRF: дані і security-state

Якщо на мить відкласти терміни й подивитися на застосунок здоровим глуздом, ми побачимо: система живе в станах. У користувача є «увійшов/не увійшов», у чернетки є «DRAFT/SUBMITTED», у профілю є «старе значення/нове значення». CSRF не вибирає «улюблені» сутності: йому важливий сам факт, що запит змінює стан чогось значущого.

Тут корисно чітко розрізнити два види стану, які змінюють запити. Бізнес-стан — це ваші доменні дані: профіль, чернетки, статуси контенту. Security-state — це те, ким браузер вважається у поточній сесії: анонімним користувачем чи конкретним користувачем, а також який набір ролей йому призначено. Саме login і logout і перемикають security-state.

Давайте зафіксуємо це в невеликій табличці — без філософії, максимально прикладно:

Операція Що реально змінюється Чому це тема CSRF
PATCH /api/me/profile Профіль користувача (бізнес-стан) Браузер може надіслати запит із cookie сам, тож треба підтвердити, що дію ініціював ваш UI
DELETE /api/drafts/{id} Дані (чернетка зникає) Те саме: «видалення» — це сильна зміна стану
POST /loginformLogin) Security-state: сесія стає автентифікованою Якщо зловмисник «підкладе» логін, жертва опиниться залогіненою не туди
POST /logout Security-state: сесія перестає бути автентифікованою Якщо logout можна підробити, користувача можна «викидати» із системи без його наміру

Інакше кажучи, CSRF-захист не про «дані vs не дані». Він про те, що змінює стан, і те, що його не змінює. Згадайте формулювання з першої лекції: уразливість живе там, де браузер автоматично додає автентифікаційний контекст. А login і logout якраз змінюють, який контекст буде додаватися далі.

2. Login CSRF і зміна security-state

Коли ми говоримо «логін», мозок автоматично малює картину: користувач сам вводить логін і пароль, натискає «Увійти», система перевіряє — і все. Але з погляду атакувального цікавіше інше питання: чи можна змусити браузер жертви виконати логін без її наміру? Це і є типовий сценарій «login CSRF» (іноді його називають forced login).

Небезпека в тому, що login змінює не просто якийсь прапорець. Він змінює відповідь на питання «хто ви?» у межах поточної сесії. Далі всі ваші захищені кінцеві точки починають працювати вже «від імені» того, ким стала сесія. Якщо атакувальний зможе підкласти логін на свій обліковий запис, жертва може почати діяти в системі як «хтось інший» і навіть не помітити підміни, особливо якщо UI не надто явно показує ім’я користувача.

Уявімо злу, але життєву історію. Користувач відкриває якийсь сторонній сайт (хай навіть «10 способів прискорити Java у 200 разів», ми всі на таке натрапляли). Цей сайт підсовує в браузер запит на POST /login, де в тілі форми лежать логін і пароль облікового запису атакувального. Якщо у вашому застосунку login endpoint не захищений механізмом CSRF, сервер може автентифікувати сесію і «прив’язати» її до облікового запису атакувального. Далі користувач чесно заходить у ваш Secure Content Platform API, створює чернетку, пише туди щось особисте… тільки пише він це в обліковий запис атакувального. Отже, «викрадення даних» сталося без крадіжки пароля жертви.

Намалюємо цей сценарій у вигляді діаграми. Вона трохи театральна, але корисна — допомагає побачити механіку очима:

sequenceDiagram
    %% Ідея: зловмисник ініціює логін на свій обліковий запис через браузер жертви
    %% Ключовий момент: браузер автоматично додає cookie (якщо вони є)

    participant V as "Жертва (браузер)"
    participant E as "Шкідливий сайт"
    participant A as "Secure Content Platform API"

    V->>E: Відкриває сторінку зловмисника
    E->>V: "Підсовує форму з автонесенням на /login"
    V->>A: "POST /login (логін/пароль атакувального) + cookie, якщо вони є"
    A->>A: "Автентифікація, заповнення SecurityContext, сесія стає автентифікованою"
    A-->>V: "302/200 (залежно від потоку)"
    V->>A: "Далі жертва ходить у /api/me, /api/drafts..."
    A->>A: "Сервер вважає, що це обліковий запис атакувального"

І ось тут головний висновок лекції: login — це операція, що змінює стан, бо вона змінює «security-ідентичність» поточної сесії. Тому захищати CSRF-токеном потрібно не лише «бізнесові» PATCH/DELETE, а й сам вхід.

Зв’язок зі Spring Security та formLogin

Гарна новина: якщо ви використовуєте стандартний formLogin і не ламаєте налаштування навмисно, Spring Security уже за замовчуванням ставиться до login як до операції, що змінює стан. Наприклад, сторінка входу, яку Spring показує з коробки, надсилає POST і містить CSRF-токен як приховане поле форми. Важливий сам факт: навіть вхід потребує CSRF-токена, бо він змінює стан безпеки.

У нашому застосунку SecurityFilterChain уже містить formLogin і logout. У мінімальному вигляді це виглядає так (фрагмент, який ви вставляєте в наявний конфіг):

import org.springframework.context.annotation.Bean;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    // Увімкнімо стандартний formLogin: обробка POST /login буде частиною security-рівня
    http.formLogin(form -> {});

    // Вихід теж змінює security-state (інвалідація сесії/очищення контексту безпеки)
    http.logout(logout -> {});

    // Збираємо ланцюжок фільтрів Spring Security
    return http.build();
}

Це проста, але важлива думка: login і logout живуть не в «бізнес-контролерах», а на security-рівні, бо вони змінюють саме security-state. Тому і CSRF-логіка теж там.

3. Logout: не можна виходити через GET

З logout ситуація ще показовіша, бо новачок часто сприймає його як «нешкідливу кнопку»: ну вийшли й вийшли, нічого ж не видалили. Насправді logout — це операція, яка змінює стан безпеки системи: ви викидаєте користувача з автентифікованого стану й очищуєте або робите недійсним те, що тримало його в стані «увійшов» (найчастіше — сесію або SecurityContext у сесії).

Якщо logout можна підробити, зловмисник отримує можливість «смикати рубильник» чужої сесії. Це може бути не такий драматичний ризик, як видалення даних, але цілком реальний: постійні forced logout можуть ламати сценарії користувача, заважати йому редагувати профіль, створювати чернетки, виконувати важливі дії. А іноді, у складних системах, forced logout може стати частиною більшої атаки: користувача викинули із системи → підсунули повторний вхід у «не той» обліковий запис → жертва продовжила роботу в неправильному контексті.

Тепер ключовий момент, який варто запам’ятати як правило хорошого тону: якщо CSRF увімкнено, Spring Security за замовчуванням робить logout через POST, а не через GET. Чому? Бо GET має бути «safe method» (в ідеальній архітектурі — чистим читанням), а logout — це зміна стану. Робити logout через GET — усе одно що повісити кнопку «Вийти» на банері: будь-хто може «натиснути» її за вас, просто змусивши браузер завантажити URL.

Якщо уявити поганий світ, де logout доступний через GET /logout, то зловмиснику навіть JavaScript не потрібен. Достатньо вставити на сторінку зображення:

<!-- ПОГАНО: якщо /logout доступний через GET, браузер одразу смикне його під час завантаження зображення -->
<img src="https://ваш-сайт/logout">

Браузер із ввічливості завантажить це зображення, автоматично надішле cookie вашої сесії — і все, користувача розлогінено. Зображення, звісно, не завантажиться (бо URL не є зображенням), але logout уже відбувся, а браузер не образився. Він узагалі рідко ображається — просто працює.

Spring Security намагається захистити вас від такого «нешкідливого» самообману. Тому в нормальній конфігурації, навіть якщо ви нічого не кастомізували, logout — це POST /logout і він під захистом CSRF.

Антипатерн: «давайте дозволимо logout через GET — так зручніше»

Іноді розробник бачить, що logout не спрацьовує «посиланням» (або в браузері під час прямого переходу на /logout), і приймає рішення рівня «аби працювало»: дозволяє logout через GET. Технічно так можна зробити, Spring це дозволяє, але саме в контексті CSRF це добрий приклад того, як не треба.

Ось як виглядає такий «shortcut» (показую як анти-приклад — не як рекомендацію):

import org.springframework.security.web.util.matcher.AntPathRequestMatcher;

// ПОГАНО: logout через GET перетворює logout на CSRF-мішень
http.logout(logout -> logout
    // Тут ми явно говоримо Spring Security: "вважай GET /logout logout-запитом"
    .logoutRequestMatcher(new AntPathRequestMatcher("/logout", "GET"))
);

Цей рядок робить logout «посиланням». Але разом із цим він робить logout легкою мішенню для підробки запитів. У навчальному проєкті ми якраз тренуємося бачити такі рішення не як «ура, запрацювало», а як «що я зараз зламав у моделі безпеки?»

4. CSRF vs ролі: різні перевірки

Дуже часта плутанина у початківців виглядає так: «Якщо мій endpoint вимагає authenticated(), навіщо ще CSRF? Адже атакувальний не залогінений!» І тут треба згадати ключову ідею: CSRF якраз і працює в сценарії, коли користувач вже залогінений, а браузер сам додає cookie сесії.

Перевірки ролей/authorities відповідають на питання «чи можна цьому користувачеві?». А CSRF відповідає на питання «чи ініційовано цю дію коректним сценарієм?». Це дві незалежні перевірки, і кожна може бути істинною або хибною незалежно.

Уявіть запит PATCH /api/me/profile. Він має бути дозволений користувачеві, який увійшов (authorization), але його також має ініціювати «наш UI» (CSRF). Якщо CSRF вимкнути, зловмисник може ініціювати запит від імені залогіненого користувача, і authorization чесно скаже: «Так, це USER, йому можна змінювати профіль». Spring Security не телепат: якщо ви не увімкнули CSRF, він не дізнається, що запит прилетів із чужої сторінки.

Саме тому в SecurityFilterChain ви зазвичай бачите і правила доступу, і CSRF-захист. Приблизно в такому стилі (сильно спрощено, щоб побачити ідею):

import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;

@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
    http.authorizeHttpRequests(auth -> auth
        // Authorization: "кому можна" (перевірка прав/автентифікації)
        .requestMatchers(HttpMethod.PATCH, "/api/me/profile").authenticated()
        .anyRequest().permitAll()
    );

    // Важливо: CSRF — окрема перевірка, вона живе у фільтрах і вмикається окремою конфігурацією
    return http.build();
}

Так, тут ми не пишемо «csrf» явно — поки що. Але думка така: authorization задає «кому можна», CSRF задає «як має виглядати коректний запит у браузерній моделі». А login і logout — це точно такі самі операції, що змінюють стан, просто вони змінюють не профіль, а саме security-state.

5. Де це видно у проєкті

У проєкті корисно тримати в голові одне правило: у session-based гілці CSRF стосується всіх unsafe methods, які змінюють або бізнес-стан, або security-state. Профіль, чернетки й submit-сценарії підпорядковуються тій самій логіці, що й будь-який інший PATCH / POST / DELETE: cookie показує, що в браузера є сесія, але не доводить, що дію справді ініціював ваш інтерфейс.

Для поточної лекції особливо важливі дві точки, які легко недооцінити:

POST /login — змінює security-state: сесія стає автентифікованою.

POST /logout — теж змінює security-state: сесія очищується, і браузер перестає вважатися користувачем, який увійшов.

Це допомагає не застрягти в хибній картині «CSRF лише про PATCH профілю і DELETE чернетки». Він з’являється всюди, де браузер із cookie-сесією змінює стан — просто в одних місцях це доменні дані, а в інших — сама картина безпеки користувача.

6. Типові помилки під час CSRF login/logout

Помилка №1: «CSRF — це лише про бізнес-дані, а login/logout тут ні до чого».
Таке мислення виникає, коли ми вважаємо стан системи лише таблицями в базі, а security-state сприймаємо як «службову дрібницю». На практиці security-state — такий самий стан системи, як статус чернетки. Login і logout змінюють цей стан, і зловмиснику часто вигідно смикати саме ці перемикачі, щоб керувати поведінкою користувача.

Помилка №2: «Logout — нешкідливий, хай буде GET, так зручніше».
Зручність у браузері часто оманлива. Logout через GET перетворюється на «посилальну» операцію, яку легко ініціювати з чужої сторінки. Так, це не видалення всіх даних, але це реальний важіль: користувача можна викидати із системи, ламаючи сценарії. У навчальному проєкті ми тренуємося обирати безпеку й передбачуваність, а не те, «щоб клікалося».

Помилка №3: «Якщо endpoint захищений ролями, CSRF не потрібен».
Ролі і CSRF вирішують різні задачі. Ролі говорять: «цей користувач має право», CSRF — «ця дія прийшла з коректного UI-сценарію». У CSRF-атаці користувач уже має роль і вже увійшов — інакше атака зазвичай просто не працює. Тому роль не рятує від CSRF, вона просто існує паралельно.

Помилка №4: «Якщо я не бачу CSRF у коді контролера, значить його немає».
Це класика Spring Security: CSRF-перевірка виконується на рівні фільтрів, до контролера. Тому відсутність «ручної перевірки токена» в методі контролера — це якраз нормальний знак: ви довірили захист інфраструктурі, а не рознесли його по бізнес-коду.

Помилка №5: «Простіше вимкнути CSRF на /login і /logout, там же немає даних».
Login і logout — це зміна security-state, а отже вимкнення CSRF саме на цих точках повертає вас у світ forced login і forced logout. Якщо ви робите винятки, робіть їх свідомо й з розумінням браузерної моделі, а не тому, що «так зручніше тестувати».

1
Задача
Spring Security, 11 рівень, 1 лекція
Недоступна
Default login page як частина security-state
Default login page як частина security-state
1
Задача
Spring Security, 11 рівень, 1 лекція
Недоступна
Bash-аудит read-only, business-state і security-state дій
Bash-аудит read-only, business-state і security-state дій
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ