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 не относится.
Обход «выйти и войти» из описания задачи не работал: выход тоже блокировался проверкой. Уточнено в комментарии к задаче.
## Что сделано
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
На 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
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Что сделано
Cookie
csrf_tokenодна на игрока и админку. Вход в админку перезаписывал её со сроком 8 ч, а сессия игрока живёт 7 дней. Когда токен истекал, все мутации, включая выход, получали 403CSRF_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перевыдаст токен.Модель угрозы
csrf_tokenи подставить его в заголовок. Перевыдача это не меняет: cookie читает только JS того же origin, а токен из ответа на кросс-сайтовый запрос атакующему недоступен.Что проверено
backend/tests/test_csrf.py(7 тестов): восстановление на GET → мутация 200; отказ без cookie сам выдаёт токен → повторный выход 200;Max-Ageуcsrf_tokenпосле входа в админку ≥fs_session; без заголовка / с чужим заголовком → 403 и токен не меняется; аноним токен не получает; middleware отдаёт чанки потока по одному; дубльSet-Cookieне дописывается.python -m pytest— 102 passed.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/logout200;GET /api/events→ поток идёт (: connected),Set-Cookieв заголовках.Secure, домен) вживую — они берутся из тех же настроек, что и при входе.Коммиты
6556115README: деплой на Pi через build-push, а не сборку на местеcb3af48CSRF: перевыдавать токен, если сессия есть, а cookie нетОтклонения от плана
mainпо его решению; к CSRF не относится.Closes #50
🤖 Generated with Claude Code
https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6