После входа в админку через 8 часов все изменения падают с «Неверный или отсутствующий CSRF-токен» (#50) #51

Merged
NotBigGhost merged 2 commits from issue-50-csrf-token-refresh into dev 2026-09-13 13:16:29 +03:00
Collaborator

Что сделано

Cookie csrf_token одна на игрока и админку. Вход в админку перезаписывал её со сроком 8 ч, а сессия игрока живёт 7 дней. Когда токен истекал, все мутации, включая выход, получали 403 CSRF_FAILED. Новый токен выдавался только при входе, так что выбраться можно было лишь удалив cookie руками.

  • CSRFMiddleware (backend/app/main.py): если запрос к /api несёт сессионную cookie без csrf_token, в стартовое сообщение ответа дописывается Set-Cookie со свежим токеном. Это касается и 403-отказа. Тело идёт насквозь, SSE не буферизуется. Если приложение само ставит токен, дубль не добавляется.
  • backend/app/core/security.py: срок csrf_token = max(JWT_USER_TTL, JWT_ADMIN_TTL), вход в админку больше не укорачивает токен игрока. Ротация токена при входе сохранена. Set-Cookie для middleware строится тем же _set_csrf_cookie, атрибуты не расходятся.
  • Фронт и эндпойнты не менялись (gen:api не нужен).

После выкладки пользователю в залипшем состоянии достаточно перезагрузить страницу: GET /api/users/me перевыдаст токен.

Модель угрозы

  • Double-submit опирается на то, что атакующий с чужого сайта не может прочитать csrf_token и подставить его в заголовок. Перевыдача это не меняет: cookie читает только JS того же origin, а токен из ответа на кросс-сайтовый запрос атакующему недоступен.
  • Проверка не ослаблена: запрос с cookie, но без заголовка или с чужим токеном — 403, и cookie в этом случае не перевыдаётся. Перевыдача срабатывает только когда cookie в запросе нет вовсе.
  • Токен выдаётся только при наличии сессионной cookie, анонимам — нет.
  • Более долгий срок токена безопасен: без действующей сессии токен ничего не даёт.
  • Вне рамок: атакующий, умеющий ставить cookie на домен (захват поддомена), ломает double-submit и до, и после изменения.

Что проверено

  • backend/tests/test_csrf.py (7 тестов): восстановление на GET → мутация 200; отказ без cookie сам выдаёт токен → повторный выход 200; Max-Age у csrf_token после входа в админку ≥ fs_session; без заголовка / с чужим заголовком → 403 и токен не меняется; аноним токен не получает; middleware отдаёт чанки потока по одному; дубль Set-Cookie не дописывается.
  • Те же тесты на старом коде: 4 падают (регрессии), 3 проходят (проверки строгости) — как и ожидалось.
  • python -m pytest — 102 passed.
  • Вживую (uvicorn, dev-вход, curl, csrf_token удалён из jar): PATCH /api/users/me/profile → 403 + Set-Cookie: csrf_token; Max-Age=604800 → повтор с токеном 200; GET /api/users/me → 200 + новый токен → POST /api/auth/logout 200; GET /api/events → поток идёт (: connected), Set-Cookie в заголовках.
  • Не проверено: прод-атрибуты (Secure, домен) вживую — они берутся из тех же настроек, что и при входе.

Коммиты

  • 6556115 README: деплой на Pi через build-push, а не сборку на месте
  • cb3af48 CSRF: перевыдавать токен, если сессия есть, а cookie нет

Отклонения от плана

  • Коммит README — правка владельца, перенесённая с main по его решению; к CSRF не относится.
  • Обход «выйти и войти» из описания задачи не работал: выход тоже блокировался проверкой. Уточнено в комментарии к задаче.

Closes #50

🤖 Generated with Claude Code

https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6

## Что сделано Cookie `csrf_token` одна на игрока и админку. Вход в админку перезаписывал её со сроком 8 ч, а сессия игрока живёт 7 дней. Когда токен истекал, все мутации, **включая выход**, получали 403 `CSRF_FAILED`. Новый токен выдавался только при входе, так что выбраться можно было лишь удалив cookie руками. - `CSRFMiddleware` (`backend/app/main.py`): если запрос к `/api` несёт сессионную cookie без `csrf_token`, в стартовое сообщение ответа дописывается `Set-Cookie` со свежим токеном. Это касается и 403-отказа. Тело идёт насквозь, SSE не буферизуется. Если приложение само ставит токен, дубль не добавляется. - `backend/app/core/security.py`: срок `csrf_token` = `max(JWT_USER_TTL, JWT_ADMIN_TTL)`, вход в админку больше не укорачивает токен игрока. Ротация токена при входе сохранена. `Set-Cookie` для middleware строится тем же `_set_csrf_cookie`, атрибуты не расходятся. - Фронт и эндпойнты не менялись (`gen:api` не нужен). После выкладки пользователю в залипшем состоянии достаточно перезагрузить страницу: `GET /api/users/me` перевыдаст токен. ## Модель угрозы - Double-submit опирается на то, что атакующий с чужого сайта не может **прочитать** `csrf_token` и подставить его в заголовок. Перевыдача это не меняет: cookie читает только JS того же origin, а токен из ответа на кросс-сайтовый запрос атакующему недоступен. - Проверка не ослаблена: запрос с cookie, но без заголовка или с чужим токеном — 403, и cookie в этом случае **не** перевыдаётся. Перевыдача срабатывает только когда cookie в запросе нет вовсе. - Токен выдаётся только при наличии сессионной cookie, анонимам — нет. - Более долгий срок токена безопасен: без действующей сессии токен ничего не даёт. - Вне рамок: атакующий, умеющий ставить cookie на домен (захват поддомена), ломает double-submit и до, и после изменения. ## Что проверено - `backend/tests/test_csrf.py` (7 тестов): восстановление на GET → мутация 200; отказ без cookie сам выдаёт токен → повторный выход 200; `Max-Age` у `csrf_token` после входа в админку ≥ `fs_session`; без заголовка / с чужим заголовком → 403 и токен не меняется; аноним токен не получает; middleware отдаёт чанки потока по одному; дубль `Set-Cookie` не дописывается. - Те же тесты на старом коде: 4 падают (регрессии), 3 проходят (проверки строгости) — как и ожидалось. - `python -m pytest` — 102 passed. - Вживую (uvicorn, dev-вход, `curl`, `csrf_token` удалён из jar): `PATCH /api/users/me/profile` → 403 + `Set-Cookie: csrf_token; Max-Age=604800` → повтор с токеном 200; `GET /api/users/me` → 200 + новый токен → `POST /api/auth/logout` 200; `GET /api/events` → поток идёт (`: connected`), `Set-Cookie` в заголовках. - Не проверено: прод-атрибуты (`Secure`, домен) вживую — они берутся из тех же настроек, что и при входе. ## Коммиты - `6556115` README: деплой на Pi через build-push, а не сборку на месте - `cb3af48` CSRF: перевыдавать токен, если сессия есть, а cookie нет ## Отклонения от плана - Коммит README — правка владельца, перенесённая с `main` по его решению; к CSRF не относится. - Обход «выйти и войти» из описания задачи не работал: выход тоже блокировался проверкой. Уточнено в комментарии к задаче. Closes #50 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
Agent added 2 commits 2026-09-13 13:14:48 +03:00
На Pi лежат только docker-compose.yml и .env, исходников для сборки там нет, поэтому
подсказка "docker compose up -d --build" падала с "unable to prepare context".
Образы собираются и пушатся с ПК скриптом scripts/build-push.ps1, а Pi их просто
подтягивает.

Правка владельца, перенесена в ветку задачи #50.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
На проде любые изменения данных, включая выход из аккаунта, отвечали 403
CSRF_FAILED, хотя сайт оставался залогиненным. Cookie csrf_token одна на игрока
и админку: вход в админку перезаписывал её со сроком 8 часов, а сессия игрока
живёт 7 дней. Когда токен истекал, сервер выдавал новый только при входе, а войти
и выйти мешала та же проверка. Из этого состояния было не выбраться, кроме как
стереть cookie сайта руками.

Теперь CSRFMiddleware перевыдаёт токен на любом ответе /api, если запрос несёт
сессионную cookie без csrf_token, в том числе на самом отказе. SPA на загрузке
делает GET /api/users/me, поэтому пользователю хватает перезагрузить страницу.
Правится только стартовое сообщение ответа, тело идёт насквозь, и SSE-поток
не буферизуется. Срок токена теперь не короче самой долгой сессии, так что вход
в админку больше не укорачивает токен игрока.

Проверка double-submit не ослаблена: запрос с cookie, но без заголовка или с
чужим токеном по-прежнему получает 403, и cookie в этом случае не перевыдаётся.
Перевыданный токен из кросс-сайтового ответа атакующему ничего не даёт: прочитать
cookie может только JS того же origin.

Тесты закрепляют восстановление на GET и на отказе, срок после входа в админку,
прежнюю строгость проверки, отсутствие токена у анонимов и то, что middleware
не склеивает чанки потока. На старом коде четыре из них падают.

#50

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6
NotBigGhost merged commit 5efce76cd9 into dev 2026-09-13 13:16:29 +03:00
NotBigGhost deleted branch issue-50-csrf-token-refresh 2026-09-13 13:16:30 +03:00
Sign in to join this conversation.