1. Користувач уже увійшов у систему
Коли вперше чуєш «CSRF», хочеться одразу запитати: «Окей, де тут токен і яку галочку мені поставити в конфігурації?». Але якщо почати з токена, вийде класичне «не розумію, але копіюю». Тому почнемо з того, що в нас уже добре працює: користувач відкрив браузер, увійшов через form login і тепер спокійно ходить захищеними кінцевими точками.
У моделі на основі сесій усе тримається на простій ідеї: сервер видав браузеру cookie JSESSIONID, і далі браузер сам додає цю cookie в кожен запит до нашого домену. Сервер бачить cookie → знаходить відповідну HttpSession → відновлює SecurityContext → розуміє, що запит іде від уже автентифікованого користувача. У цей момент початківець зазвичай радіє, ніби зібрав IKEA-шафу без жодного зайвого гвинтика. І це справді зручно… доки ми не подивимося на цю саму «зручність» очима атакувальника.
Важливо зауважити одну тонкість: cookie — це не «доказ того, що користувач саме зараз хотів зробити саме цю дію». Cookie — це доказ того, що браузер зараз має технічну можливість звернутися до сервера в контексті вже створеної сесії. Тобто cookie відповідає на запитання «у вас є перепустка?», але не відповідає на запитання «ви самі вирішили зайти саме в ці двері?».
2. Що таке CSRF: дія від імені користувача без крадіжки пароля
CSRF (Cross-Site Request Forgery) найпростіше зрозуміти як «підробку дії», а не як «злам пароля». Тут ніхто не підбирає credentials, не ламає хеші, не лізе в базу, не читає ваш application.yml (хоча це інколи звучить спокусливо). CSRF — це коли зловмисник змушує браузер жертви надіслати запит на ваш сайт у контексті вже автентифікованого користувача.
Тобто жертва вже увійшла в систему чесно. Атака не про те, щоб стати цим користувачем назавжди. Атака про те, щоб у моменті виконати якусь дію, що змінює стан: оновити профіль, видалити чернетку, надіслати чернетку на модерацію, змінити email (якби він був) тощо.
На пальцях сценарій виглядає так:
sequenceDiagram
participant U as Користувач
participant B as Браузер
participant A as Сайт зловмисника
participant S as API платформи захищеного контенту
U->>B: "Увійшов у S (отримує JSESSIONID)"
Note over B,S: Браузер тепер автоматично надсилає cookie до S
U->>B: Відкриває сайт A (в іншій вкладці)
A->>B: Підсовує приховану форму/запит до S
B->>S: Надсилає запит на S + автоматично додає JSESSIONID
S->>S: "О, це ж автентифікований користувач!"
S->>S: "Виконує дію (наприклад, delete/update)"
S-->>B: Повертає відповідь (часто неважливо, чи побачить її A)
Ключовий момент: зловмисник не зобов’язаний бачити відповідь. Йому часто достатньо самого факту, що дія відбулася. Наприклад, якщо мета — розлогінити користувача (logout), зіпсувати його профіль, перевести чернетку в статус SUBMITTED або видалити її. Навіть якщо користувач не зрозумів, що сталося, усе вже зроблено.
3. Браузер і автоматичні cookies
Щоб CSRF перестав бути містикою, потрібно на секунду прийняти неприємну думку: браузер — дуже старанний кур’єр. Він настільки старанний, що інколи доставляє «посилку» туди, куди ви не просили. Якщо браузер бачить запит до https://наш-домен, він за своїми правилами автоматично додає cookies для цього домену (якщо cookies не обмежені, але сьогодні ми в це не заглиблюємося).
І тут з’являється важливий «ха-ха, але не смішно» нюанс. Ми часто думаємо: «Ну і що, що інший сайт надішле запит? Адже діє same-origin policy!». І ось тут багато хто плутається.
Same-origin policy (дуже спрощено) захищає нас від того, що чужий сайт прочитає відповідь від нашого сайту та вкраде дані. Але вона не заважає чужому сайту ініціювати надсилання запиту, особливо якщо це робиться «браузерними» засобами на кшталт HTML-форми.
І ось чому CSRF — це зазвичай атака на зміну стану. Зловмиснику не потрібно читати відповідь. Йому потрібно, щоб сервер виконав команду.
Найбуденніший (і трохи дратівливий) приклад: уявіть, що у вас є endpoint «видалити чернетку». Якщо браузер увійшов у систему, то запит, який видалить чернетку, цілком може бути надісланий «не тим екраном». Браузер, звісно, відрізняє домени, але не розрізняє «це запит зі сторінки нашого SPA» і «це запит зі сторінки зловмисника, де кнопку ви взагалі не натискали».
4. Запити, що змінюють стан, і CSRF
Щоб не перетворити CSRF на містичну сутність рівня «воно просто інколи ламає Postman», потрібно домовитися: CSRF цікавлять лише запити, що змінюють стан. Стан застосунку — це не тільки «рядки в базі даних». Це будь-яка операція, після якої «у світі стало інакше»: запис створено, змінено, видалено, статус переведено, сесію змінено, щось надіслано на модерацію тощо.
Дуже зручно мислити запитами, що змінюють стан, через HTTP-методи (так, я знаю: «а в нашому проєкті все одно POST усюди» — ось саме тому потім і боляче).
Давайте зафіксуємо базову інженерну табличку. Вона не замінює RFC і не робить вас автором HTTP-стандарту, але чудово допомагає не стріляти собі в ногу.
| HTTP-метод | Safe (лише читання) | Типовий зміст | CSRF-ризик у session/cookie-моделі |
|---|---|---|---|
| GET | так | читання | зазвичай ні, якщо не змінює стан |
| HEAD | так | читання метаданих | зазвичай ні |
| OPTIONS | так | «що можна?» | зазвичай ні |
| POST | ні | створення/дія | так |
| PUT | ні | заміна ресурсу | так |
| PATCH | ні | часткове оновлення | так |
| DELETE | ні | видалення | так |
Якщо спростити до однієї фрази: CSRF живе там, де браузер автоматично додає «ключ від вашої сесії», а запит потенційно змінює стан.
5. Безпечні HTTP-методи та семантика
Коли ви тільки починаєте писати backend, дуже легко спокуситися поганою ідеєю: «А давайте видалення зробимо через GET, так простіше тестувати в браузері!». Або: «Посилання ж зручніше, ніж DELETE, бо DELETE в адресному рядку не введеш». Це справді зручно… приблизно як зберігати пароль на стікері на моніторі: зручно, доки не прийшла реальність.
GET вважається безпечним методом. Це не означає «безпечний у плані хакерів», це означає: він не повинен змінювати стан системи. На цьому очікуванні зав’язана величезна кількість поведінки навколо: кешування, prefetch, link preview, «розумні» браузерні оптимізації, боти, пошукові роботи. Ви здивуєтеся, скільки сутностей на світі готові «просто сходити за посиланням», навіть без вашого кліку.
Тепер уявіть, що ви зробили видалення так:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DraftController {
@GetMapping("/api/drafts/{id}/delete")
public void deleteDraftViaGet(@PathVariable Long id) {
// Антипатерн: GET не повинен змінювати стан.
// Таке видалення може випадково спрацювати через посилання, префетч або попередній перегляд у месенджері.
}
}
На рівні «воно ж працює» — так, працює. Але ви щойно перетворили видалення на дію, яку можна випадково викликати посиланням, зображенням або префетчем.
Протилежний (правильний за змістом) варіант виглядає так:
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DraftController {
@DeleteMapping("/api/drafts/{id}")
public void deleteDraft(@PathVariable Long id) {
// Тут дія явно змінює стан: чернетку буде видалено.
// Такий endpoint і за змістом, і за інструментами захисту (зокрема CSRF) очікувано «небезпечний».
}
}
І це не «причіпка до стилю». Це частина фундаменту, на якому тримається нормальна web-security модель. Якщо ви ламаєте семантику HTTP, далі ви змушені вигадувати костилі, а потім дивуватися, чому Spring Security «вередує».
Плюс, у контексті CSRF є прямий практичний ефект: багато CSRF-сценаріїв історично реалізовувалися саме через прості механізми «підсунь посилання/зображення/форму». Якщо дія, що змінює стан, доступна через GET, ви зробили зловмиснику практично подарунок. Не той, що вам захочеться отримати, а інший — той, що вам не сподобається.
6. Автентифікація vs намір
До цього місця часто виникає запитання: «Почекайте. Але ж у нас є автентифікація! У нас же користувач увійшов у систему, отже це він зробив запит, хіба ні?»
З погляду сервера запит дійсно виглядає як від користувача: cookie валідна, session жива, SecurityContext відновився. Але CSRF-загроза якраз і каже: «а хто сказав, що цей запит ініційовано вашою сторінкою, а не чужою?»
Це взагалі окрема категорія в голові: перевірка права і перевірка джерела наміру — різні задачі.
Ролі та повноваження відповідають на запитання «чи може користувач видаляти чернетку?». Власник — може.
CSRF-захист відповідає на інше запитання: «цей запит справді надійшов із коректного користувацького сценарію (з вашого застосунку), а не був згенерований зовнішньою сторінкою в момент, коли користувач просто сидів залогіненим?»
Тобто CSRF не конкурує з ролями. CSRF доповнює session-based модель: «добре, ти той самий користувач, але доведи ще, що це твоя операція, а не операція, яку під тебе підсовують».
І тут ми підходимо до тієї самої ідеї токена, але поки без Spring DSL і без класів.
7. Інтуїція CSRF token
CSRF token — це додаткове непередбачуване значення, яке має прийти разом із запитом, що змінює стан. Ідея дуже проста: браузер автоматично додає cookies, але не повинен мати можливості автоматично «вгадати й приклеїти» токен, якщо запит було ініційовано не вашим застосунком.
Людською мовою це звучить так: «Кур’єр (браузер) носить вашу перепустку (cookie) в кишені та показує її всім дверям вашого дому. Але для небезпечних дій (видалення/зміна) ми просимо ще й кодове слово, яке лежить не в кишені кур’єра, а видається й контролюється вашим застосунком».
Часто цей захист описують як Synchronizer Token Pattern: у сервера є очікуване значення, у запиту є надіслане значення, і сервер порівнює їх.
Дуже важливо зрозуміти один тезис із сьогоднішньої лекції: зберігати CSRF token лише в cookie недостатньо, тому що cookie — це якраз той автоматичний канал. Якщо токен поїде тим самим потягом, що й сесія, атакувальна сторінка знову зможе скористатися браузером жертви як кур’єром. Тому CSRF token має приходити з каналу, який зовнішня сторінка не може коректно відтворити. На практиці це часто означає, що токен передається:
— або в тілі форми (як приховане поле), яке ваша сторінка вміє згенерувати,
— або в спеціальному заголовку, який звичайна HTML-форма не додасть сама по собі.
Поки важливо схопити саму інтуїцію: чому cookie не рятує і чому потрібен другий канал для передавання токена.
Для закріплення — маленька схема «нормального» світу:
flowchart TD
Page[Наша сторінка застосунку] -->|отримує token| Token[CSRF token]
Page -->|надсилає PATCH/POST/DELETE + token| Req[Запит]
Req --> Server[Сервер]
Server -->|порівнює очікуваний token і надісланий| Check{"OK?"}
Check -->|так| Do[Виконуємо дію]
Check -->|ні| Stop[Відхиляємо запит ще до контролера]
І тут головне: токен — це не «заміна логіна». Це «другий фактор саме для браузерного сценарію», який підтверджує коректність потоку, а не особу користувача.
8. Мініприклади на нашому API: читання vs зміна стану
Поки достатньо навчитися бачити різницю за першими ознаками.
Приклад читання (public zone), де ми очікуємо «лише читання»:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class PublicArticleController {
@GetMapping("/api/public/articles")
public String readArticles() {
// Endpoint лише для читання: за змістом це безпечне читання (safe).
return "лише читання";
}
}
Приклад зміни стану в зоні користувача:
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ProfileController {
@PatchMapping("/api/me/profile")
public void updateProfile() {
// Endpoint, що змінює стан: оновлюємо дані поточного користувача.
// У session/cookie-моделі такі запити — кандидати на CSRF-захист.
}
}
І приклад типової небезпечної дії — видалення:
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
@RestController
class DraftController {
@DeleteMapping("/api/drafts/{id}")
public void deleteDraft(@PathVariable Long id) {
// Endpoint, що змінює стан: видаляємо ресурс.
// Якщо користувач увійшов у систему в браузері, без CSRF-захисту це типова поверхня атаки.
}
}
Зверніть увагу, що в усіх трьох прикладах питання «чи аутентифікований користувач?» і питання «чи має він роль або право?» — це зовсім окрема тема. Сьогоднішня думка інша: щойно ми в браузерній моделі маємо session/cookie і запит, що змінює стан, з’являється окремий клас загрози, який не вирішується ні ролями, ні самим фактом, що SecurityContext відновився.
9. Типові помилки під час роботи з CSRF
Помилка №1: сприймати CSRF як «будь-який POST без логіна».
Таке мислення виникає, коли безпека сприймається як «перевірка користувача», а не як захист на різних шарах. CSRF-атака якраз працює після того, як користувач уже увійшов у систему. Якщо у вас запит без логіна — це взагалі інша історія, там спочатку згадуємо 401 і authenticated(), а не CSRF.
Помилка №2: плутати CSRF з ролями, authorities і «у кого є право».
Ролі відповідають на запитання «чи можна цьому користувачу видаляти свою чернетку?». CSRF — «хто ініціював запит і звідки він прийшов?». У реальному застосунку вони працюють разом. Якщо переплутати і намагатися «лікувати CSRF» ролями, буде відчуття, що ви ставите замок на вхідні двері, коли проблема в тому, що вікно відкрите.
Помилка №3: робити дії, що змінюють стан, через GET.
Ця помилка часто виникає з бажання «швидше протестувати» або «зробити посилання». У підсумку ви ламаєте HTTP-семантику і спрощуєте зловмиснику життя. Плюс ви ускладнюєте життя собі: уся екосистема навколо GET вважає, що це читання, і починає «допомагати» (кешувати, префетчити, показувати попередній перегляд посилань). А потім ви дивуєтеся, чому «чернетки самі видаляються». Вони не самі. Це ви їм допомогли.
Помилка №4: думати, що cookie = «користувач точно сам це зробив».
Cookie — це лише «паспорт сесії», який браузер надсилає автоматично. Він доводить зв’язок сесії та запитів, але не намір користувача. CSRF існує саме тому, що ми дозволяємо браузеру автоматично носити цей «паспорт» і показувати його в запитах.
Помилка №5: вважати, що CSRF token «можна просто покласти в cookie і все».
Тут пастка в тому, що cookie — той самий автоматичний канал. Якщо токен поїде тим самим потягом, що й сесія, зловмисник знову зможе скористатися браузером жертви як кур’єром. Тому токен має брати участь у запиті так, щоб зовнішня сторінка не могла коректно його додати.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ