1. Bootstrap CSRF token для SPA-клиента
Если вы читаете эту лекцию и думаете «ну у меня же уже есть JSESSIONID, зачем ещё какая-то сущность с названием “token”?», то вы в отличной компании — так думают почти все, кто впервые скрещивает “React на одном порту” и “Spring Boot на другом” и пытается сделать PATCH /api/me/profile.
Даже если preflight уже проходит, а JSESSIONID доезжает до API нормально, state-changing запрос всё равно не считается безопасным автоматически. Ему нужен ещё один кусок данных, который браузер не подставит за вас сам, — CSRF token.
Проблема в том, что браузер в session-based модели делает две очень разные вещи. Во-первых, он сам отправляет cookie с идентификатором сессии (например, JSESSIONID) — и это удобно, потому что сервер может восстановить пользователя из сессии. Во-вторых, браузер не будет сам добавлять в запрос «секретный дополнительный параметр», который должен доказать, что запрос действительно инициирован вашей страницей, а не случайной формой на чужом сайте. Именно поэтому CSRF-токен должен попадать в ту часть запроса, которая не “прилипает” автоматически — чаще всего это заголовок.
Отсюда появляется слово “bootstrap”. Это не магия и не отдельный протокол, а просто стартовый шаг: клиент сначала делает запрос, чтобы получить актуальный CSRF token, запоминает его, а затем использует в state-changing запросах. Если этого шага нет, то ваш первый PATCH будет выглядеть как классическое «почему сервер отвечает 403, хотя я залогинен?!». И да, на этом месте люди обычно начинают отключать CSRF “чтобы работало”. Мы как раз хотим, чтобы работало и при этом оставалось осмысленно защищённым.
Cookie, CSRF token и Origin
Прежде чем писать код, полезно зафиксировать в голове “кто что делает”, иначе вы будете полчаса дебажить не тот слой. Это особенно важно в cross-origin ситуации, где к вашим проблемам добавляются CORS и cookie-политика.
Ниже — короткая таблица, которую я люблю держать в голове, когда кто-то говорит «у меня CSRF сломался»:
| Сущность | Откуда берётся | Кто отправляет в запросе | Зачем нужна |
|---|---|---|---|
| JSESSIONID | Сервер установил cookie | Браузер автоматически (если политика cookies разрешает) | Чтобы сервер нашёл сессию и восстановил SecurityContext |
| CSRF token | Сервер сгенерировал и сохранил (обычно в session) | Ваш JS-код (в заголовке или параметре) | Чтобы доказать, что запрос инициирован вашим клиентом, а не CSRF-атакой |
| Origin | Браузер | Браузер автоматически | Чтобы браузер и сервер могли применить CORS-правила |
Ключевой момент: cookie и CSRF token — это два разных слоя защиты. Cookie — “кто ты (через сессию)”, CSRF token — “точно ли это ты инициировал запрос из правильного контекста”.
Назначение endpoint /csrf
Сейчас будет важная мысль, которую легко упустить: CSRF token не является “токеном авторизации”. Это не “секретный ключ”, который вы храните как пароль. Его задача — не доказать, что вы пользователь, а доказать, что ваш state-changing запрос не был подделан “снаружи” за счёт того, что cookies приклеились автоматически.
Поэтому /csrf endpoint — это не “логин” и не “часть профиля”. Это простой, технический, почти утилитарный ресурс, который отдаёт клиенту текущий CSRF token и то, как именно сервер ожидает его получить (например, название заголовка). Представьте, что это “инструкция для клиента: куда положить пломбу”.
Хорошая версия /csrf endpoint’а должна быть маленькой, предсказуемой и скучной (скучные endpoint’ы — самые надёжные, потому что им нечем удивлять). Он должен отдавать минимальный контракт, вроде {headerName, token}, и не пытаться “заодно” вернуть профиль, настройки, любимую музыку пользователя и прогноз погоды (если вы смогли скрестить CSRF и прогноз погоды — поздравляю, вы изобрели новый жанр багов).
2. Реализация /csrf в Secure Content Platform API
Теперь перейдём к нашему проекту Secure Content Platform API. У нас уже есть stateful security-ветка с session-based authentication, и CSRF включён (либо явно, либо дефолтно). Наша задача — добавить небольшой endpoint, который позволит отдельному браузерному клиенту (условно: отдельный UI на другом origin) получить токен.
DTO для bootstrap-ответа
Начнём с простого контракта. В Java 25 это удобно сделать через record, чтобы не плодить лишний boilerplate.
package com.example.securecontent.security.csrf;
// DTO, который возвращаем клиенту для bootstrap CSRF
public record CsrfBootstrapResponse(String headerName, String token) { }
Здесь мы намеренно не тащим parameterName, потому что в JSON API чаще всего удобнее работать через заголовок. И да, headerName мы отдаём не “для красоты”, а чтобы клиент не хардкодил строку X-CSRF-TOKEN и не начинал страдать, если вы позже поменяете стратегию.
Контроллер /csrf
Теперь добавим контроллер. В Spring MVC есть очень приятная вещь: CsrfToken можно просто принять как параметр метода, и Spring его подставит.
package com.example.securecontent.security.csrf;
import org.springframework.security.web.csrf.CsrfToken;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RestController;
@RestController
class CsrfBootstrapController {
@GetMapping("/csrf")
CsrfBootstrapResponse bootstrap(CsrfToken csrfToken) {
// CsrfToken сюда подставляет Spring Security для текущего запроса (и текущей сессии)
return new CsrfBootstrapResponse(
csrfToken.getHeaderName(), // как именно сервер ожидает получить токен (заголовок)
csrfToken.getToken() // текущее значение токена для этой сессии
);
}
}
Для нашего API это и есть рабочий контракт /csrf: клиент получает {headerName, token} и дальше использует именно его. Финальной конфигурации останется только открыть этот endpoint и покрыть его тем же CORS-правилом, что и основной API.
Важно понимать, что этот метод не “придумывает токен сам”. Он лишь заставляет Spring Security достать текущий токен из контекста запроса. Если токена ещё не было (например, новая сессия), то CSRF-механизм его создаст и сохранит там, где хранит по своей стратегии (в нашей учебной модели — обычно в session).
Доступ к /csrf в SecurityFilterChain
Теперь нужно дать клиенту возможность получить этот токен. В большинстве учебных и прикладных сценариев /csrf делают permitAll(), потому что токен может быть нужен ещё до того, как пользователь вошёл. Например, если вы делаете login/logout как state-changing операции, это тоже CSRF-история.
Покажем маленький фрагмент SecurityFilterChain (полный будем аккуратно собирать чуть позже, чтобы не смешивать всё в одну кашу).
import org.springframework.context.annotation.Bean;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.web.SecurityFilterChain;
@Bean
SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(auth -> auth
// /csrf должен быть доступен клиенту, чтобы он мог получить токен до state-changing запросов
.requestMatchers(HttpMethod.GET, "/csrf").permitAll()
// все остальные запросы защищаем как обычно
.anyRequest().authenticated()
);
return http.build();
}
Сейчас это выглядит “слишком упрощённо”, и да — это нормально. Мы пока сосредоточены на одной идее: endpoint, который отдаёт токен, должен быть доступен. Все остальные правила доступа и CORS-часть мы соберём в общей конфигурации чуть позже, чтобы не сбить фокус.
3. Bootstrap flow клиента
Сейчас соберём “путь клиента” в последовательность, чтобы вы могли отлаживать это не по ощущениям, а по шагам. Потому что CSRF-отладка “по ощущениям” быстро превращается в древнее шаманство: вы гремите бубном, браузер гремит preflight’ом, Spring гремит 403 — и все довольны, кроме вас.
Остаёмся в том же local сценарии: страница живёт на http://localhost:5173, API — на http://localhost:8080.
Вот нормальный bootstrap flow для session-based клиента:
sequenceDiagram
participant B as "Browser (SPA)"
participant A as "Secure Content Platform API"
Note over B,A: "В обоих запросах важно отправлять cookies (credentials: include), иначе сессия не сохранится"
B->>A: "GET /csrf with credentials"
A-->>B: "200 { headerName, token } + Set-Cookie: JSESSIONID=..."
B->>A: "PATCH /api/me/profile Cookie: JSESSIONID + header CSRF"
A-->>B: "200 OK profile updated"
Обратите внимание на “with credentials”. В браузерном fetch это обычно означает credentials: "include". Без этого вы не получите устойчивую сессию, а значит CSRF token будет “жить” в одной сессии, а PATCH придёт в другой. В итоге сервер скажет: “токен не тот”, и это будет честно.
Ниже — пример очень компактного JS-кода (псевдо-клиент, без фреймворков), который показывает идею:
// 1) Забираем CSRF token и при этом сохраняем/получаем сессионные cookies
const csrf = await fetch("http://localhost:8080/csrf", {
method: "GET",
credentials: "include", // важно: без этого не будет стабильной сессии -> токен станет «не тем»
}).then(r => r.json());
// 2) Делаем state-changing запрос, прикладывая и cookie, и CSRF token в заголовок
await fetch("http://localhost:8080/api/me/profile", {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
[csrf.headerName]: csrf.token, // сервер ожидает токен именно в этом заголовке
},
body: JSON.stringify({ displayName: "Neo" }),
});
Если вы здесь забудете credentials: "include", то будет классический баг: /csrf вернул токен, PATCH отправил токен — а сервер всё равно считает его неверным. И это происходит не потому что “Spring сломан”, а потому что токен привязан к сессии, а сессия без cookies не сохраняется между запросами.
Обновление токена после login/logout
Теперь самое коварное место, из-за которого люди чаще всего думают “CSRF живёт своей жизнью”. Представьте, вы сделали GET /csrf, получили токен, сохранили его в UI, всё красиво. Затем пользователь логинится… и внезапно следующий PATCH начинает падать с “Invalid CSRF token”.
Это часто выглядит как “Spring Security опять что-то придумал”. Но реальность проще: в stateful модели SecurityContext и CSRF token обычно живут “рядом” с session, а сессия может поменяться.
Когда вы логинитесь через form login, Spring Security (и вообще многие security-системы) могут менять идентификатор сессии, чтобы закрывать класс уязвимостей session fixation. Это тема из Дня 9, но сейчас нам важен практический эффект: сменился session id — значит мог смениться и CSRF token, потому что он хранится в рамках нового session-состояния.
А при logout ещё проще: logout обычно инвалидирует сессию. Если сессия исчезла, то токен, лежащий в ней, тоже “исчез”. Поэтому после logout ваш старый токен превращается в исторический артефакт, примерно как билет на поезд, который уже ушёл.
Практическое правило здесь очень скучное, но надёжное: после событий, которые меняют security-состояние (login и logout), клиенту полезно обновить CSRF token, сделав новый GET /csrf. Это не “костыль”, а естественная часть модели: токен привязан к текущему состоянию сессии.
Безопасность /csrf и CORS
Сейчас будет момент, где важно не переборщить с уверенностью. Очень легко сказать: “Окей, /csrf мы сделали permitAll(), значит всё хорошо”. Но здесь скрыта тонкая вещь: CSRF-токен безопасен ровно настолько, насколько безопасна возможность прочитать его.
CSRF-атака по своей природе обычно не может прочитать ответ вашего API (same-origin policy в браузере её останавливает). А вот если вы сами на стороне сервера разрешили CORS “всем и всегда” и включили credentials, то вы фактически сказали: “любой сайт может прочитать мой /csrf и потом отправить state-changing запросы”. И вы своими руками вырубили смысл CSRF.
Поэтому /csrf endpoint — это как маленькая дверь в подъезде. Дверь-то сама по себе нормальная. Но если вы повесите на неё табличку “вход свободный для всех, ключ под ковриком”, вы быстро обнаружите, что ваши вещи любят чужие люди.
Именно поэтому /csrf должен жить в той же аккуратной cross-origin конфигурации, что и основной API: разрешённый origin, credentials, явный список заголовков и никакого “allow all на всякий случай”.
4. Типичные ошибки при bootstrap CSRF
Ошибка №1: ожидание, что CORS “решит CSRF”.
Очень распространённая путаница: разработчик включает CORS, видит, что браузер перестал ругаться на блокировку доступа к ответу, и ожидает, что PATCH теперь будет проходить. Но CORS отвечает лишь за то, может ли страница прочитать ответ, а CSRF — за то, допускается ли state-changing запрос в cookie/session модели. Если CSRF token не передан, сервер всё равно честно вернёт отказ.
Ошибка №2: получение токена без сохранения сессии (нет cookies/credentials).
Снаружи выглядит так: /csrf возвращает токен, вы его отправляете, а сервер говорит “invalid token”. На самом деле вы просто каждый раз приходите с новой сессией или без сессии: токен был выдан одной сессии, а проверяется в другой. Лечится это не отключением CSRF, а корректной передачей cookies и настройкой credentials на клиенте.
Ошибка №3: “взял токен один раз и живу с ним вечно”.
Иногда токен действительно будет жить долго, и это создаёт опасную иллюзию, что он “постоянный”. Но после login/logout, после смены session id, после перезапуска браузера или при других сменах состояния токен может стать невалидным. Надёжнее мыслить им как “актуальным на текущую сессию” и иметь понятный способ его обновить.
Ошибка №4: bootstrap токена прячут в бизнес-ответы.
Кажется удобным: “а давайте вместе с GET /api/me/profile отдавать CSRF token”. В итоге у вас появляется “профиль, который внезапно отвечает за безопасность”, клиент начинает зависеть от порядка вызовов бизнес-эндпоинтов, а вы получаете странные циклические зависимости. Отдельный /csrf endpoint скучен, но зато честен и предсказуем.
Ошибка №5: первая реакция на 403 — глобальный .csrf().disable().
Это как лечить “не заводится машина” снятием колёс: технически проблема с движением исчезает (потому что двигаться нечему), но смысл автомобиля тоже исчезает. Если вы строите stateful/session модель, CSRF — часть этой модели. Отключать его можно только осознанно, понимая, какой альтернативой вы закрываете угрозу (и чаще всего в browser/cookie мире достойной альтернативы просто нет).
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ