JavaRush /Курсы /Spring Security /csrf

csrf ( ) в MockMvc

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

1. CSRF: тестируем, не надеемся

MockMvc уже прогоняет запросы через SecurityFilterChain, а user() / @WithMockUser уже позволяют быстро проверять базовые правила доступа. Но для state-changing запроса в session/cookie-модели одной аутентификации мало: он ещё должен пройти CSRF-барьер. С этого места и начинается развилка всего набора security-тестов: отдельно для stateful-запросов, отдельно для реального login/logout flow и отдельно для stateless/JWT-сценариев.

Если говорить по-честному, CSRF — это тот механизм, который отлично работает… пока кто-то не поменял конфигурацию, не добавил новый endpoint или не «подкрутил удобство» одной строкой в security DSL. Ручная проверка CSRF обычно происходит один раз: «о, 403, значит защищено». А потом проект живёт, ветвится, развивается — и внезапно через месяц кто-то случайно отключает CSRF в тестовом профиле, потому что “мешало прогонять запросы”, и вы теряете гарантию.

В тестах мы хотим не “просто убедиться, что 403 бывает”, а зафиксировать очень конкретное поведение: для state-changing операций в stateful/session ветке приложения запрос проходит только при наличии корректного CSRF-токена. Важно не потерять границу применения: CSRF имеет смысл тестировать там, где мы действительно используем session/cookie-модель. В stateless/JWT ветке, где сервер не полагается на cookies, CSRF обычно не является частью модели — и тесты там будут строиться иначе.

Чтобы ощущать CSRF как инженерный инструмент, а не как «случайный 403 от Spring», нам нужно покрыть минимум три сценария: корректный токен, отсутствие токена и намеренно неправильный токен. Тогда любая регрессия станет видна сразу, а не в проде по репорту “у пользователей всё само постится куда-то не туда”.

2. Ментальная модель csrf() в MockMvc

Когда мы пишем тест с csrf(), мы не тестируем «контроллер», и даже не тестируем «сервис». Мы тестируем поведение security-слоя, который стоит до ваших контроллеров и иногда вообще не пускает запрос внутрь приложения. Это важная мысль: CSRF-проверка происходит на уровне фильтров Spring Security (по сути, это барьер в filter chain), а значит, хороший тест должен быть про вход на границе, а не про детали внутренней реализации.

В реальном браузерном сценарии CSRF-токен обычно живёт где-то рядом с сессией: сервер выдаёт токен, клиент (браузер или SPA) хранит его и отправляет обратно при каждом state-changing запросе. Spring Security сравнивает «ожидаемый токен» (который он восстановил из HttpSession/repository) с «переданным токеном» (в заголовке или параметре запроса). Если токена нет или он не совпадает — запрос считается потенциально опасным и блокируется.

MockMvc в тестах помогает нам имитировать это без браузера. csrf() в spring-security-test — это просто удобная “машинка”, которая подкладывает в запрос CSRF-токен в правильном формате, так чтобы CsrfFilter (или его аналогичная часть цепочки) принял запрос как корректный. А csrf().useInvalidToken() делает обратное: подкладывает токен, который выглядит как токен, но точно не совпадает с тем, что ожидает сервер. Получается чистый, воспроизводимый негативный сценарий.

Для наглядности можно держать в голове такую схему принятия решения (упрощённо):

flowchart TD
    A["State-changing request: POST/PATCH/DELETE"] --> B{CSRF включён?}
    B -- нет --> OK[Запрос идёт дальше]
    B -- да --> C{Есть CSRF токен?}
    C -- нет --> F[403 Forbidden]
    C -- да --> D{Токен совпадает с ожидаемым?}
    D -- нет --> F
    D -- да --> OK

Эта схема важна именно для тестов: если вы хотите проверить CSRF, вы должны убрать другие причины падения и оставить в тесте только этот “if-else” по токену.

3. csrf() как RequestPostProcessor

Когда в тесте вы пишете .with(csrf()), вы добавляете к запросу специальные данные, которые Spring Security воспринимает как CSRF-токен. На уровне API это выглядит как простой “добавочный обработчик”, но под капотом он делает весьма практичную вещь: создаёт токен и кладёт его в request (и при необходимости в session), так чтобы фильтр CSRF смог «увидеть ожидаемое значение» и «увидеть переданное значение» в одном и том же тестовом запросе.

Важно не путать этот механизм с тем, что будет в проде. В проде токен обычно выдаётся на одном запросе (например, при заходе на страницу или через /csrf endpoint), затем клиент хранит его и отправляет в последующих запросах. В тестах мы чаще всего не хотим воспроизводить весь dance с несколькими запросами — мы хотим проверять, что конкретный endpoint защищён. Поэтому csrf() — это честный shortcut уровня тестирования: он не отменяет CSRF в приложении, он помогает корректно пройти CSRF-барьер.

Ещё одна полезная деталь: csrf() по умолчанию отправляет токен так, как это ожидает стандартная конфигурация Spring Security. А если в вашем проекте клиент отправляет токен в заголовке (часто это делают SPA), то в spring-security-test есть возможность смоделировать это тоже (например, через режим “as header”). На fundamentals-уровне вам не нужно запоминать все варианты, но нужно понимать принцип: csrf() — это инструмент, чтобы тест был сфокусирован на безопасности, а не на ручной сборке служебных параметров.

4. Позитивный CSRF-тест

Любое тестирование безопасности легко превращается в странный спорт “поймай 403”. Поэтому мы начнём с самого спокойного и важного сценария: корректный запрос от аутентифицированного пользователя со свежим токеном. Этот тест нужен, чтобы зафиксировать, что endpoint вообще работает в нормальном режиме, а не только «хорошо падает».

В нашем проекте Secure Content Platform API хороший кандидат для такого теста — обновление профиля текущего пользователя. Это типичный state-changing запрос (PATCH), который в session-based ветке должен требовать CSRF. При этом по смыслу он owner-only, но в тесте мы сейчас изолируем именно CSRF, поэтому берём понятного пользователя и не усложняем сценарий.

Ниже — минимальный пример. Считайте, что в тестовом классе mvc уже настроен (мы делали это вчера через @SpringBootTest, @AutoConfigureMockMvc и подключение security в MockMvc).

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void updateProfileWhenUserAndCsrfThenOk() throws Exception {
    // Act: имитируем state-changing запрос от аутентифицированного пользователя
    mvc.perform(patch("/api/me/profile")
            // Аутентификация должна быть “решённой задачей”, чтобы тест был именно про CSRF
            .with(user("maria").roles("USER"))
            // Валидный CSRF-токен: запрос должен пройти фильтр CSRF
            .with(csrf())
            .contentType(MediaType.APPLICATION_JSON)
            // Тело запроса — просто пример изменения профиля
            .content("""
                {"displayName":"Maria"}
                """))
        // Assert: при корректном CSRF ожидаем успешный ответ
        .andExpect(status().isOk());
}

Обратите внимание на композицию: мы одновременно задаём пользователя (чтобы не получить 401/403 по другим причинам) и добавляем CSRF (чтобы исключить CSRF-отказ). В итоге тест становится очень “чистым”: если он упал — значит, либо endpoint действительно перестал принимать корректный CSRF, либо мы сломали сам endpoint. И это уже нормальная инженерная сигнализация, а не гадание на статусах.

5. Негативные CSRF-тесты: missing и invalid

В реальных инцидентах “CSRF сломан” чаще всего выглядит не как «всё отключено». Намного чаще ломаются детали: клиент перестал отправлять токен, токен стал устаревать, токен пересоздаётся после login/logout, а фронт не обновляет его — и вы получаете хаос. Поэтому одного позитивного теста недостаточно: он доказывает, что механизм может работать, но не доказывает, что он действительно стоит на страже.

Первый обязательный негативный сценарий — токена нет вообще. Это должен быть 403 Forbidden при условии, что пользователь аутентифицирован. Почему “при условии”? Потому что если пользователь не аутентифицирован, вы можете получить 401 (или 403 в зависимости от конфигурации), и тест перестанет быть про CSRF. Нам нужно, чтобы отказ был именно из-за токена.

import org.junit.jupiter.api.Test; 
import org.springframework.http.MediaType;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void updateProfileWhenMissingCsrfThenForbidden() throws Exception {
    // Act: запрос выполняется от имени пользователя, но без CSRF-токена
    mvc.perform(patch("/api/me/profile")
            // Пользователь есть — значит, 403 должен быть именно из-за CSRF, а не из-за анонимности
            .with(user("maria").roles("USER"))
            .contentType(MediaType.APPLICATION_JSON)
            .content("""
                {"displayName":"Maria"}
                """))
        // Assert: отсутствие CSRF для state-changing запроса должно блокироваться
        .andExpect(status().isForbidden());
}

Второй обязательный сценарий — токен есть, но он неправильный. Это не то же самое, что отсутствие. Отсутствие часто означает “клиент забыл”, а неверный токен означает “клиент отправил что-то не то” или “у клиента токен устарел”. В spring-security-test для этого есть ровно тот инструмент, который нужно запомнить: useInvalidToken().

Возьмём, например, отправку черновика на модерацию: POST /api/drafts/15/submit. Это state-changing операция и очень подходящий кандидат для CSRF-покрытия.

import org.junit.jupiter.api.Test; 

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void submitDraftWhenInvalidCsrfThenForbidden() throws Exception {
    mvc.perform(post("/api/drafts/15/submit")
            // Снова: аутентификация есть, чтобы тест был про CSRF, а не про доступ
            .with(user("maria").roles("USER"))
            // Ключевой момент: токен “похож на настоящий”, но гарантированно не совпадает с ожидаемым
            .with(csrf().useInvalidToken()))
        // Assert: неверный CSRF должен блокироваться
        .andExpect(status().isForbidden());
}

Заметьте: мы не просто “не добавили csrf”, а добавили неправильный. Это делает тест гораздо сильнее. Он ловит ситуации, когда CSRF-фильтр формально включён, но случайно настроен так, что “любой токен прокатывает”, или когда вы перенастроили репозиторий токенов и перестали корректно сравнивать ожидаемое и фактическое значение.

6. Изоляция CSRF в тестах

Самая частая проблема с CSRF-тестами у новичков — они пишут тест “без пользователя”, получают 401 или 403 и радостно думают: «Отлично! CSRF работает». А через пару минут оказывается, что CSRF тут вообще ни при чём — просто endpoint закрыт, и анонимов туда не пускают. Это классический случай ложноположительного результата: тест зелёный, но смысловой ценности почти нет.

Правильная стратегия выглядит как логическая изоляция причины отказа. Если вы тестируете CSRF, то аутентификация должна быть уже “решённой задачей”: вы добавляете user() (или используете @WithMockUser в простых случаях). Авторизация тоже должна быть “решённой задачей”: вы выбираете такого пользователя и такой endpoint, чтобы не получить 403 из-за ролей/authorities. Только после этого вы варьируете CSRF-состояние: валидный, отсутствует, неправильный.

Эта дисциплина кажется занудной, но она экономит часы. Особенно когда проект растёт, появляются method-security правила и owner-based ограничения. Если вы не изолируете причину отказа, то при падении теста вы будете смотреть на 403 и думать: “Это CSRF? Это роль? Это ownership? Это вообще endpoint удалили?” — и дальше начинается весёлый квест “угадай, что сломалось”. Мы хотим, чтобы каждый тест имел одну главную причину успеха/провала.

7. CSRF и multipart/upload

С multipart-запросами у начинающих разработчиков есть забавная ментальная ловушка: “Ну это же файл, а не JSON, значит, наверное, CSRF как-то по-другому”. На самом деле с точки зрения CSRF-модели всё очень скучно и правильно: multipart POST всё равно меняет состояние на сервере, а значит должен быть защищён в stateful/cookie-модели.

В нашем проекте как раз есть отличный обязательный special case: POST /api/me/avatar. Это и state-changing операция, и multipart, и потенциально чувствительная точка (загрузка данных на сервер). Поэтому мы обязаны иметь CSRF-тест на upload: иначе очень легко потом “случайно открыть” upload-операцию для атак, пока вы будете думать, что раз это файл, то оно как-то само безопасно.

Минимальный тест выглядит так:

import org.junit.jupiter.api.Test; 
import org.springframework.mock.web.MockMultipartFile;

import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.multipart;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;

@Test
void uploadAvatarWhenUserAndCsrfThenOk() throws Exception {
    // Arrange: создаём тестовый файл (как будто это пришло из формы/SPA)
    MockMultipartFile file =
            new MockMultipartFile("file", "avatar.png", "image/png", new byte[]{1, 2, 3});

    mvc.perform(multipart("/api/me/avatar")
            // multipart-builder собирает multipart/form-data запрос
            .file(file)
            // Аутентификация есть: иначе легко спутать проблемы доступа с CSRF
            .with(user("maria").roles("USER"))
            // Валидный CSRF обязателен для multipart POST в stateful/cookie модели
            .with(csrf()))
        // Assert: с корректным CSRF запрос должен быть принят
        .andExpect(status().isOk());
}

Здесь важно несколько вещей, даже если вы пока не чувствуете их кожей. Во-первых, multipart-запрос легко забыть покрыть тестами, потому что он “не как все”. Во-вторых, если вы случайно отключите CSRF где-то на уровне конфигурации (или наоборот, включите не там), именно такие endpoint’ы первыми начинают вести себя странно и «ломать» фронт. И в-третьих, тест на upload очень хорошо ловит деградацию конфигурации, когда кто-то меняет csrf или multipart настройки и не проверяет последствия.

Чтобы картинка была более инженерной, полезно держать маленькую таблицу, где видно, какие наши endpoint’ы в stateful ветке обязаны иметь CSRF-покрытие:

Feature-зона проекта Endpoint Метод Почему нужен CSRF в session-ветке
Profile /api/me/profile PATCH меняем данные профиля через cookie/session
Drafts /api/drafts/{id}/submit POST state-changing действие от имени пользователя
Drafts /api/drafts/{id} DELETE удаление — классический CSRF-кейс
Files /api/me/avatar POST multipart загрузка файла тоже меняет состояние

Не нужно пытаться покрыть CSRF “везде” бездумно. Нужен минимум на каждую feature-зону, где есть state-changing операции. Тогда регрессии вы поймаете быстро и не утонете в тестах.

8. Организация CSRF-тестов по feature-зонам

Когда тестов становится больше десяти, у новичков часто возникает желание сделать один класс SecurityTests и свалить туда всё: CSRF, роли, JWT, login/logout, “мой/чужой” и так далее. Это работает примерно до первого серьёзного падения, после которого вы открываете класс на 600 строк и начинаете морально принимать неизбежность карьеры бариста.

Правило, которое очень хорошо держит код в порядке: CSRF-тесты должны жить рядом с той feature-зоной, где они нужны. То есть тесты для профиля — рядом с profile, тесты для черновиков — рядом с content (drafts), тесты для upload — рядом с file. Внутри каждого такого тестового класса вы делаете минимум: один позитивный сценарий с csrf() и два негативных (missing и invalid), причём с понятным пользователем, чтобы причина отказа была однозначной.

Если упростить до одной фразы: “CSRF — это не отдельная функция системы, это часть контракта конкретных state-changing endpoint’ов”. Поэтому и тестироваться он должен как часть контракта этих endpoint’ов.

9. Типичные ошибки CSRF-тестов

Ошибка №1: тестируют CSRF на GET-запросах и радуются 200/403, не понимая смысла.
CSRF — это защита от нежелательных изменений состояния в cookie/session модели. GET по определению должен быть safe (в идеальном мире), и Spring Security по умолчанию не требует CSRF-токен для таких запросов. Если вы начнёте проверять CSRF на GET, вы просто создадите шум и потеряете фокус на действительно уязвимых операциях.

Ошибка №2: пишут “missing CSRF” тест без пользователя и получают не тот отказ.
Когда запрос идёт от anonymous-пользователя, вы легко можете получить 401 (или 403) из-за того, что endpoint закрыт, а не из-за CSRF. Такой тест формально зелёный, но он не доказывает ничего про CSRF. Если вы тестируете CSRF, сначала сделайте пользователя аутентифицированным, и только потом убирайте/ломайте токен.

Ошибка №3: ограничиваются только happy path и не ловят регрессии.
Один позитивный тест с csrf() не защитит вас от случайного отключения CSRF или от неправильной настройки репозитория токенов. Минимальный полезный набор для чувствительного endpoint’а — это “valid token проходит”, “missing token падает”, “invalid token падает”. Без этих трёх сценариев вы не фиксируете реальную гарантию.

Ошибка №4: ради «зелёных тестов» отключают CSRF в тестовой конфигурации.
Это самый опасный самообман. Тесты становятся удобными и зелёными, но они больше не тестируют то, что вы хотите. Если вы отключили CSRF в тестах, вы перестали проверять один из ключевых механизмов stateful ветки, и в результате ручная проверка снова становится единственным способом “поймать” проблему. А ручная проверка, как мы уже знаем, любит исчезать именно тогда, когда она нужна.

Ошибка №5: забывают про multipart/upload endpoint’ы и оставляют их без CSRF-покрытия.
Файловые загрузки часто живут отдельной жизнью: другой controller, другой request builder, другие нюансы. Но с точки зрения CSRF это такой же state-changing запрос. Если вы не добавите тесты на upload, вы легко пропустите ситуацию, где профиль и черновики защищены, а upload вдруг стал “дыркой” — просто потому, что его никто не проверял.

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