Хардненинг по итогам пен-теста #25 (#56–#62) #63
@@ -27,6 +27,11 @@ _BCRYPT_MAX_BYTES = 72
|
|||||||
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
# одного аккаунта; на IP — против перебора по многим логинам с одного адреса.
|
||||||
_PAIR_LIMIT = 5
|
_PAIR_LIMIT = 5
|
||||||
_IP_LIMIT = 20
|
_IP_LIMIT = 20
|
||||||
|
# На сам аккаунт (IP-независимо, #60): распределённый перебор с ротацией IP всё равно
|
||||||
|
# упирается в этот предел. Щедрее пары, чтобы поток ошибок с разных адресов не запирал
|
||||||
|
# вход настоящему владельцу (лимит на аккаунт — вектор lockout-DoS, потому не строгий);
|
||||||
|
# успешный вход его сбрасывает.
|
||||||
|
_ACCOUNT_LIMIT = 50
|
||||||
# Неверный текущий пароль при смене — на аккаунт.
|
# Неверный текущий пароль при смене — на аккаунт.
|
||||||
_CURRENT_PASSWORD_LIMIT = 5
|
_CURRENT_PASSWORD_LIMIT = 5
|
||||||
|
|
||||||
@@ -67,14 +72,18 @@ def login_player(session: Session, nickname: str, password: str, ip: str | None)
|
|||||||
nickname = (nickname or "").strip()
|
nickname = (nickname or "").strip()
|
||||||
ip = ip or "unknown"
|
ip = ip or "unknown"
|
||||||
pair_key = f"login:{ip}:{nickname.casefold()}"
|
pair_key = f"login:{ip}:{nickname.casefold()}"
|
||||||
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT}
|
account_key = f"login-user:{nickname.casefold()}"
|
||||||
|
limits = {pair_key: _PAIR_LIMIT, f"login-ip:{ip}": _IP_LIMIT, account_key: _ACCOUNT_LIMIT}
|
||||||
login_throttle.check(limits)
|
login_throttle.check(limits)
|
||||||
try:
|
try:
|
||||||
user = authenticate_player(session, nickname, password)
|
user = authenticate_player(session, nickname, password)
|
||||||
except InvalidCredentialsError:
|
except InvalidCredentialsError:
|
||||||
login_throttle.fail(limits)
|
login_throttle.fail(limits)
|
||||||
raise
|
raise
|
||||||
|
# Успех снимает счётчики этого аккаунта (пара IP+логин и лимит на аккаунт); лимит по IP
|
||||||
|
# оставляем — он общий для всех логинов с адреса.
|
||||||
login_throttle.reset(pair_key)
|
login_throttle.reset(pair_key)
|
||||||
|
login_throttle.reset(account_key)
|
||||||
return user
|
return user
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
"""Ограничение неудачных попыток ввода пароля (защита от перебора).
|
||||||
|
|
||||||
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn.
|
Счётчики живут в памяти процесса — как и SSE-шина, это рассчитано на один воркер uvicorn
|
||||||
Перезапуск их обнуляет; для окна в 15 минут это приемлемо.
|
(`--workers 1`, см. entrypoint.sh/run.*). Перезапуск их обнуляет; для окна в 15 минут это
|
||||||
|
приемлемо. Ограничения устойчивости (#60):
|
||||||
|
* рестарт (в т.ч. деплой) сбрасывает окно — злоумышленник получает новую квоту после
|
||||||
|
перезапуска, но окно короткое, а рестарты редки;
|
||||||
|
* при уходе от одного воркера лимит делится между процессами (каждый считает своё) —
|
||||||
|
тогда счётчики нужно вынести во внешний стор (Redis pub/sub, как отмечено в CLAUDE.md
|
||||||
|
про SSE-шину), общий для всех воркеров.
|
||||||
|
Помимо пары «IP + логин» и лимита по IP есть IP-независимый лимит на аккаунт
|
||||||
|
(`login-user:*` / `admin-login-user:*`), чтобы ротация X-Forwarded-For / многих адресов
|
||||||
|
(#58) не снимала защиту полностью.
|
||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
"""Вход по логину (нику) и паролю: регистрация, вход, защита от перебора."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
from fastapi.testclient import TestClient
|
from fastapi.testclient import TestClient
|
||||||
from sqlmodel import Session, select
|
from sqlmodel import Session, select
|
||||||
|
|
||||||
@@ -181,6 +182,26 @@ def test_throttle_per_ip_across_logins(client: TestClient):
|
|||||||
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
|
assert _login(client, nickname="Ещё один", password="wrong-password").status_code == 429
|
||||||
|
|
||||||
|
|
||||||
|
def test_account_scoped_throttle_survives_ip_rotation(client: TestClient, engine, monkeypatch):
|
||||||
|
"""Перебор одного логина с РАЗНЫХ адресов (ротация X-Forwarded-For, #58) упирается в
|
||||||
|
IP-независимый лимит на аккаунт (#60): пара IP+логин и лимит по IP так не копятся."""
|
||||||
|
import app.core.ratelimit as ratelimit
|
||||||
|
from app.auth.password import _ACCOUNT_LIMIT, login_player
|
||||||
|
from app.core.errors import InvalidCredentialsError, TooManyAttemptsError
|
||||||
|
|
||||||
|
now = [3000.0]
|
||||||
|
monkeypatch.setattr(ratelimit.time, "monotonic", lambda: now[0])
|
||||||
|
_register(client, nickname="Жертва", password=PASSWORD)
|
||||||
|
|
||||||
|
with Session(engine) as s:
|
||||||
|
for i in range(_ACCOUNT_LIMIT): # каждый раз новый адрес
|
||||||
|
with pytest.raises(InvalidCredentialsError):
|
||||||
|
login_player(s, "Жертва", "wrong-password", ip=f"10.0.{i // 256}.{i % 256}")
|
||||||
|
# ещё одна попытка с совершенно нового адреса — уже блок по лимиту на аккаунт
|
||||||
|
with pytest.raises(TooManyAttemptsError):
|
||||||
|
login_player(s, "Жертва", "wrong-password", ip="203.0.113.7")
|
||||||
|
|
||||||
|
|
||||||
# ─── Установка и смена пароля ────────────────────────────────────────────────
|
# ─── Установка и смена пароля ────────────────────────────────────────────────
|
||||||
|
|
||||||
def _set_password(client: TestClient, new: str, current: str | None = None):
|
def _set_password(client: TestClient, new: str, current: str | None = None):
|
||||||
|
|||||||
Reference in New Issue
Block a user