diff --git a/README.md b/README.md index 392afe1..9f2e099 100644 --- a/README.md +++ b/README.md @@ -602,6 +602,7 @@ Dockerfile только для локального compose если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась. - Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет. + - Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000. ## Соседние документы diff --git a/api/src/services/session_audit.py b/api/src/services/session_audit.py index 1599edc..16264e5 100644 --- a/api/src/services/session_audit.py +++ b/api/src/services/session_audit.py @@ -8,7 +8,8 @@ (пользователь не найден, отключён, неоднозначен: email в `description`, `user_id` — если пользователь найден). Запрос без заголовка `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`, в проде — `src.db.db.session_transaction`): транзакция запроса при 401/403 откатывается (R-1), а запись об отказе должна остаться. Ошибка записи аудита входа ответ не меняет: она логируется, LOGIN первого запроса будет повторён следующим запросом. @@ -26,6 +27,9 @@ from src.models.enums import AuditAction, AuditSection from src.services.audit import AuditService OBJECT_TYPE = "app_user" +#: пределы длины полей события входа: заголовок User-Agent и email из токена присылает клиент (ревью R-1) +USER_AGENT_MAX = 512 +DESCRIPTION_MAX = 1000 FIRST_REQUEST_DESCRIPTION = "первый запрос с токеном" DEV_TOKEN_DESCRIPTION = "выдан dev-токен" @@ -33,6 +37,11 @@ DEV_TOKEN_DESCRIPTION = "выдан dev-токен" 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]: """IP клиента (за прокси — из `ProxyHeadersMiddleware`) и заголовок `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, user_agent: str | None, ) -> None: - """Событие входа в транзакции `session` (раздел SESSION, объект — пользователь).""" + """Событие входа в транзакции `session` (раздел SESSION, объект — пользователь); длинные поля обрезаются.""" await AuditService(session).record( action=action, section=AuditSection.SESSION, object_type=OBJECT_TYPE, object_id=user_id, user_id=user_id, - description=description, + description=clip(description, DESCRIPTION_MAX), ip_address=ip_address, - user_agent=user_agent, + user_agent=clip(user_agent, USER_AGENT_MAX), ) diff --git a/api/tests/test_login_audit.py b/api/tests/test_login_audit.py index 22b8153..da3b984 100644 --- a/api/tests/test_login_audit.py +++ b/api/tests/test_login_audit.py @@ -22,7 +22,13 @@ from src.core.config import get_settings from src.models.access import AppUser from src.models.audit_ex import AuditEvent 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_UNAUTHORIZED = 401 @@ -253,3 +259,36 @@ async def test_failed_login_write_does_not_raise_and_is_retried() -> None: session.fail = False await audit.first_request(user_id, ip_address=None, user_agent=None) 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