Перейти к содержимому

CSRF: как устроено ​

TIP

Это текущая реализация; при переезде на BFF её заменяет Авторизация и CSRF в BFF.

Как сервис cellestial.ru защищается от CSRF: какие cookie выдаёт бэк, что и где проверяется, что делает фронт. Код — на коммитах бэка 4094350 и фронта 344ad0b. Как это выглядит в каждом случае — «Сценарии», что проверено и чего не хватает — «Проверка и ограничения».

Схема защиты — Double Submit Cookie с подписью: сервер кладёт токен в cookie, которую JavaScript нашего сайта может прочитать, а фронт повторяет его значение в заголовке X-CSRF-Token. Чужая страница не может ни прочитать эту cookie, ни поставить заголовок, поэтому подделанный запрос приходит без верного заголовка и получает 403.

CookiePathСрокHttpOnlySecureSameSiteКто ставит
access_token/api/v115 минут (ACCESS_TOKEN_TTL)дада на проде (COOKIE_SECURE)Laxlogin, register, refresh
refresh_token/api/v1/authдо конца refresh-сессии, 30 дней (REFRESH_TOKEN_TTL)дада на проде (COOKIE_SECURE)Laxlogin, register, refresh
__Host-csrf/30 дней (REFRESH_TOKEN_TTL)нетвсегдаLaxlogin, 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-заголовки, и фронт может прочитать его тело.

Что проверяется на какой ручке ​

ЗапросOriginCookie равна заголовкуПодпись токена
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 ничего не меняет.

Формат токена ​

internal/auth/csrf.go:

  • анонимный — 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, фронт показывает вход.