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:
parent
900fdc8ece
commit
ea119ffaa5
@ -602,6 +602,7 @@ Dockerfile только для локального compose
|
||||
если пользователь найден). Запись делается в отдельной транзакции и остаётся, хотя транзакция запроса откатилась.
|
||||
- Не события входа: запрос без заголовка `Authorization` и 403 «недостаточно прав». Ошибка записи аудита входа
|
||||
ответ не меняет (только лог). Выход (`LOGOUT`) не пишется: сессий на сервере нет.
|
||||
- Поля, которые присылает клиент, обрезаются: `user_agent` — до 512 символов, `description` — до 1000.
|
||||
|
||||
## Соседние документы
|
||||
|
||||
|
||||
@ -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),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user