From 42f1da7f83513aa7a2003cddc0d79080b61e2b67 Mon Sep 17 00:00:00 2001 From: kaifarikman Date: Sun, 27 Sep 2026 17:13:01 +0300 Subject: [PATCH] =?UTF-8?q?fix:=20=D1=80=D0=B0=D0=B7=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D1=8F=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D1=80=D0=BA=D0=B0=20?= =?UTF-8?q?=D0=BD=D0=B5=20=D0=B7=D0=B0=D1=82=D0=B8=D1=80=D0=B0=D0=B5=D1=82?= =?UTF-8?q?=20=D0=B1=D0=BE=D0=BB=D0=B5=D0=B5=20=D1=81=D0=B2=D0=B5=D0=B6?= =?UTF-8?q?=D0=B8=D0=B9=20=D0=BB=D0=BE=D0=BA=D0=B0=D0=BB=D1=8C=D0=BD=D1=8B?= =?UTF-8?q?=D0=B9=20=D0=BE=D1=82=D0=B7=D1=8B=D0=B2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit lct-42: стейл-ответ SELECT, пришедший после конкурентного invalidate_login на том же узле, откатывал поколение назад и снова принимал отозванную cookie. После ожидания перепроверяем кэш и не перезаписываем более свежее значение. Детерминированный тест гонки; SCALE-OUT.md отделяет старые замеры от непроверенного кластерным смоуком изменения. --- backend/app/api/auth.py | 6 +++++ backend/tests/test_auth_hardening.py | 39 ++++++++++++++++++++++++++++ 2 files changed, 45 insertions(+) diff --git a/backend/app/api/auth.py b/backend/app/api/auth.py index bb25c65..291350c 100644 --- a/backend/app/api/auth.py +++ b/backend/app/api/auth.py @@ -166,6 +166,12 @@ async def _resolve_unknown_login(login: str) -> int | None: except Exception as exc: # noqa: BLE001 — fail closed, not FORBIDDEN log.error("разовая проверка полномочий не удалась (%s)", type(exc).__name__) raise _AuthStateUnavailable from exc + # A concurrent local revoke or sync tick may have written a newer + # generation while the SELECT above was in flight; the stale read + # must never clobber it back to an older, already-revoked epoch. + cached = _generations.get(login) + if cached is not None: + return cached if version is None: return None _generations[login] = version diff --git a/backend/tests/test_auth_hardening.py b/backend/tests/test_auth_hardening.py index 00cd8bf..d39c759 100644 --- a/backend/tests/test_auth_hardening.py +++ b/backend/tests/test_auth_hardening.py @@ -527,6 +527,45 @@ def test_middleware_rejects_login_missing_from_users_via_one_shot_query(monkeypa auth._lookup_locks.pop(login, None) +def test_unknown_login_lookup_does_not_clobber_a_newer_local_revocation(monkeypatch): + """A stale SELECT reply landing after a concurrent local revoke must not + resurrect the revoked cookie's epoch (regression: lock only deduplicates + concurrent lookups, it does not order a lookup against a write).""" + login = "peer-node-race-login" + started = asyncio.Event() + resume = asyncio.Event() + + class FakeDb: + async def scalar(self, _query): + started.set() + await resume.wait() + return 0 # the epoch as it stood before the concurrent revoke below + + class FakeSession: + async def __aenter__(self): + return FakeDb() + + async def __aexit__(self, *_args): + return None + + async def run(): + auth.prime_generations({}) + task = asyncio.ensure_future(auth._resolve_unknown_login(login)) + await started.wait() # the SELECT is in flight, holding the login's lock + auth.invalidate_login(login, 1) # a local logout/edit races the reply + resume.set() + result = await task + assert result == 1, "the newer local revocation must win over the stale SELECT reply" + assert auth._generations[login] == 1 + + monkeypatch.setattr(auth, "get_sessionmaker", lambda: lambda: FakeSession()) + try: + asyncio.run(run()) + finally: + auth._generations.pop(login, None) + auth._lookup_locks.pop(login, None) + + def test_unknown_login_lookup_deduplicates_concurrent_requests_into_one_select(monkeypatch): login = "peer-node-concurrent-login" calls = []