CSRF: как устроено
TIP
Это текущая реализация; при переезде на BFF её заменяет Авторизация и CSRF в BFF.
Как сервис cellestial.ru защищается от CSRF: какие cookie выдаёт бэк, что и где проверяется, что делает фронт. Код — на коммитах бэка 4094350 и фронта 344ad0b. Как это выглядит в каждом случае — «Сценарии», что проверено и чего не хватает — «Проверка и ограничения».
Схема защиты — Double Submit Cookie с подписью: сервер кладёт токен в cookie, которую JavaScript нашего сайта может прочитать, а фронт повторяет его значение в заголовке X-CSRF-Token. Чужая страница не может ни прочитать эту cookie, ни поставить заголовок, поэтому подделанный запрос приходит без верного заголовка и получает 403.
Три cookie
| Cookie | Path | Срок | HttpOnly | Secure | SameSite | Кто ставит |
|---|---|---|---|---|---|---|
access_token | /api/v1 | 15 минут (ACCESS_TOKEN_TTL) | да | да на проде (COOKIE_SECURE) | Lax | login, register, refresh |
refresh_token | /api/v1/auth | до конца refresh-сессии, 30 дней (REFRESH_TOKEN_TTL) | да | да на проде (COOKIE_SECURE) | Lax | login, register, refresh |
__Host-csrf | / | 30 дней (REFRESH_TOKEN_TTL) | нет | всегда | Lax | login, register, refresh, logout и middleware |
- Access и refresh фронт не видит:
HttpOnly. Код —tokenCookie, пути —app.go. __Host-csrfфронт читает изdocument.cookie, поэтому безHttpOnly. Префикс__Host-требуетSecure,Path=/и запрещаетDomain: поддомен не подложит свою cookie с этим именем. Код —CSRF.Cookie.- Login, register и refresh ставят все три cookie и подписанный CSRF-токен (
sessionCookies). Logout стирает access и refresh и ставит новый анонимный CSRF-токен (LogoutUser).
Когда приходит __Host-csrf
Отдельной ручки за токеном нет. Middleware CSRF добавляет cookie к ответу любой ручки /api/v1 (кроме OPTIONS), если в запросе её нет, а для известного пользователя — и если она анонимная (строки 48 и 75):
- пользователь известен — по живому
access_tokenили, если его нет, по записиrefresh_token(строки 52–72) — в ответе подписанный токен; так восстанавливается токен у вошедшего пользователя без cookie; - пользователя нет, cookie не было — анонимный токен; пользователя нет, а анонимная cookie уже есть — новой не выдаётся;
- пользователь ищется, только если
Originпустой или свой: чужой сайт не получит подписанный токен чужого пользователя; - cookie ставится и на ответах с ошибкой (
401,403,404), если обработчик не поставил свою; у всех ответов, где middleware готов выдать cookie, —Cache-Control: no-store(csrfBootstrapWriter); - подписанную cookie, даже испорченную, middleware сам не заменяет.
Поэтому к форме входа фронт уже держит токен: стартовая проверка сессии приносит его даже ответом 403.
Порядок middleware
router.go: CSRF стоит раньше обработчика, поэтому отклонённый запрос ничего не меняет. CORS — раньше CSRF: ответ 403 тоже несёт CORS-заголовки, и фронт может прочитать его тело.
Что проверяется на какой ручке
| Запрос | Origin | Cookie равна заголовку | Подпись токена |
|---|---|---|---|
POST /auth/login, /auth/register | пустой или свой | да | нет: пользователя ещё нет |
POST /auth/refresh | пустой или свой | да | да, для владельца refresh-сессии (в обработчике) |
POST /auth/logout | — | да | да, для владельца access и refresh-сессии |
остальные POST, PUT, PATCH, DELETE | — | да | да, для пользователя из access_token |
GET, HEAD, OPTIONS | — | нет | нет |
- «Cookie равна заголовку»: ровно одна cookie
__Host-csrf, ровно один заголовокX-CSRF-Token, оба не пустые и совпадают, сравнение за постоянное время (строки 93–98). Origin— изAPP_ORIGINиCORS_ALLOWED_ORIGINS(строки 87–92).- Подпись — после
Authenticateдля всех изменяющих, кроме login, register, refresh (строки 31–36); refresh и logout дополнительно проверяют её для владельца refresh-сессии (checkRefreshCSRF): у refresh access может быть уже истёкшим. - Любой отказ —
403с кодомcsrf_invalid, не401: на401фронт делает refresh и повторяет запрос, а refresh CSRF-токен не чинит. GETне проверяется, поэтомуGETв API ничего не меняет.
Формат токена
- анонимный — 32 случайных байта (
crypto/rand) в base64url; - подписанный —
base64url(nonce) + "." + base64url(HMAC-SHA256(CSRF_SECRET, "<userID>." + nonce)),nonce— 32 случайных байта.
Подпись привязана к пользователю: токен одного пользователя не проходит у другого, даже если подставить его и в cookie, и в заголовок. Сервер токены не хранит — проверяет подпись секретом CSRF_SECRET, отдельным от JWT_SECRET.
Когда токен меняется
Новый токен приходит при login, register, refresh (то есть не реже раза в 15 минут, пока пользователь работает) и logout. Обычные POST, PUT, PATCH, DELETE его не меняют.
Защищает не частая смена значения, а то, что чужой сайт его не видит и не может подставить. Смена при входе и выходе нужна против подмены токена до входа (token fixation). Смена на каждый запрос ничего не добавляет против CSRF, но ломает параллельные запросы и вторую вкладку: запрос, отправленный со старым значением, получил бы 403. OWASP считает токен на сессию достаточным (CSRF Prevention Cheat Sheet).
Что делает фронт
- Перед каждым
POST,PUT,PATCH,DELETEчитает__Host-csrfизdocument.cookieи ставитX-CSRF-Token; токен нигде не хранит — ни в памяти, ни вlocalStorage(setCsrfHeader). - Вход и регистрацию после
403 csrf_invalidповторяет один раз: этот ответ уже принёс новую cookie (csrfMiddleware). Остальные запросы после403показывают ошибку — см. ограничения. - На
401(кроме/auth/*) делает один общийPOST /auth/refreshсX-CSRF-Tokenи повторяет запрос со свежим заголовком (authMiddleware). - При старте проверяет сессию:
POST /auth/refresh, затемGET /users/me(session.ts). У гостя refresh получает403или401— ответ приносит__Host-csrf, фронт показывает вход.