1. Пользователь уже залогинен
Когда впервые слышишь «CSRF», хочется сразу спросить: «Окей, где там этот токен и какую галочку мне поставить в конфиге?». Но если начать с токена, получится классическое “не понимаю, но копирую”. Поэтому начнём с того, что у нас уже хорошо работает: пользователь открыл браузер, залогинился через form login, и теперь спокойно ходит по защищённым endpoint’ам.
В session-based модели всё держится на простой идее: сервер выдал браузеру 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 Secure Content Platform 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. State-changing запросы и CSRF
Чтобы не превратить CSRF в мистическую сущность уровня «оно просто иногда ломает Postman», нужно договориться: CSRF интересует только запросы, меняющие состояние. Состояние приложения — это не только «строчки в базе данных». Это любая операция, после которой “в мире стало по-другому”: запись создана, изменена, удалена, статус переведён, сессия изменилась, что-то отправлено на модерацию, и т.п.
Очень удобно мыслить state-changing запросами через HTTP-методы (да, я знаю: “а у нас в проекте всё равно POST везде” — вот как раз поэтому потом и больно).
Давайте зафиксируем базовую инженерную табличку. Она не заменяет RFC и не делает вас автором HTTP-стандарта, но отлично помогает не стрелять себе в ногу.
| HTTP method | Safe (должен быть только чтением) | Typical смысл | CSRF-риск в session/cookie модели |
|---|---|---|---|
| GET | да | чтение | обычно нет, если не меняет состояние |
| HEAD | да | чтение метаданных | обычно нет |
| OPTIONS | да | “что можно?” | обычно нет |
| POST | нет | создание/действие | да |
| PUT | нет | замена ресурса | да |
| PATCH | нет | частичное обновление | да |
| DELETE | нет | удаление | да |
Если упростить до одной фразы: CSRF живёт там, где браузер автоматически прикладывает “ключ от вашей сессии”, а запрос потенциально меняет состояние.
5. Safe HTTP methods и семантика
Когда вы только начинаете писать backend, очень легко соблазниться плохой идеей: «А давайте удаление сделаем через GET, так проще тестировать в браузере!». Или: «Ссылка же удобнее, чем DELETE, потому что DELETE в адресной строке не введёшь». Это правда удобно… примерно как хранить пароль на стикере на мониторе: удобно, пока не пришла реальность.
GET считается safe method. Это не значит “безопасный в плане хакеров”, это значит: он не должен менять состояние системы. На этом ожидании завязано огромное количество поведения вокруг: кеширование, 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) {
// Здесь действие явно state-changing: черновик будет удалён.
// Такой endpoint и по смыслу, и по инструментам защиты (в т.ч. CSRF) ожидаемо “опасный”.
}
}
И это не «придирка к стилю». Это часть фундамента, на котором держится нормальная web-security модель. Если вы ломаете семантику HTTP, дальше вы вынуждены изобретать костыли, а потом удивляться, почему Spring Security “капризничает”.
Плюс, в контексте CSRF есть прямой практический эффект: многие CSRF-сценарии исторически реализовывались именно через простые механизмы “подсунь ссылку/картинку/форму”. Если state-changing действие доступно через GET, вы сделали злоумышленнику практически подарок. Не тот, который GiftGenius подбирает, а другой — который вам не понравится.
6. Аутентификация vs намерение
До этого места часто возникает вопрос: «Подождите. Но ведь у нас есть authentication! У нас же пользователь залогинен, значит это он сделал запрос, разве нет?»
С точки зрения сервера запрос действительно выглядит как от пользователя: cookie валидная, session живая, SecurityContext восстановился. Но CSRF-угроза как раз и говорит: “а кто сказал, что этот запрос инициирован вашей страницей, а не чужой?”
Это вообще отдельная категория в голове: проверка права и проверка источника намерения — разные задачи.
Роль/authority отвечает на вопрос “может ли пользователь удалять черновик?”. Владелец — может.
CSRF-защита отвечает на другой вопрос: “этот запрос действительно пришёл из корректного пользовательского сценария (из вашего приложения), а не был сгенерирован внешней страницей в момент, когда пользователь просто сидел залогиненным?”
То есть CSRF не конкурирует с ролями. CSRF дополняет session-based модель: “хорошо, ты тот самый пользователь, но докажи ещё, что это твоя операция, а не операция, которую под тебя подсовывают”.
И тут мы подходим к той самой идее токена, но пока без Spring DSL и без классов.
7. Интуиция CSRF token
CSRF token — это дополнительное непредсказуемое значение, которое должно прийти вместе с изменяющим запросом. Идея очень простая: браузер автоматически прикладывает cookies, но не должен иметь возможности автоматически “угадать и приклеить” токен, если запрос был инициирован не вашим приложением.
На человеческом языке это звучит так: “Курьер (браузер) носит ваш пропуск (cookie) в кармане и показывает его всем дверям вашего дома. Но для опасных действий (удаление/изменение) мы просим ещё и кодовое слово, которое лежит не в кармане курьера, а выдаётся и контролируется вашим приложением”.
Часто эту защиту описывают как Synchronizer Token Pattern: у сервера есть ожидаемое значение, у запроса есть присланное значение, и сервер сравнивает их.
Очень важно понять один тезис из сегодняшней лекции: хранить CSRF token только в cookie недостаточно, потому что cookie — это как раз то, что браузер отправляет автоматически. Если токен будет лежать в cookie и только там, атакующая страница может инициировать запрос, и браузер радостно отправит и 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[Отклоняем запрос до контроллера]
И тут главное: токен — это не «замена логина». Это “второй фактор именно для браузерного сценария”, который подтверждает корректность flow, а не личность пользователя.
8. Мини-примеры на нашем API: чтение vs изменение состояния
Пока достаточно научиться видеть разницу по первым признакам.
Пример чтения (public zone), где мы ожидаем “read-only”:
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class PublicArticleController {
@GetMapping("/api/public/articles")
public String readArticles() {
// Read-only endpoint: по смыслу это безопасное чтение (safe).
return "read only";
}
}
Пример изменения состояния в “моей зоне” пользователя:
import org.springframework.web.bind.annotation.PatchMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class ProfileController {
@PatchMapping("/api/me/profile")
public void updateProfile() {
// State-changing 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) {
// State-changing endpoint: удаляем ресурс.
// Если пользователь залогинен в браузере, без CSRF-защиты это типичная поверхность атаки.
}
}
Заметьте, что во всех трёх примерах вопрос «аутентифицирован ли пользователь?» и вопрос «есть ли у него роль/право?» — вообще отдельная тема. Сегодняшняя мысль другая: как только мы в браузерной модели имеем session/cookie и state-changing запрос, появляется отдельный класс угрозы, который не решается ни ролями, ни тем фактом, что SecurityContext восстановился.
9. Типичные ошибки при работе с CSRF
Ошибка №1: воспринимать CSRF как “любой POST без логина”.
Такое мышление возникает, когда security видится как “проверка пользователя”, а не как защита разных слоёв. CSRF-атака как раз работает после того, как пользователь уже залогинен. Если у вас запрос без логина — это вообще другая история, там сначала вспоминаем 401 и authenticated(), а не CSRF.
Ошибка №2: путать CSRF с ролями, authorities и “у кого есть право”.
Роли отвечают на вопрос “можно ли этому пользователю удалять свой черновик?”. CSRF — “кто инициировал запрос и откуда он пришёл?”. В реальном приложении они работают вместе. Если перепутать и пытаться «лечить CSRF» ролями, будет ощущение, что вы ставите замок на входную дверь, когда проблема в том, что окно открыто.
Ошибка №3: делать state-changing действия через GET.
Эта ошибка часто вылезает из желания «быстрее протестировать» или «сделать ссылочку». В итоге вы ломаете HTTP-семантику и упрощаете злоумышленнику жизнь. Плюс вы усложняете жизнь себе: вся ecosystem вокруг GET считает, что это чтение, и начинает “помогать” (кешировать, префетчить, показывать превью ссылок). А потом вы удивляетесь, почему “черновики сами удаляются”. Они не сами. Это вы им помогли.
Ошибка №4: думать, что cookie = “пользователь точно сам это сделал”.
Cookie — это всего лишь «паспорт сессии», который браузер отправляет автоматически. Он доказывает связь сессии и запросов, но не намерение пользователя. CSRF существует именно потому, что мы позволяем браузеру автоматически носить этот «паспорт» и показывать его на запросах.
Ошибка №5: считать, что CSRF token “можно просто положить в cookie и всё”.
Тут ловушка в том, что cookie — тот же автоматический канал. Если токен поедет тем же поездом, что и сессия, злоумышленник снова сможет воспользоваться браузером жертвы как курьером. Поэтому токен должен участвовать в запросе так, чтобы внешняя страница не могла корректно его добавить.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ