JavaRush /Курсы /Spring Security /CSRF: browser, cookies и state-changing

CSRF: browser, cookies и state-changing

Spring Security
11 уровень , 0 лекция
Открыта

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 — тот же автоматический канал. Если токен поедет тем же поездом, что и сессия, злоумышленник снова сможет воспользоваться браузером жертвы как курьером. Поэтому токен должен участвовать в запросе так, чтобы внешняя страница не могла корректно его добавить.

1
Задача
Spring Security, 11 уровень, 0 лекция
Недоступна
Профиль без state-changing GET
Профиль без state-changing GET
1
Задача
Spring Security, 11 уровень, 0 лекция
Недоступна
Карта safe и state-changing операций для drafts
Карта safe и state-changing операций для drafts
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ