CSRF: перевыдавать токен, если сессия есть, а cookie нет
На проде любые изменения данных, включая выход из аккаунта, отвечали 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
This commit is contained in:
@@ -65,12 +65,19 @@ def generate_csrf_token() -> str:
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
|
||||
def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
def _csrf_max_age() -> int:
|
||||
"""Токен один на обе сессии, поэтому живёт не меньше самой долгой из них: иначе вход
|
||||
в админку (8 ч) перезаписывал токен игрока (7 дней), и после его истечения все мутации
|
||||
игрока падали с CSRF_FAILED. Без сессии токен бесполезен — лишний срок безопасен."""
|
||||
return max(settings.jwt_user_ttl_minutes, settings.jwt_admin_ttl_minutes) * 60
|
||||
|
||||
|
||||
def _set_csrf_cookie(response: Response) -> str:
|
||||
csrf = generate_csrf_token()
|
||||
response.set_cookie(
|
||||
key=CSRF_COOKIE,
|
||||
value=csrf,
|
||||
max_age=max_age,
|
||||
max_age=_csrf_max_age(),
|
||||
httponly=False, # должен читаться JS, чтобы продублировать в заголовок
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
@@ -80,6 +87,14 @@ def _set_csrf_cookie(response: Response, max_age: int) -> str:
|
||||
return csrf
|
||||
|
||||
|
||||
def fresh_csrf_set_cookie() -> tuple[bytes, bytes]:
|
||||
"""Готовый заголовок Set-Cookie со свежим токеном — для ASGI-middleware, где объекта
|
||||
Response нет. Строится тем же _set_csrf_cookie, чтобы атрибуты не разъехались."""
|
||||
carrier = Response()
|
||||
_set_csrf_cookie(carrier)
|
||||
return next((k, v) for k, v in carrier.raw_headers if k == b"set-cookie")
|
||||
|
||||
|
||||
def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
ttl = settings.jwt_user_ttl_minutes
|
||||
token = create_token(user_id, AUDIENCE_USER, ttl, provider)
|
||||
@@ -93,7 +108,7 @@ def set_user_session(response: Response, user_id: int, provider: str) -> None:
|
||||
path=_USER_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
_set_csrf_cookie(response)
|
||||
|
||||
|
||||
def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
@@ -109,7 +124,7 @@ def set_admin_session(response: Response, admin_id: int) -> None:
|
||||
path=_ADMIN_PATH,
|
||||
domain=settings.cookie_domain_value,
|
||||
)
|
||||
_set_csrf_cookie(response, ttl * 60)
|
||||
_set_csrf_cookie(response)
|
||||
|
||||
|
||||
def clear_user_session(response: Response) -> None:
|
||||
|
||||
+29
-4
@@ -34,12 +34,35 @@ _STATIC_DIR = Path(os.getenv("STATIC_DIR", str(Path(__file__).resolve().parent.p
|
||||
_UNSAFE_METHODS = {"POST", "PUT", "PATCH", "DELETE"}
|
||||
|
||||
|
||||
def _with_fresh_csrf_cookie(send): # noqa: ANN001, ANN202
|
||||
"""Дописывает свежий csrf_token в заголовки ответа. Трогает только
|
||||
http.response.start: тело (в т.ч. SSE-поток) проходит насквозь, чанк за чанком."""
|
||||
|
||||
async def wrapped(message): # noqa: ANN001
|
||||
if message["type"] == "http.response.start":
|
||||
headers = list(message.get("headers", []))
|
||||
already_set = any(
|
||||
k.lower() == b"set-cookie" and v.startswith(security.CSRF_COOKIE.encode() + b"=")
|
||||
for k, v in headers
|
||||
)
|
||||
if not already_set:
|
||||
headers.append(security.fresh_csrf_set_cookie())
|
||||
message = {**message, "headers": headers}
|
||||
await send(message)
|
||||
|
||||
return wrapped
|
||||
|
||||
|
||||
class CSRFMiddleware:
|
||||
"""Double-submit CSRF на чистом ASGI: для аутентифицированных мутаций на /api требуем
|
||||
совпадения заголовка X-CSRF-Token и cookie csrf_token.
|
||||
|
||||
Если сессия есть, а csrf_token в запросе нет (cookie истекла или её стёрли), любой ответ
|
||||
на /api — включая отказ ниже — перевыдаёт токен. Иначе состояние не лечилось: токен
|
||||
выдаётся только при входе, а войти и выйти мешала эта же проверка.
|
||||
|
||||
Намеренно НЕ на BaseHTTPMiddleware: тот буферизует потоковые ответы и ломает SSE
|
||||
(/api/events). Чистый ASGI пропускает стримы насквозь, вмешиваясь только при отказе CSRF.
|
||||
(/api/events). Чистый ASGI пропускает стримы насквозь.
|
||||
"""
|
||||
|
||||
def __init__(self, app) -> None: # noqa: ANN001
|
||||
@@ -48,13 +71,15 @@ class CSRFMiddleware:
|
||||
async def __call__(self, scope, receive, send): # noqa: ANN001
|
||||
if scope["type"] == "http":
|
||||
request = Request(scope)
|
||||
if request.method in _UNSAFE_METHODS and request.url.path.startswith("/api"):
|
||||
if request.url.path.startswith("/api"):
|
||||
has_session = (
|
||||
security.USER_COOKIE in request.cookies
|
||||
or security.ADMIN_COOKIE in request.cookies
|
||||
)
|
||||
if has_session:
|
||||
cookie_token = request.cookies.get(security.CSRF_COOKIE)
|
||||
cookie_token = request.cookies.get(security.CSRF_COOKIE)
|
||||
if has_session and not cookie_token:
|
||||
send = _with_fresh_csrf_cookie(send)
|
||||
if has_session and request.method in _UNSAFE_METHODS:
|
||||
header_token = request.headers.get(security.CSRF_HEADER)
|
||||
if not cookie_token or cookie_token != header_token:
|
||||
response = JSONResponse(
|
||||
|
||||
Reference in New Issue
Block a user