From 112b583264959d77ceecc4b7330dde9c31ad86bc Mon Sep 17 00:00:00 2001 From: NotBigGhost Date: Sun, 13 Sep 2026 17:16:49 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B5=D0=B3=D0=B8=D1=81=D1=82=D1=80?= =?UTF-8?q?=D0=B0=D1=86=D0=B8=D1=8F:=20throttle=20=D0=BF=D0=BE=20IP=20?= =?UTF-8?q?=D0=BE=D1=82=20=D1=81=D0=BF=D0=B0=D0=BC=D0=B0=20=D0=B0=D0=BA?= =?UTF-8?q?=D0=BA=D0=B0=D1=83=D0=BD=D1=82=D0=BE=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit throttle_register ограничивает частоту POST /api/auth/register с одного IP через тот же LoginThrottle (считаются все попытки). Enumeration ников через 409 не закрываем — ники и так публичны в топе (отмечено в отчёте). #62 Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01XfTsytzT6TojfmprRDKiV6 --- backend/app/auth/password.py | 14 ++++++++++++++ backend/app/routers/auth.py | 3 ++- backend/tests/test_password_auth.py | 21 +++++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) diff --git a/backend/app/auth/password.py b/backend/app/auth/password.py index 7c60e8f..6e09643 100644 --- a/backend/app/auth/password.py +++ b/backend/app/auth/password.py @@ -34,6 +34,8 @@ _IP_LIMIT = 20 _ACCOUNT_LIMIT = 50 # Неверный текущий пароль при смене — на аккаунт. _CURRENT_PASSWORD_LIMIT = 5 +# Регистраций с одного IP за окно — против спама аккаунтов (#62). +_REGISTER_IP_LIMIT = 10 def validate_new_password(password: str) -> None: @@ -87,6 +89,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None) return user +def throttle_register(ip: str | None) -> None: + """Ограничивает частоту регистраций с одного IP (спам аккаунтов, #62). + + Считаем каждую попытку (и успешную, и нет), поэтому массовое создание аккаунтов + с уникальными никами упирается в предел так же, как повторы по занятому нику. + Enumeration ников через 409 NICKNAME_TAKEN не закрываем: ники и так публичны в топе.""" + ip = ip or "unknown" + limits = {f"register-ip:{ip}": _REGISTER_IP_LIMIT} + login_throttle.check(limits) + login_throttle.fail(limits) + + def check_current_password(user: User, current_password: str | None) -> None: """Сменить уже заданный пароль можно только зная текущий, и подбирать его нельзя: иначе оставленная открытой сессия позволила бы отобрать аккаунт насовсем.""" diff --git a/backend/app/routers/auth.py b/backend/app/routers/auth.py index acc9d87..a9fd310 100644 --- a/backend/app/routers/auth.py +++ b/backend/app/routers/auth.py @@ -8,7 +8,7 @@ from fastapi import APIRouter, Depends, Request, Response from sqlmodel import Session from app.auth.login import establish_session -from app.auth.password import login_player +from app.auth.password import login_player, throttle_register from app.auth.registry import enabled_methods from app.auth.telegram import TelegramProvider from app.core import security @@ -39,6 +39,7 @@ def password_register( session: Session = Depends(get_session), ) -> s.MeRead: """Регистрация по логину (нику) и паролю. Telegram привязывается позже в профиле.""" + throttle_register(client_ip(request)) # против спама аккаунтов с одного IP (#62) user = user_service.register_local(session, body.nickname, body.password) audit_service.record( session, diff --git a/backend/tests/test_password_auth.py b/backend/tests/test_password_auth.py index a49905c..65c78ce 100644 --- a/backend/tests/test_password_auth.py +++ b/backend/tests/test_password_auth.py @@ -65,6 +65,27 @@ def test_register_rejects_bad_nickname(client: TestClient): assert _register(client, nickname="x").status_code == 422 +def test_register_is_throttled_per_ip(client: TestClient, monkeypatch): + """Спам регистраций с одного IP упирается в лимит (#62).""" + import app.core.ratelimit as ratelimit + from app.auth.password import _REGISTER_IP_LIMIT + + now = [4000.0] + monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0]) + + for i in range(_REGISTER_IP_LIMIT): + client.cookies.clear() + assert _register(client, nickname=f"Ник{i}").status_code == 200 + client.cookies.clear() + blocked = _register(client, nickname="Лишний") + assert blocked.status_code == 429 + assert blocked.json()["error"]["code"] == "TOO_MANY_ATTEMPTS" + + now[0] += 15 * 60 # окно истекло + client.cookies.clear() + assert _register(client, nickname="ПослеОкна").status_code == 200 + + # ─── Вход ──────────────────────────────────────────────────────────────────── def test_login_after_logout(client: TestClient):