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