fix(tradein/auth): предотвратить подмену X-Authenticated-User при session-auth (#2552)
All checks were successful
CI / changes (pull_request) Successful in 10s
CI Trade-In / changes (pull_request) Successful in 10s
CI Trade-In / frontend-checks (pull_request) Has been skipped
CI / backend-tests (pull_request) Has been skipped
CI / frontend-tests (pull_request) Has been skipped
CI / openapi-codegen-check (pull_request) Has been skipped
CI Trade-In / backend-tests (pull_request) Successful in 1m25s

CRITICAL: _propagate_authenticated_user делала skip-if-present вместо
перезаписи — клиент-контролируемый X-Authenticated-User (Caddy шлёт его
на КАЖДЫЙ прод-запрос) выигрывал у резолвленной сессии для всего
downstream-трафика, читающего заголовок напрямую (_assert_estimate_access*,
account_quota, /trade-in/history, support.py) — в обоих auth_mode
(dual и db_only). Теперь заголовок безусловно перезаписывается сессионным
username (ASGI header-имена всегда lowercase bytes).

Medium: .encode("latin-1") без errors="replace" крашил бы 500-кой каждый
запрос кириллического username. Login timing-oracle — verify_password
короткозамыкалась на unknown-username/NULL-hash (~1мс vs ~100-300мс bcrypt)
→ теперь всегда сверяется против dummy-хеша при отсутствующем юзере/хеше.
Login rate-limit key length-prefixed — username с ':' (или IPv6 IP) больше
не может схлопнуть чужой бюджет.

Новые тесты подтверждают регрессию: прогнаны на старом коде (до фикса)
через временный откат rbac.py — все три (spoof dual-mode, spoof db_only,
кириллица) падали с 'victim' == 'alice' / UnicodeEncodeError; после
фикса — зелёные. test_rbac.py/test_internal_auth_secret.py без изменений.
This commit is contained in:
bot-backend 2026-07-30 20:14:26 +03:00
parent 0835266516
commit 4ecc3d689c
3 changed files with 170 additions and 22 deletions

View file

@ -9,9 +9,20 @@ Security:
- Неверные creds (неизвестный username / неактивен / password_hash NULL / - Неверные creds (неизвестный username / неактивен / password_hash NULL /
неверный пароль) ОДИНАКОВЫЙ 401 с generic сообщением не раскрываем, неверный пароль) ОДИНАКОВЫЙ 401 с generic сообщением не раскрываем,
существует ли username (user-enumeration защита). существует ли username (user-enumeration защита).
- #2552 post-review Medium 2: `verify_password` ВСЕГДА вызывается ровно
один раз для несуществующего username / NULL password_hash сверяем
против статичного dummy-хеша (`_DUMMY_PASSWORD_HASH`, сгенерирован один
раз на импорте модуля), результат игнорируется. Без этого короткое
замыкание (`user is None сразу 401`) давало наблюдаемую разницу во
времени ответа (~1мс без bcrypt vs ~100-300мс с ним) классический
timing-oracle для user-enumeration, даже при одинаковом detail-сообщении.
- Rate-limit по (username, IP) ЖЁСТЧЕ общего `RateLimitMiddleware` - Rate-limit по (username, IP) ЖЁСТЧЕ общего `RateLimitMiddleware`
(`/api/*`), т.к. login типичная brute-force поверхность. Использует (`/api/*`), т.к. login типичная brute-force поверхность. Использует
`SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). `SlidingWindowLimiter` (тот же примитив, что и общий rate-limit). Ключ
length-prefixed (`len(username):username:ip`) без этого произвольный
username с `:` внутри мог бы схлопнуть бюджет с другой (username, ip)
парой (IPv6-адреса тоже содержат `:`, так что просто эскейпить разделитель
в username недостаточно паразитная граница возможна с обеих сторон).
- Raw-пароль НИКОГДА не логируется и не попадает в user_events payload - Raw-пароль НИКОГДА не логируется и не попадает в user_events payload
только username/ip/user_agent/path/method (см. schedule_event ниже). только username/ip/user_agent/path/method (см. schedule_event ниже).
""" """
@ -19,6 +30,7 @@ Security:
from __future__ import annotations from __future__ import annotations
import logging import logging
import secrets
from typing import Annotated from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, Request, Response from fastapi import APIRouter, Depends, HTTPException, Request, Response
@ -27,7 +39,7 @@ from sqlalchemy.orm import Session
from app.core.config import settings from app.core.config import settings
from app.core.db import get_db from app.core.db import get_db
from app.core.password import verify_password from app.core.password import hash_password, verify_password
from app.core.ratelimit import SlidingWindowLimiter, _client_ip from app.core.ratelimit import SlidingWindowLimiter, _client_ip
from app.services.auth_session import create_session, get_user_by_username, revoke_session from app.services.auth_session import create_session, get_user_by_username, revoke_session
from app.services.user_events import schedule_event from app.services.user_events import schedule_event
@ -46,6 +58,13 @@ _LOGIN_LIMITER = SlidingWindowLimiter(
window_s=settings.login_rate_limit_window_s, window_s=settings.login_rate_limit_window_s,
) )
# Timing-oracle защита (см. module docstring): bcrypt-хеш случайного пароля,
# сгенерированный ОДИН РАЗ на импорте модуля — используется вместо
# password_hash, когда юзер не найден/деактивирован/без пароля, чтобы
# `verify_password` (доминирующая по времени операция, ~100-300мс) всегда
# отрабатывала полный bcrypt-компар, независимо от того, существует ли аккаунт.
_DUMMY_PASSWORD_HASH = hash_password(secrets.token_urlsafe(16))
_INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль" _INVALID_CREDENTIALS_DETAIL = "неверный логин или пароль"
@ -67,7 +86,7 @@ async def login(
) -> LoginResponse: ) -> LoginResponse:
ip = _client_ip(request) ip = _client_ip(request)
user_agent = request.headers.get("user-agent") user_agent = request.headers.get("user-agent")
rate_key = f"{body.username}:{ip}" rate_key = f"{len(body.username)}:{body.username}:{ip}"
retry_after = _LOGIN_LIMITER.check(rate_key) retry_after = _LOGIN_LIMITER.check(rate_key)
if retry_after is not None: if retry_after is not None:
@ -78,12 +97,15 @@ async def login(
) )
user = get_user_by_username(db, body.username) user = get_user_by_username(db, body.username)
credentials_ok = ( hash_to_check = (
user is not None user["password_hash"]
and user["is_active"] if user is not None and user["password_hash"] is not None
and user["password_hash"] is not None else _DUMMY_PASSWORD_HASH
and verify_password(body.password, user["password_hash"])
) )
# ВСЕГДА вызывается — dummy-хеш при отсутствующем юзере/NULL password_hash
# держит время ответа одинаковым независимо от существования аккаунта.
password_ok = verify_password(body.password, hash_to_check)
credentials_ok = user is not None and user["is_active"] and password_ok
if not credentials_ok: if not credentials_ok:
schedule_event( schedule_event(

View file

@ -95,12 +95,25 @@ def _db_role_path_allowed(role: str, path: str) -> bool:
def _propagate_authenticated_user(request: Request, username: str) -> None: def _propagate_authenticated_user(request: Request, username: str) -> None:
"""Инжектит ``X-Authenticated-User`` в ASGI scope (если его там ещё нет), """Инжектит ``X-Authenticated-User`` в ASGI scope — ПЕРЕЗАПИСЫВАЯ, а не
чтобы ``RateLimitMiddleware``/``RequestAuditMiddleware`` (оба читают сырой только добавляя при отсутствии, чтобы ``RateLimitMiddleware``/
заголовок напрямую, #2213/#2550) и downstream route-хендлеры (читающие ``RequestAuditMiddleware`` (оба читают сырой заголовок напрямую,
его через FastAPI ``Header()``) видели сессионного DB-юзера так же, как #2213/#2550) и downstream route-хендлеры (читающие его через FastAPI
Caddy trusted-header юзера без правок в каждом из этих мест по ``Header()``) видели РЕЗОЛВЛЕННОГО ИЗ СЕССИИ юзера без правок в каждом
отдельности (минимально инвазивный способ). из этих мест по отдельности (минимально инвазивный способ).
#2552 post-review fix (CRITICAL): раньше это была skip-if-present
мутация (``if request.headers.get(...): return``) сессия резолвилась
ПЕРВОЙ (см. rbac_guard), но клиент-контролируемый ``X-Authenticated-User``
(который Caddy шлёт на КАЖДЫЙ прод-запрос) выигрывал у неё для ВСЕГО
downstream-трафика: атакующий с валидной cookie юзера ``alice`` мог
подделать заголовок ``X-Authenticated-User: victim`` и получить доступ к
данным victim в ~15 роутах, читающих заголовок напрямую
(``_assert_estimate_access*``, ``account_quota``, ``/trade-in/history``,
``support.py``) работало в ОБОИХ auth_mode (dual и db_only), т.к. эти
хендлеры не знают про rbac_guard'овский ``from_session`` флаг, только про
сырой заголовок. Session-identity ДОЛЖНА быть источником истины, если
сессия резолвлена полная перезапись, не skip.
Механизм: ``request.scope`` ОДИН и тот же dict-объект, прокинутый по Механизм: ``request.scope`` ОДИН и тот же dict-объект, прокинутый по
ссылке через весь ASGI call chain (Starlette не копирует scope между ссылке через весь ASGI call chain (Starlette не копирует scope между
@ -112,6 +125,10 @@ def _propagate_authenticated_user(request: Request, username: str) -> None:
предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()`` предыдущие) и читает ``request.headers`` уже ПОСЛЕ ``call_next()``
отработал весь внутренний стек, включая эту мутацию. отработал весь внутренний стек, включая эту мутацию.
ASGI header-имена всегда lowercase bytes (см. ASGI spec), поэтому
фильтр по ``b"x-authenticated-user"`` ловит заголовок независимо от
регистра, в котором его прислал клиент (Starlette уже нормализует).
Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно Известное ограничение: ``RateLimitMiddleware`` тоже внешний относительно
rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот rbac_guard, но читает заголовок ДО вызова call_next() (до того, как этот
guard успевает отработать) для ЭТОГО конкретного запроса сессионный guard успевает отработать) для ЭТОГО конкретного запроса сессионный
@ -122,12 +139,9 @@ def _propagate_authenticated_user(request: Request, username: str) -> None:
точного per-user квотинга для DB-юзеров переносить резолв сессии выше точного per-user квотинга для DB-юзеров переносить резолв сессии выше
RateLimit в app/main.py отдельным issue. RateLimit в app/main.py отдельным issue.
""" """
if request.headers.get("X-Authenticated-User"):
return
request.scope["headers"] = [ request.scope["headers"] = [
*request.scope.get("headers", []), (k, v) for k, v in request.scope.get("headers", []) if k != b"x-authenticated-user"
(b"x-authenticated-user", username.encode("latin-1")), ] + [(b"x-authenticated-user", username.encode("latin-1", "replace"))]
]
async def rbac_guard( async def rbac_guard(

View file

@ -18,12 +18,12 @@ from __future__ import annotations
import os import os
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from types import SimpleNamespace from types import SimpleNamespace
from typing import Any from typing import Annotated, Any
os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test") os.environ.setdefault("DATABASE_URL", "postgresql+psycopg://test:test@localhost:5432/test")
import pytest import pytest
from fastapi import FastAPI from fastapi import FastAPI, Header
from fastapi.testclient import TestClient from fastapi.testclient import TestClient
from app.api.v1 import auth as auth_router from app.api.v1 import auth as auth_router
@ -182,6 +182,15 @@ def _build_test_app(store: _Store) -> FastAPI:
async def tradein_dummy() -> dict: async def tradein_dummy() -> dict:
return {"ok": True} return {"ok": True}
@app.get("/api/v1/trade-in/whoami")
async def tradein_whoami(
x_authenticated_user: Annotated[str | None, Header(alias="X-Authenticated-User")] = None,
) -> dict:
"""Echoes the X-Authenticated-User header exactly as a downstream handler
(`_assert_estimate_access*`, `account_quota`, etc.) would see it used to
assert session-identity wins over a client-forged header (#2552 spoof fix)."""
return {"user": x_authenticated_user}
def _override_get_db(): # generator dependency — matches app.core.db.get_db shape def _override_get_db(): # generator dependency — matches app.core.db.get_db shape
yield _FakeDB(store) yield _FakeDB(store)
@ -271,6 +280,47 @@ def test_login_null_password_hash_401(client: TestClient, store: _Store) -> None
assert resp.status_code == 401 assert resp.status_code == 401
def test_login_always_calls_verify_password_timing_oracle_guard(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
"""#2552 post-review Medium 2: `verify_password` должен выполняться ровно
один раз на КАЖДУЮ попытку логина включая неизвестный username и NULL
password_hash иначе короткое замыкание даёт наблюдаемый timing-oracle
для user-enumeration. Тест не измеряет тайминг (флейки в CI), а проверяет
сам факт + аргумент вызова через monkeypatch-счётчик."""
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("nullhash", None, role="employee")
calls: list[str] = []
real_verify = auth_router.verify_password
def _counting_verify(plain: str, hashed: str) -> bool:
calls.append(hashed)
return real_verify(plain, hashed)
monkeypatch.setattr(auth_router, "verify_password", _counting_verify)
resp_unknown = client.post("/api/v1/auth/login", json={"username": "ghost", "password": "x"})
assert resp_unknown.status_code == 401
resp_null_hash = client.post(
"/api/v1/auth/login", json={"username": "nullhash", "password": "x"}
)
assert resp_null_hash.status_code == 401
resp_wrong_pw = client.post(
"/api/v1/auth/login", json={"username": "alice", "password": "wrong"}
)
assert resp_wrong_pw.status_code == 401
assert len(calls) == 3
# Unknown user / NULL hash — сверяется против dummy-хеша, не против NULL.
assert calls[0] == auth_router._DUMMY_PASSWORD_HASH
assert calls[1] == auth_router._DUMMY_PASSWORD_HASH
# Реальный юзер с реальным hash — НЕ dummy.
assert calls[2] != auth_router._DUMMY_PASSWORD_HASH
def test_login_rate_limit_429(client: TestClient, store: _Store) -> None: def test_login_rate_limit_429(client: TestClient, store: _Store) -> None:
store.add_user("dave", hash_password("Secret123!"), role="employee") store.add_user("dave", hash_password("Secret123!"), role="employee")
limit = config.settings.login_rate_limit limit = config.settings.login_rate_limit
@ -421,4 +471,66 @@ def test_session_user_can_reach_tradein_but_not_admin(client: TestClient, store:
assert ok.status_code == 200 assert ok.status_code == 200
denied = client.get("/api/v1/admin/dummy") denied = client.get("/api/v1/admin/dummy")
assert denied.status_code in (401, 403, 404) # rbac_guard's admin-gate matches the path regex BEFORE routing even happens
# (route isn't registered on this test app) — role=employee != admin -> 403,
# never a 404 (a bare "any non-2xx" assertion would mask a rbac_guard typo).
assert denied.status_code == 403
# ---------------------------------------------------------------------------
# #2552 post-review CRITICAL fix: session identity must win over a spoofed
# client-sent X-Authenticated-User header (was a skip-if-present bug — the
# forged header used to override the session for every downstream reader of
# the raw header: _assert_estimate_access*, account_quota, /trade-in/history,
# support.py — in BOTH auth_mode=dual and db_only).
# ---------------------------------------------------------------------------
def test_session_identity_wins_over_spoofed_header_dual_mode(
client: TestClient, store: _Store
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_session_identity_wins_over_spoofed_header_db_only_mode(
client: TestClient, store: _Store, monkeypatch: pytest.MonkeyPatch
) -> None:
store.add_user("alice", hash_password("Secret123!"), role="employee")
store.add_user("victim", hash_password("Secret123!"), role="employee")
client.post("/api/v1/auth/login", json={"username": "alice", "password": "Secret123!"})
monkeypatch.setattr(config.settings, "auth_mode", "db_only")
resp = client.get(
"/api/v1/trade-in/whoami",
headers={"X-Authenticated-User": "victim"},
)
assert resp.status_code == 200
assert resp.json()["user"] == "alice"
def test_cyrillic_username_session_propagation_does_not_500(
client: TestClient, store: _Store
) -> None:
"""#2552 post-review Medium 1: `.encode("latin-1")` без errors="replace" на
кириллическом username крашил бы КАЖДЫЙ запрос такого юзера с 500."""
store.add_user("алиса", hash_password("Secret123!"), role="employee")
login_resp = client.post(
"/api/v1/auth/login", json={"username": "алиса", "password": "Secret123!"}
)
assert login_resp.status_code == 200, login_resp.text
resp = client.get("/api/v1/trade-in/whoami")
assert resp.status_code == 200, resp.text
# latin-1 "replace" гарантированно не крашит — точное значение (что именно
# получится из non-latin1 байт) не является контрактом, важно отсутствие 500.
assert resp.json()["user"] is not None