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 вдруг стал “дыркой” — просто потому, что его никто не проверял.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ