fix: события входа обрезают user_agent до 512 и description до 1000 символов (ревью R-1, #9)

`clip` в `services/session_audit.py` применяется в `record_login` (общая точка
LOGIN/LOGIN_FAILED): заголовок User-Agent и email из токена присылает клиент,
без ограничения запись в журнал была бы произвольной длины. Тесты: помощник,
запись `SessionAudit` без БД и длинный User-Agent при 401 через API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012vJkmBGKKqMoEMzvsXG3y2
This commit is contained in:
Andrey Epifancev 2026-10-05 13:45:29 +04:00
parent 900fdc8ece
commit ea119ffaa5
3 changed files with 54 additions and 5 deletions

View File

@ -602,6 +602,7 @@ Dockerfile только для локального compose
если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась. если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась.
- Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа - Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа
ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет. ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет.
- Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000.
## Соседние документы ## Соседние документы

View File

@ -8,7 +8,8 @@
(пользователь не найден, отключён, неоднозначен: email в `description`, `user_id` — если пользователь найден). (пользователь не найден, отключён, неоднозначен: email в `description`, `user_id` — если пользователь найден).
Запрос без заголовка `Authorization` попыткой входа не считается; 403 «недостаточно прав» — авторизация, не вход. Запрос без заголовка `Authorization` попыткой входа не считается; 403 «недостаточно прав» — авторизация, не вход.
Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса. Раздел `SESSION`, `object_type = "app_user"`, `object_id = user_id`; IP и user-agent — из запроса. Поля, которые
присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000.
LOGIN_FAILED и LOGIN первого запроса пишутся в ОТДЕЛЬНОЙ короткой транзакции (`transaction`, в проде — LOGIN_FAILED и LOGIN первого запроса пишутся в ОТДЕЛЬНОЙ короткой транзакции (`transaction`, в проде —
`src.db.db.session_transaction`): транзакция запроса при 401/403 откатывается (R-1), а запись об отказе должна остаться. `src.db.db.session_transaction`): транзакция запроса при 401/403 откатывается (R-1), а запись об отказе должна остаться.
Ошибка записи аудита входа ответ не меняет: она логируется, LOGIN первого запроса будет повторён следующим запросом. Ошибка записи аудита входа ответ не меняет: она логируется, LOGIN первого запроса будет повторён следующим запросом.
@ -26,6 +27,9 @@ from src.models.enums import AuditAction, AuditSection
from src.services.audit import AuditService from src.services.audit import AuditService
OBJECT_TYPE = "app_user" OBJECT_TYPE = "app_user"
#: пределы длины полей события входа: заголовок User-Agent и email из токена присылает клиент (ревью R-1)
USER_AGENT_MAX = 512
DESCRIPTION_MAX = 1000
FIRST_REQUEST_DESCRIPTION = "первый запрос с токеном" FIRST_REQUEST_DESCRIPTION = "первый запрос с токеном"
DEV_TOKEN_DESCRIPTION = "выдан dev-токен" DEV_TOKEN_DESCRIPTION = "выдан dev-токен"
@ -33,6 +37,11 @@ DEV_TOKEN_DESCRIPTION = "выдан dev-токен"
TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]] TransactionFactory = Callable[[], AbstractAsyncContextManager[AsyncSession]]
def clip(text: str | None, limit: int) -> str | None:
"""Строка не длиннее `limit` символов (`None` остаётся `None`)."""
return text if text is None else text[:limit]
def request_client(request: HTTPConnection) -> tuple[str | None, str | None]: def request_client(request: HTTPConnection) -> tuple[str | None, str | None]:
"""IP клиента (за прокси — из `ProxyHeadersMiddleware`) и заголовок `User-Agent` запроса.""" """IP клиента (за прокси — из `ProxyHeadersMiddleware`) и заголовок `User-Agent` запроса."""
return (request.client.host if request.client else None, request.headers.get("user-agent")) return (request.client.host if request.client else None, request.headers.get("user-agent"))
@ -47,16 +56,16 @@ async def record_login( # noqa: PLR0913 - поля события входа б
ip_address: str | None, ip_address: str | None,
user_agent: str | None, user_agent: str | None,
) -> None: ) -> None:
"""Событие входа в транзакции `session` (раздел SESSION, объект — пользователь).""" """Событие входа в транзакции `session` (раздел SESSION, объект — пользователь); длинные поля обрезаются."""
await AuditService(session).record( await AuditService(session).record(
action=action, action=action,
section=AuditSection.SESSION, section=AuditSection.SESSION,
object_type=OBJECT_TYPE, object_type=OBJECT_TYPE,
object_id=user_id, object_id=user_id,
user_id=user_id, user_id=user_id,
description=description, description=clip(description, DESCRIPTION_MAX),
ip_address=ip_address, ip_address=ip_address,
user_agent=user_agent, user_agent=clip(user_agent, USER_AGENT_MAX),
) )

View File

@ -22,7 +22,13 @@ from src.core.config import get_settings
from src.models.access import AppUser from src.models.access import AppUser
from src.models.audit_ex import AuditEvent from src.models.audit_ex import AuditEvent
from src.models.enums import AuditAction, AuditSection from src.models.enums import AuditAction, AuditSection
from src.services.session_audit import FIRST_REQUEST_DESCRIPTION, SessionAudit from src.services.session_audit import (
DESCRIPTION_MAX,
FIRST_REQUEST_DESCRIPTION,
USER_AGENT_MAX,
SessionAudit,
clip,
)
HTTP_OK = 200 HTTP_OK = 200
HTTP_UNAUTHORIZED = 401 HTTP_UNAUTHORIZED = 401
@ -253,3 +259,36 @@ async def test_failed_login_write_does_not_raise_and_is_retried() -> None:
session.fail = False session.fail = False
await audit.first_request(user_id, ip_address=None, user_agent=None) await audit.first_request(user_id, ip_address=None, user_agent=None)
assert audit.logged_in == {user_id} assert audit.logged_in == {user_id}
# --- ограничение длины полей события входа (ревью R-1) ------------------------------------------------------------
def test_clip_limits_text_length() -> None:
assert clip(None, 5) is None
assert clip("abc", 5) == "abc"
assert clip("a" * 10, 5) == "aaaaa"
async def test_login_event_fields_are_clipped() -> None:
session = _RecordingSession(fail=False)
audit = SessionAudit(_fake_transaction(session)) # type: ignore[arg-type]
await audit.login_failed(None, "x" * 5000, ip_address="10.0.0.1", user_agent="U" * 5000)
(event,) = session.added
assert isinstance(event, AuditEvent)
assert event.user_agent == "U" * USER_AGENT_MAX
assert event.description == "x" * DESCRIPTION_MAX
@pytest.mark.integration
async def test_long_user_agent_is_clipped_in_journal(app: FastAPI, seeded: AsyncSession) -> None:
async with _client(app) as client:
response = await client.get(
"/api/v1/me", headers={**_bearer(GARBAGE_TOKEN), "User-Agent": "A" * (USER_AGENT_MAX * 4)}
)
assert response.status_code == HTTP_UNAUTHORIZED
(event,) = await _events(seeded)
assert event.user_agent == "A" * USER_AGENT_MAX