fix(auth): count login lockouts in the database across Gateway replicas (#6501)

* fix(auth): count login lockouts in the database across Gateway replicas

POST /api/v1/auth/login/local throttled failed logins per client IP in a
module-level dict, so with N Gateway replicas behind one load balancer an
attacker got N x auth.local.max_login_attempts guesses and a lockout on
one replica was invisible to the others.

Move the counter behind a LoginThrottleStore (deerflow.persistence.
login_throttle): MemoryLoginThrottleStore keeps the historical per-process
semantics and 10k tracked-IP bound; SqlLoginThrottleStore shares one
login_throttle row per IP through the application database, incrementing
and starting the lock in a single dialect-native upsert, applying check
decisions with compare-and-set predicates in a separate write
transaction, and sweeping served locks plus 24h-idle counters in bounded
batches. Both stores pin the existing contract: the lock starts when the
count reaches max_login_attempts, the committed duration is honored when
the policy shrinks mid-lock, a served sentence is never resurrected,
tightening keeps the count, success resets the IP, and the 429 status and
message are unchanged.

New startup-only selector auth.local.throttle_storage (auto | memory | db,
default auto = the database whenever database.backend is sqlite or
postgres). The Gateway installs the store once at startup after the
persistence engine; deps._validate_login_throttle_storage warns when a
multi-process deployment keeps memory. Migration 0034_login_throttle
creates the table; config_version 55 -> 56 in config.example.yaml and the
Helm chart. Docs: README, CONFIGURATION.md, AUTH_DESIGN.md, config and
migrations AGENTS.md, CHANGELOG.

* docs(changelog): link the login throttle entry to #6501

* fix(auth): re-check expiry on the row the login-throttle sweep deletes

The bounded cleanup in SqlLoginThrottleStore.record_failure deleted
`WHERE ip IN (SELECT ip ... WHERE <served or stale> LIMIT 200)`. On
PostgreSQL READ COMMITTED the expiry predicate applied only inside the
snapshot-bound subquery: when the DELETE had to wait on a row lock held
by a concurrent check() that was extending the same lock under a raised
lockout_seconds, PostgreSQL re-evaluated only the outer WHERE on the
updated tuple, IP membership still held for the old candidate, and the
now-active lock was deleted; the next attempt from that IP got a clean
record and reached password verification (review of #6501, P2).

Repeat the served/stale predicate on the DELETE target as well
(SqlLoginThrottleStore.sweep_statement), keeping the bounded candidate
subquery, so the re-evaluation sees the updated row's expiry. The other
writes already carry their full compare-and-set predicate on the target
row: _discard and the check() UPDATE both use _matches (ip, fail_count,
locked_at, lock_duration_seconds), so a tuple changed while they waited
no longer matches and the stale decision is skipped. _discard keeps the
equality compare-and-set rather than adding an expiry condition: its
early-release branch deletes a lock whose committed sentence has not
elapsed under the lowered live policy, which an expiry predicate would
wrongly block, and the equality predicate already pins every input of
the decision. reset stays unconditional by design.

Tests: a dialect-compile test asserts the DELETE's own WHERE carries the
expiry predicate for postgresql and sqlite (red on the previous HEAD), and
a PostgreSQL-only test (skipped without TEST_POSTGRES_URI) reproduces the
update-versus-sweep interleaving with two engines, waiting on
pg_stat_activity for the sweep to block before the extender commits.
Docs: sql.py concurrency contract and the migrations guide's 0034 bullet.

* fix(auth): keep an active login lock's clock and fail closed on explicit db

Address the "changes requested" review on #6501.

Lock clock (confirmed defect): record_failure re-stamped locked_at = now
for every failure on an already-locked row, so a sustained attacker's
sentence slid forward with each attempt. Both stores now apply one rule
in one atomic decision: new_count = fail_count + 1; below max_attempts the
IP counts with no lock (a failure after the operator raised the threshold
clears an existing lock); at or over it, a failure during an *active* lock
keeps locked_at and the committed lock_duration unchanged, while a
never-locked or served row starts anew with now / lockout_seconds (so the
failure that reaches a raised threshold starts a fresh lock rather than
keeping a stale locked_at). The SQL upsert expresses it as nested case()
on the row's own values; the memory store mirrors it. The sweeps in both
stores now leave the row being recorded to that decision instead of
deleting a served lock or stale counter first, which split the contract
(SQL would have produced a fresh (1, NULL, NULL) row). base.py states the
rule.

Explicit db downgrade: resolve_login_throttle_store raises SystemExit —
the failure type of the other startup validations in app.gateway.deps —
when throttle_storage='db' resolves to the shared table but no engine is
initialised, so langgraph_runtime refuses to start instead of silently
running every replica on per-process counters; the lazy bare-app path
applies the same rule and installs nothing. 'auto' keeps warning and
falling back. 'db' on a memory database still degrades with a warning
(there is no shared table to insist on).

Non-blocking items: _key() logs a warning when a client key is truncated
to 255 characters (never the key itself); the sql.py docstring covers
wall-clock locked_at across replicas (run NTP; skew shifts evaluation by
that much) and why STALE_COUNTER_SECONDS=24h / SWEEP_BATCH_SIZE=200 were
chosen; a store-level blocking-I/O anchor covers the SQL reset path; the
_validate_login_throttle_storage warning was verified to render
'throttle_storage=memory' for the StrEnum (the gate test now passes the
enum member and asserts that text). reset stays unconditional: a peer
failure that commits after a reset leaves fail_count=1, one genuine
failure after the cleared history, which a new test documents.

Docs: CHANGELOG entry, config.example.yaml, CONFIGURATION.md,
AUTH_DESIGN.md and the throttle_storage field description.

* fix(auth): re-evaluate a login-throttle check whose compare-and-set missed

SqlLoginThrottleStore.check applied its decision (commit the live
duration, or clear a served / released lock) with a compare-and-set
predicate on the snapshot it had read, but ignored the affected-row
count. When a peer changed the row between the read and the write — an
admitted wrong password incrementing fail_count on another replica, or a
successful login clearing the row — the UPDATE or DELETE matched zero
rows, yet check reported the decision as applied: it could return an
extended 60s sentence while the row kept its 1s duration, so the next
check cleared the lock and let password verification through; a live
duration decrease could be dropped the same way; and a row a peer had
just cleared was still reported as locked (#6501 review, P2).

Every compare-and-set write in check now checks result.rowcount. On a
miss the row is re-read and re-evaluated from the top, up to
CHECK_CAS_ATTEMPTS (3) times, applying a fresh compare-and-set decision
on the new snapshot and never clobbering a reset or replacement: a row
that is gone is reported as allowed, a fresh peer lock is reported as
locked, and an incremented count gets the extension or decrease
committed on it. Exhausting the bound fails closed: nothing is written
and the answer is the last snapshot's lock under the live policy, logged
at debug. The read stays in its own short transaction and each write in
a separate one.

The evaluation is now one shared pure function, evaluate_check in
base.py (CheckDecision), used by both stores so the contract cannot
diverge. The memory store keeps a single pass: its compare-and-set
cannot miss because there is no await between the read and the write on
the loop thread, which its docstring now states.

Tests (red first on df26c2acf) use a session proxy that runs the peer
mutation on a second engine right after the snapshot read ends its
transaction: the reviewer's raised-duration interleaving (59.5s reported
and committed, the increment survives, a later check still locked), the
mirrored decrease, early release on the fresh row, a reset landing
between read and write (0.0, no row recreated), and the bound-exhausted
fail-closed case.

* docs(changelog): resolve merge markers left in the Unreleased section

* test(migrations): pin 0035_login_throttle as the head in the 0034 watermark test

Upstream #6477 added test_migration_0034_run_event_seq_watermark.py asserting
0034 is the single head; this branch chains 0035_login_throttle after it, so
the test now pins the current head and walks the chain like its siblings.

* perf(auth): one session per login-throttle check, one transaction per failure

Two non-blocking performance suggestions from the #6501 review.

Single session per check. The router probed the row with store.get()
and then called store.check(), which re-read it: a recorded IP cost two
connection-pool checkouts and two SELECTs per request on the
unauthenticated login path, letting a lockout flood compete with
legitimate traffic for Postgres connections. LoginThrottleStore.check
now takes the policy as an async callable (LoginThrottlePolicy) and
resolves it lazily inside the store: the probe, the policy resolution
and the compare-and-set loop share one session, a clean IP returns 0.0
without ever calling the policy (the config.yaml read is still skipped
for the common case), and a recorded IP resolves it exactly once, also
across compare-and-set retries. The decision is made on a snapshot read
after the resolution: the policy read is a yield point, and a decision
that needs no write has no compare-and-set to catch a row a peer changed
meanwhile — so one session, but still a read after the yield. The
memory store takes the same signature and re-reads its record after the
await for the same reason. The router passes its asyncio.to_thread
policy read as the callable and drops the separate probe; get stays on
the protocol for tests and anchors. No short-TTL verdict cache: it would
mask a successful login's reset for the TTL window, and the single
session already halves the hot path.

Sweep in the upsert's transaction, plus an index. record_failure ran the
housekeeping DELETE in its own transaction and the upsert in a second
one, two commits (two WAL flushes on PostgreSQL) per failed login; the
sweep now rides in the upsert's transaction with one commit, the keep
exclusion already protecting the row being recorded. The new
ix_login_throttle_updated_at (ORM __table_args__ and the unreleased
0035 migration, created after the table and dropped before it, each
guarded separately so a table left by an interrupted attempt still gets
its index) serves the sweep's stale-counter predicate; the served-lock
predicate scans a table the sweep itself keeps small.

Tests (red first on a137fbc47): a clean IP never resolves the policy and
a recorded IP resolves it exactly once (both stores), the SQL store opens
exactly one session per check and commits once per record_failure
(counting wrappers), the migration test asserts the index after upgrade,
after completing a partial upgrade that lacked it, and gone after
downgrade while compare_metadata stays empty; the router-level
"mutated during policy resolution" tests now inject their mutation
through the policy callable.

* docs(changelog): note config_version 57 in the login throttle entry
This commit is contained in:
Nan Gao 2026-10-08 22:35:19 +08:00 committed by GitHub
parent 62c54238d2
commit b99ac916b6
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
32 changed files with 2724 additions and 466 deletions

View File

@ -804,6 +804,29 @@ This release closes that milestone with **439 merged pull requests**.
### Fixed
- **auth:** Login lockouts are now counted once per client IP across every
Gateway replica. `POST /api/v1/auth/login/local` kept its failed-login
counter in a per-process dict, so with N replicas behind one load balancer an
attacker got N × `max_login_attempts` guesses and a lockout on one replica
was invisible to the others. The counter now lives behind a
`LoginThrottleStore`: the new `auth.local.throttle_storage` selector
(default `auto`) keeps it in the shared `login_throttle` table (migration
`0035_login_throttle`) whenever `database.backend` is `sqlite` or
`postgres`, and falls back to the in-process counter with a warning when
there is no database to share (a `memory` database, or a configured database
whose engine is not initialised); `memory` forces the historical per-process
behavior; `db` forces the table and refuses to start when the configured
database's engine is unavailable. The Gateway resolves the store once at
startup, right after the persistence engine; a bare app resolves it on the
first throttle call. Failures are counted with one atomic upsert that keeps
an active lock's start and committed duration (the sentence is "N seconds
after the lock started", not "after the last attempt"), the duration
committed at lock time is still honored when the policy changes mid-lock, a
successful login clears the IP everywhere, served locks and idle counters are
swept in bounded batches, and a declared multi-instance deployment that
keeps `memory` logs a startup warning. Status codes and messages of the
login endpoint are unchanged; `max_login_attempts` and `lockout_seconds`
stay live-read. `config_version` is now 57. ([#6501])
- **memory:** DeerMem's derived SQLite FTS5 retrieval index can now live
outside the memory root, and a Gateway instance now notices facts another
instance wrote. The index for every user was one SQLite database in WAL mode
@ -9259,4 +9282,5 @@ with **180 merged pull requests** since the first 2.0 milestone tag.
[#6484]: https://github.com/bytedance/deer-flow/pull/6484
[#6494]: https://github.com/bytedance/deer-flow/pull/6494
[#6495]: https://github.com/bytedance/deer-flow/pull/6495
[#6501]: https://github.com/bytedance/deer-flow/pull/6501
[#6506]: https://github.com/bytedance/deer-flow/pull/6506

View File

@ -519,7 +519,7 @@ Browser login uses `HttpOnly` session cookies. The login page offers a "keep me
DeerFlow still uses `Forwarded` / `X-Forwarded-*` headers to recover the browser-facing scheme and origin behind a proxy. The bundled nginx sets `X-Forwarded-Proto`, but preserves an upstream HTTPS value and does not overwrite every forwarded header. Configure the outer trusted proxy to replace or strip client-supplied forwarding headers before traffic reaches DeerFlow.
> [!IMPORTANT]
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. Kubernetes replicas run one worker per Pod, which the worker count cannot see: declare them with `deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`, which deploy tooling such as a Helm chart can set from its replica count) so the startup gate enforces the same prerequisites instead of staying inert. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. IM channel state and other process-local services still need their own multi-worker coordination.
> The Gateway still owns active run tasks in process, so production defaults to a single Gateway worker (`GATEWAY_WORKERS=1`). Multi-worker deployments require Postgres, the Redis stream bridge (`stream_bridge.type: redis`), `run_ownership.heartbeat_enabled: true`, and `run_events.backend: db`; process-local memory/JSONL event stores cannot enforce singleton delivery receipts across workers. Kubernetes replicas run one worker per Pod, which the worker count cannot see: declare them with `deployment.multi_instance: true` (or `DEER_FLOW_MULTI_INSTANCE=1`, which deploy tooling such as a Helm chart can set from its replica count) so the startup gate enforces the same prerequisites instead of staying inert. The bridge shares SSE delivery and bounded `Last-Event-ID` replay across workers. When a valid reconnect cursor has been trimmed, or a subscriber that already established an empty-stream wait falls behind before its first delivery, Memory and Redis emit a machine-readable SSE `gap` event instead of silently returning a partial replay; the Web UI reloads durable thread/event state and resumes from the retained tail. Lease reconciliation marks runs from dead workers as errors, persists their delivery receipts, publishes the terminal stream marker, schedules retained-stream cleanup, and updates the affected thread status. SSE, `/wait`, and internal stream consumers use `stream_bridge.heartbeat_interval_seconds` (default `15`) for idle liveness checks; changing it requires a Gateway restart. Malformed Redis reconnect IDs live-tail new events instead of replaying the retained buffer, and the rolling retained-buffer TTL (`stream_ttl_seconds`) remains a cleanup safety net rather than a run timeout. Failed-login counters and lockouts for `POST /api/v1/auth/login/local` (`auth.local.max_login_attempts` / `lockout_seconds`) are kept in the shared `login_throttle` table whenever the application database is SQLite or Postgres (`auth.local.throttle_storage: auto`, the default), so every replica enforces one lockout per client IP; `memory` keeps the historical per-process counter, which under N replicas hands an attacker N × `max_login_attempts` guesses and logs a startup warning. IM channel state and other process-local services still need their own multi-worker coordination.
>
> In single-process JSONL deployments, cancelling an admitted event-store mutation waits for its background file I/O, rollback, and bookkeeping to settle before releasing the thread write lock. This prevents an older cancelled write from recreating deleted records or rolling back a later successful write. Cancellation can therefore wait on slow storage; it does not stop an in-flight filesystem operation. Callers still waiting to acquire the lock can cancel without starting a mutation. A batch spanning multiple threads drains its current thread group before propagating cancellation; subsequent thread groups do not start.
>

View File

@ -0,0 +1,121 @@
"""Resolve and hold the Gateway's login throttle store.
The auth router counts failed local logins through a
:class:`~deerflow.persistence.login_throttle.LoginThrottleStore`. Which
implementation backs it is decided once per process from
``auth.local.throttle_storage`` and ``database.backend``
(:func:`deerflow.config.auth_config.resolve_login_throttle_storage`):
- ``db`` (the ``auto`` result whenever an application database exists) —
:class:`SqlLoginThrottleStore` over the shared ``login_throttle`` table, so
every Gateway replica using that database enforces one lockout per IP;
- ``memory`` — :class:`MemoryLoginThrottleStore`, the historical per-process
dict; under several replicas an attacker gets N × ``max_login_attempts``
guesses, which ``app.gateway.deps._validate_login_throttle_storage`` warns
about at startup.
``langgraph_runtime`` installs the store right after the persistence engine
is initialised, mirroring ``get_local_provider``'s process-level cache. A bare
app that never ran that lifespan (tests, embedded routers) resolves lazily on
the first throttle call from the live config. Tests swap the store through
:func:`install_login_throttle_store` / :func:`reset_login_throttle_store`.
"""
from __future__ import annotations
import logging
import threading
from typing import Any
from deerflow.config.auth_config import LocalAuthConfig, resolve_login_throttle_storage
from deerflow.persistence.login_throttle import LoginThrottleStore, MemoryLoginThrottleStore, SqlLoginThrottleStore
logger = logging.getLogger(__name__)
_store: LoginThrottleStore | None = None
_install_lock = threading.Lock()
def resolve_login_throttle_store(config: Any | None, *, session_factory: Any | None) -> LoginThrottleStore:
"""Build the store ``config`` asks for, given the (possibly absent) ORM session factory.
``auto`` never raises: with no database to share, or a configured database
whose engine is not initialised (a bare app without the Gateway lifespan),
it degrades to the in-process counter with a warning so the login endpoint
keeps throttling. An explicit ``db`` is an operator statement that lockouts
must be shared: when the configured database's engine is unavailable this
raises ``SystemExit`` with an actionable message — the same failure type
as the other startup validations in ``app.gateway.deps`` — so
``langgraph_runtime`` refuses to start instead of silently running every
replica on per-process counters. ``db`` on a ``memory`` database still
degrades with a warning (there is no shared table to insist on).
"""
local = getattr(getattr(config, "auth", None), "local", None)
selector = getattr(local, "throttle_storage", LocalAuthConfig.model_fields["throttle_storage"].default)
selector_value = str(getattr(selector, "value", selector))
database_backend = getattr(getattr(config, "database", None), "backend", None)
resolved = resolve_login_throttle_storage(selector, database_backend)
if resolved == "memory":
if selector_value == "db":
logger.warning(
"auth.local.throttle_storage=db requires database.backend to be 'sqlite' or 'postgres' (got %r). Falling back to the in-process login throttle counter: lockouts are per Gateway process and are not shared across replicas.",
database_backend,
)
return MemoryLoginThrottleStore()
if session_factory is None:
if selector_value == "db":
raise SystemExit(
f"auth.local.throttle_storage='db' requires the persistence engine for database.backend={database_backend!r} to be initialised before login "
"throttling starts, but no engine is available, so lockouts could not be shared across Gateway replicas. Initialise the database engine first "
"(the Gateway does this in langgraph_runtime), or set auth.local.throttle_storage to 'auto' (falls back to the in-process counter with a warning) or 'memory'."
)
logger.warning(
"auth.local.throttle_storage=%s resolved to the shared database table, but no persistence engine is initialised. Falling back to the in-process login throttle counter for this process.",
selector_value,
)
return MemoryLoginThrottleStore()
logger.info("auth.local.throttle_storage=%s resolved to the shared login_throttle table (database.backend=%s); login lockouts are enforced across every Gateway replica using this database.", selector_value, database_backend)
return SqlLoginThrottleStore(session_factory)
def installed_login_throttle_store() -> LoginThrottleStore | None:
"""The store installed for this process, or ``None`` before startup / after reset."""
return _store
def install_login_throttle_store(store: LoginThrottleStore) -> None:
"""Make ``store`` the process-wide login throttle store (startup and test hook)."""
global _store
_store = store
def reset_login_throttle_store() -> None:
"""Forget the installed store so the next throttle call resolves afresh."""
global _store
_store = None
def resolve_and_install_from_live_config() -> LoginThrottleStore:
"""Lazy fallback for apps without the Gateway lifespan: resolve from ``get_app_config()``.
Synchronous and blocking (it reads ``config.yaml``); the router calls it
through ``asyncio.to_thread``. Only ``FileNotFoundError`` falls back to the
``LocalAuthConfig`` defaults, matching ``_login_throttle_policy``: a
malformed config propagates rather than silently picking a store, and an
explicit ``db`` without an initialised engine fails closed exactly as it
does at Gateway startup (nothing is installed). Racing resolvers keep the
first installed store.
"""
from deerflow.config.app_config import get_app_config
from deerflow.persistence.engine import get_session_factory
with _install_lock:
if _store is not None:
return _store
try:
config: Any = get_app_config()
except FileNotFoundError:
config = None
store = resolve_login_throttle_store(config, session_factory=get_session_factory())
install_login_throttle_store(store)
return store

View File

@ -259,6 +259,39 @@ def _validate_agent_storage(config: AppConfig) -> None:
)
def _validate_login_throttle_storage(config: AppConfig) -> None:
"""Warn when a multi-process deployment counts login failures per process.
``auth.local.throttle_storage`` resolves to the shared ``login_throttle``
table whenever an application database exists, so under the multi-process
gate (which already requires Postgres) only an explicit ``memory`` lands
here. That is not fatal — the throttle still works on every replica — but
with N replicas behind one load balancer an attacker gets N x
``max_login_attempts`` guesses and a lockout on one replica is invisible
to the others, exactly the gap the shared table closes. Mirrors the
``agent_storage.backend='file'`` divergence warning above.
"""
signal = _multi_process_signal(config)
if signal is None:
return
local = getattr(getattr(config, "auth", None), "local", None)
if local is None:
return
from deerflow.config.auth_config import LocalAuthConfig, resolve_login_throttle_storage
selector = getattr(local, "throttle_storage", LocalAuthConfig.model_fields["throttle_storage"].default)
db_backend = getattr(getattr(config, "database", None), "backend", None)
if resolve_login_throttle_storage(selector, db_backend) == "memory":
logger.warning(
"%s with auth.local.throttle_storage=%s: failed-login counters and lockouts are kept per Gateway process, "
"so an attacker behind the load balancer gets N x max_login_attempts guesses and a lockout on one replica "
"is invisible to the others. Set auth.local.throttle_storage='auto' (or 'db') so the shared login_throttle "
"table in the application database enforces one limit per IP.",
signal[0],
str(getattr(selector, "value", selector)),
)
def _validate_memory_retrieval_index(config: AppConfig) -> None:
"""Warn when a declared multi-instance deployment keeps DeerMem's retrieval index on the shared memory volume.
@ -569,6 +602,8 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# Reject agent_storage.backend='db' on a non-durable database, and warn on
# node-divergent file storage under multi-worker Postgres.
_validate_agent_storage(startup_config)
# Warn when login lockouts stay per-process under several Gateway processes.
_validate_login_throttle_storage(startup_config)
# Warn when a declared multi-instance deployment shares DeerMem's SQLite
# retrieval index across instances through the memory volume.
_validate_memory_retrieval_index(startup_config)
@ -640,6 +675,15 @@ async def langgraph_runtime(app: FastAPI, startup_config: AppConfig) -> AsyncGen
# Initialize repositories — one get_session_factory() call for all.
sf = get_session_factory()
# The login throttle store is resolved once per process from the startup
# snapshot and the engine above (auth.local.throttle_storage is
# startup-only); the router reads it through the same hook tests use.
from app.gateway.auth.login_throttle import install_login_throttle_store, reset_login_throttle_store, resolve_login_throttle_store
install_login_throttle_store(resolve_login_throttle_store(config, session_factory=sf))
stack.callback(reset_login_throttle_store)
if sf is not None:
from deerflow.persistence.feedback import FeedbackRepository
from deerflow.persistence.personal_access_tokens import PersonalAccessTokenRepository

View File

@ -16,6 +16,7 @@ from starlette.responses import RedirectResponse
from app.gateway.auth import (
UserResponse,
create_access_token,
login_throttle,
)
from app.gateway.auth.config import get_auth_config
from app.gateway.auth.errors import AuthErrorCode, AuthErrorResponse
@ -38,6 +39,7 @@ from app.gateway.csrf_middleware import CSRF_COOKIE_NAME, _request_origin, auth_
from app.gateway.deps import get_current_user_from_request, get_local_provider
from app.gateway.utils import constant_time_equals
from deerflow.config.auth_config import OIDCProviderConfig
from deerflow.persistence.login_throttle import LoginThrottleStore
logger = logging.getLogger(__name__)
@ -160,27 +162,40 @@ def _set_session_cookie(response: Response, token: str, request: Request, *, rem
# ── Rate Limiting ────────────────────────────────────────────────────────
# In-process dict — not shared across workers.
#
# **Limitation**: with multi-worker deployments (e.g., gunicorn -w N), each
# worker maintains its own lockout table, so an attacker effectively gets
# N × max_login_attempts guesses before being locked out everywhere. For
# production multi-worker setups, replace this with a shared store (Redis,
# database-backed counter) to enforce a true per-IP limit.
# Failed logins are counted per client IP in a ``LoginThrottleStore``
# (``deerflow.persistence.login_throttle``). The store is resolved once per
# process by ``app.gateway.auth.login_throttle`` — the Gateway lifespan
# installs it from the startup config and the persistence engine, a bare app
# resolves it lazily on the first throttle call — and ``auth.local.
# throttle_storage`` picks the implementation: the shared ``login_throttle``
# table whenever an application database exists (every replica sharing that
# database enforces one lockout per IP), otherwise the historical in-process
# counter (per process: N replicas give an attacker N × max_login_attempts
# guesses; ``deps._validate_login_throttle_storage`` warns about that).
#
# The policy values are operator-configurable via auth.local.max_login_attempts /
# auth.local.lockout_seconds (read live per call, matching _local_registration_enabled,
# so a config reload applies to the next login without a Gateway restart). The
# no-config.yaml fallback is the LocalAuthConfig model defaults — a single source
# of truth, not a second copy of the numbers.
#
# A lock's stored duration always matches the policy the lock was last
# evaluated under (its creation counts as an evaluation, and every check that
# leaves the lock active commits the then-current duration, decreases
# included): a lowered lockout_seconds releases an active lock early, a raised
# one extends it — and a sentence that already served the last-evaluated
# duration is never resurrected. The stores implement that contract
# (``deerflow/persistence/login_throttle/base.py``); this module only sequences
# the calls and keeps the config read off the event loop.
# ip → (fail_count, locked_at, locked_duration). The stored duration always
# matches the policy the lock was last evaluated under (its creation counts
# as an evaluation, and every check that leaves the lock active commits the
# then-current duration, decreases included): a lowered lockout_seconds
# releases an active lock early, a raised one extends it — and a sentence
# that already served the last-evaluated duration is never resurrected.
_login_attempts: dict[str, tuple[int, float, float]] = {}
async def _login_throttle_store() -> LoginThrottleStore:
"""The process-wide store, resolving it off the loop on first use in a bare app."""
store = login_throttle.installed_login_throttle_store()
if store is None:
# Resolution reads config.yaml (stat + hash); never on the loop.
store = await asyncio.to_thread(login_throttle.resolve_and_install_from_live_config)
return store
def _login_throttle_policy() -> tuple[int, float]:
@ -271,99 +286,33 @@ def _get_client_ip(request: Request) -> str:
async def _check_rate_limit(ip: str) -> None:
"""Raise 429 if the IP is currently locked out.
The record lookup comes before policy resolution on purpose: a clean IP
(no failed attempts recorded — the overwhelming majority of logins) must
not pay a config read, and ``get_app_config`` re-hashes config.yaml on
every call while this endpoint is unauthenticated. When a record exists
the policy is resolved off the event loop via ``asyncio.to_thread``:
every request from a recorded IP — including an already-locked attacker
flooding the endpoint — pays that read on the way to its answer, and the
stat + hash must not block the loop.
The policy is handed to the store as an async callable and resolved
lazily, inside the store's single session: a clean IP (no failed attempts
recorded — the overwhelming majority of logins) never pays the config
read, and ``get_app_config`` re-hashes config.yaml on every call while
this endpoint is unauthenticated. A recorded IP — including an
already-locked attacker flooding the endpoint — resolves it exactly once,
off the event loop via ``asyncio.to_thread`` (the stat + hash must not
block the loop), and the SQL store serves the probe, the resolution and
the decision from one connection-pool checkout instead of two.
The policy read is a yield point: another request for the same IP may
delete or replace the record meanwhile, so the store decides on a fresh
read taken after it and guards its own mutations against that snapshot —
a record replaced mid-flight (e.g. a successful login followed by a new
failure) is never clobbered by a stale decision.
"""
record = _login_attempts.get(ip)
if record is None:
return
max_attempts, lockout_seconds = await asyncio.to_thread(_login_throttle_policy)
# The await above is a yield point: while this coroutine was suspended,
# another request for the same IP may have deleted or replaced the record
# (the pre-async version was atomic on the loop). The pre-read served only
# as the cheap clean-IP skip; decide on a fresh snapshot from here on —
# everything below is synchronous, and every mutation is guarded by
# re-comparing against that snapshot so a record replaced mid-flight
# (e.g. a successful login followed by a new failure) is never clobbered.
record = _login_attempts.get(ip)
if record is None:
return
fail_count, locked_at, locked_duration = record
if fail_count < max_attempts:
return
if locked_at == 0.0:
# Over the *current* threshold but the lock never started under the
# threshold these failures accumulated under (the operator tightened
# max_login_attempts mid-count). Keep the record: the next failure
# starts the lock and a successful login clears it — deleting here
# would hand the IP a fresh budget under a stricter policy.
return
now = time.time()
if now >= locked_at + locked_duration:
# The lock served the full sentence of the duration in force when it
# started — a later duration increase must not resurrect it.
if _login_attempts.get(ip) == record:
del _login_attempts[ip]
return
if now < locked_at + lockout_seconds:
# Still locked. The sentence now follows the current duration, and
# that evaluation is committed — including decreases — so the stored
# sentence always matches the policy the lock was last evaluated
# under; a later raise can never resurrect time the lock already
# served under a shorter policy.
if lockout_seconds != locked_duration and _login_attempts.get(ip) == record:
_login_attempts[ip] = (fail_count, locked_at, lockout_seconds)
store = await _login_throttle_store()
async def policy() -> tuple[int, float]:
return await asyncio.to_thread(_login_throttle_policy)
remaining = await store.check(ip, policy=policy, now=time.time())
if remaining > 0.0:
raise HTTPException(
status_code=429,
detail="Too many login attempts. Try again later.",
)
# Original sentence still running, but the current (lowered) duration has
# already elapsed — release early.
if _login_attempts.get(ip) == record:
del _login_attempts[ip]
_MAX_TRACKED_IPS = 10000
def _record_failure_under_policy(ip: str, max_attempts: int, lockout_seconds: float) -> None:
"""Apply one failed login to the counter under an explicit policy."""
# Evict expired lockouts when dict grows too large. Expiry is a property
# of each record's own committed sentence — `t > 0 and now >= t + d` —
# independent of the live threshold: a record locked under an old, lower
# threshold must still be swept once its sentence is served, even if the
# current max has moved past its count. Gating on the current threshold
# here would retain expired records while the capacity fallback below
# evicts live counters (they sort first), granting active offenders
# fresh budgets.
if len(_login_attempts) >= _MAX_TRACKED_IPS:
now = time.time()
expired = [k for k, (c, t, d) in _login_attempts.items() if t > 0.0 and now >= t + d]
for k in expired:
del _login_attempts[k]
# If still too large, evict cheapest-to-lose half ordered by each
# record's own expiry: never-locked counters (t + d == 0.0) first,
# then locked records whose committed sentence expires earliest.
if len(_login_attempts) >= _MAX_TRACKED_IPS:
by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1] + kv[1][2])
for k, _ in by_time[: len(by_time) // 2]:
del _login_attempts[k]
record = _login_attempts.get(ip)
if record is None:
_login_attempts[ip] = (1, 0.0, 0.0)
else:
new_count = record[0] + 1
if new_count >= max_attempts:
_login_attempts[ip] = (new_count, time.time(), lockout_seconds)
else:
_login_attempts[ip] = (new_count, 0.0, 0.0)
async def _record_login_failure(ip: str) -> None:
@ -373,6 +322,7 @@ async def _record_login_failure(ip: str) -> None:
this is the first config read for a previously clean IP, and the login
endpoint is unauthenticated.
"""
store = await _login_throttle_store()
try:
max_attempts, lockout_seconds = await asyncio.to_thread(_login_throttle_policy)
except Exception:
@ -386,14 +336,15 @@ async def _record_login_failure(ip: str) -> None:
from deerflow.config.auth_config import LocalAuthConfig
fallback = LocalAuthConfig()
_record_failure_under_policy(ip, fallback.max_login_attempts, fallback.lockout_seconds)
await store.record_failure(ip, max_attempts=fallback.max_login_attempts, lockout_seconds=fallback.lockout_seconds, now=time.time())
raise
_record_failure_under_policy(ip, max_attempts, lockout_seconds)
await store.record_failure(ip, max_attempts=max_attempts, lockout_seconds=lockout_seconds, now=time.time())
def _record_login_success(ip: str) -> None:
"""Clear failure counter for the given IP on successful login."""
_login_attempts.pop(ip, None)
async def _record_login_success(ip: str) -> None:
"""Clear the failure counter for the given IP on successful login."""
store = await _login_throttle_store()
await store.reset(ip)
# ── Endpoints ─────────────────────────────────────────────────────────────
@ -419,7 +370,7 @@ async def login_local(
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(),
)
_record_login_success(client_ip)
await _record_login_success(client_ip)
token = create_access_token(str(user.id), token_version=user.token_version)
_set_session_cookie(response, token, request, remember_me=remember_me)

View File

@ -110,7 +110,7 @@ enum UserScope:
- 成功后签发 JWT,放入 `access_token` HttpOnly cookie。
- 响应体只返回 `expires_in` 和 `needs_setup`,不返回 token。
登录失败会按客户端 IP 计数。IP 解析只在 TCP peer 属于 `AUTH_TRUSTED_PROXIES` 时信任 `X-Real-IP`,不使用 `X-Forwarded-For`。阈值与锁定时长可通过 `auth.local.max_login_attempts`(默认 5)和 `auth.local.lockout_seconds`(默认 300 秒)配置,按次实时读取,改配置后下一次登录即生效,无需重启 Gateway(`max_login_attempts` 最小为 2:单次失败不得锁定 IP。时长热改按方向生效:下调可提前释放进行中的锁定、收紧阈值会保留已计数的失败;上调只延长仍在锁定期内的锁定,不会复活已服满原时长的锁定)。
登录失败会按客户端 IP 计数。IP 解析只在 TCP peer 属于 `AUTH_TRUSTED_PROXIES` 时信任 `X-Real-IP`,不使用 `X-Forwarded-For`。阈值与锁定时长可通过 `auth.local.max_login_attempts`(默认 5)和 `auth.local.lockout_seconds`(默认 300 秒)配置,按次实时读取,改配置后下一次登录即生效,无需重启 Gateway(`max_login_attempts` 最小为 2:单次失败不得锁定 IP。时长热改按方向生效:下调可提前释放进行中的锁定、收紧阈值会保留已计数的失败;上调只延长仍在锁定期内的锁定,不会复活已服满原时长的锁定)。计数器的存放位置由 `auth.local.throttle_storage` 决定(启动时解析一次,改动需重启):默认 `auto` 在 `database.backend` 为 `sqlite` / `postgres` 时使用应用数据库中的 `login_throttle` 表,所有共享该数据库的 Gateway 副本对同一 IP 执行同一份限制;`memory` 退回进程内计数器(N 个副本意味着攻击者有 N × `max_login_attempts` 次机会,且一个副本上的锁定对其他副本不可见,多实例部署会在启动时打 WARNING);`db` 强制使用共享表,数据库为 `memory` 时降级为进程内计数器并打 WARNING,已配置的数据库引擎不可用时则拒绝启动(`auto` 在该情况下降级并打 WARNING)。锁定期内的再次失败不会重置 `locked_at`:锁定时长从锁定开始计算,而不是从最后一次尝试计算。
### 注册
@ -442,7 +442,7 @@ thread 归属读取 Gateway 所配置数据库(`config.yaml` 的 `database`)
| 边界 | 当前行为 | 后续方向 |
|---|---|---|
| 无 admin 时注册普通用户 | 允许注册普通 `user` | 如产品要求先初始化 admin,给 `/register` 加 gate |
| 登录限速 | 进程内 dict,单 worker 精确,多 worker 近似 | Redis / DB-backed rate limiter |
| 登录限速 | 默认存放在应用数据库的 `login_throttle` 表(`auth.local.throttle_storage: auto`),多副本共享同一份计数;`memory` 为进程内计数器,多 worker 近似 | Redis-backed rate limiter;按账号而非仅按 IP 限速 |
| OAuth / OIDC | 已实现通用 OIDC SSO(Keycloak, Google, Azure AD, Okta 等),支持 PKCE + nonce、auto-provisioning、email domain 限制(详见 [SSO.md](SSO.md)) | 支持 RP-initiated logout、自定义 scope 映射 |
| IM 用户隔离 | `channel_connections` 绑定到 `users.id`;未绑定消息在 `require_bound_identity: true` 时被拒绝 | 更多渠道与审计能力 |
| Internal Auth 终端直持 token | 平台可把共享密钥下发给终端,导致 `Owner-User-Id` 可伪造 | 仅平台后端持 token;终端走平台自己的认证 |

View File

@ -584,6 +584,8 @@ Notes:
- Set `multi_instance: true` (or export `DEER_FLOW_MULTI_INSTANCE=1`, which lets deploy tooling such as a Helm chart set it from its replica count) on every instance that shares one database. Startup then enforces the same prerequisites as `GATEWAY_WORKERS > 1`: `database.backend: postgres`, `run_events.backend: db`, `run_ownership.heartbeat_enabled: true`, and a Redis stream bridge (`stream_bridge.type: redis` or `DEER_FLOW_STREAM_BRIDGE_REDIS_URL`). It refuses an explicit `sandbox.ownership.type: memory`, process-local browser tools, and `scheduler.enabled: true` without `scheduler.multi_instance: true`.
- `DEER_FLOW_MULTI_INSTANCE` treats blank, `0`, `false`, `no` and `off` as "not declared"; any other value declares a multi-instance deployment, so a typo fails closed.
- `GET /health/ready` pings the Redis stream bridge on every probe and answers 503 (`stream_bridge: unreachable`) while Redis is down, so the orchestrator drains that instance instead of routing it runs it cannot publish or stream; the memory bridge reports `not_configured`. When `sandbox.provisioner_url` is set, the body also carries the provisioner's `/health` verdict (`provisioner: ok|unreachable`), which never changes the status code because every instance shares one provisioner. That probe follows the sandbox clients' proxy policy: loopback, private, link-local and cluster-local provisioner addresses bypass `HTTP_PROXY`, external hosts keep the environment's proxy settings.
- The declaration also drives the `agent_storage.backend: file` divergence warning, the inbound webhook dedupe warning, the `auth.local.throttle_storage: memory` warning, and the WeChat QR-login guard, which otherwise only look at the worker count.
- Login lockouts are shared through the database: `auth.local.throttle_storage` (startup-only) defaults to `auto`, which keeps the per-IP failed-login counters for `POST /api/v1/auth/login/local` in the `login_throttle` table whenever `database.backend` is `sqlite` or `postgres`, so every instance enforces one `max_login_attempts` limit per client IP and a lockout on one instance holds on all of them. `memory` keeps the historical per-process counter (N instances give an attacker N × `max_login_attempts` guesses; a declared multi-instance deployment logs a warning), and `db` forces the table (it falls back to memory with a warning when the database backend is `memory`, and refuses to start when the configured database's engine is unavailable; `auto` falls back with a warning in that case). `max_login_attempts` and `lockout_seconds` stay live-read.
- The declaration also drives the `agent_storage.backend: file` divergence warning, the inbound webhook dedupe warning, and the WeChat QR-login guard, which otherwise only look at the worker count.
- With the default DeerMem backend, the declaration also warns when the derived SQLite retrieval index sits inside the shared `storage_path` (the default `{storage_path}/.retrieval`). Set `memory.backend_config.retrieval_index_path` to an instance-local directory (relative values resolve against `storage_path`): SQLite WAL is unsupported on network filesystems, and the index is rebuilt from the Markdown facts at startup and re-synced per user scope after a peer writes.
- Restart-required: the gate runs once at startup. Restart all Gateway instances together after changing it.

View File

@ -63,7 +63,7 @@ YAML loading and both middleware tool-call paths in `test_tool_output_config_lim
**Config Hot-Reload Boundary**: Gateway dependencies route through `get_app_config()` on every request, so per-run fields like `models[*].max_tokens`, `summarization.*`, `title.*`, `memory.*`, `subagents.*`, `verification.*`, `tools[*]`, and the agent system prompt pick up `config.yaml` edits on the next message. `AppConfig` is intentionally **not** cached on `app.state` — `lifespan()` keeps a local `startup_config` variable for one-shot bootstrap work and passes it to `langgraph_runtime(app, startup_config)`.
Infrastructure fields are **restart-required**. The authoritative list lives in `packages/harness/deerflow/config/reload_boundary.py::STARTUP_ONLY_FIELDS` and is mirrored by the standardised `"startup-only:"` prefix on the corresponding `Field(description=...)` in `AppConfig` or an explicitly registered nested config model, so IDE hover on those fields surfaces the reason inline (no need to context-switch into this table). Currently registered: `plugins`, `database`, `checkpointer`, `run_events`, `agent_storage`, `stream_bridge`, `sandbox`, `skills.container_path`, `log_level`, `logging`, `channels`, `channel_connections`, `scheduler`, `mcp_tasks`, `subagent_runtime`, `subagent_batches`, `run_ownership`, `dedupe_storage`. Adding a new restart-required field requires updating the registry; drift is pinned by `tests/test_reload_boundary.py`. `scheduler.recursion_limit` is the exception inside that section: it is read from `get_app_config()` at each scheduled dispatch, so a YAML edit applies to the next run without restarting the poller.
Infrastructure fields are **restart-required**. The authoritative list lives in `packages/harness/deerflow/config/reload_boundary.py::STARTUP_ONLY_FIELDS` and is mirrored by the standardised `"startup-only:"` prefix on the corresponding `Field(description=...)` in `AppConfig` or an explicitly registered nested config model, so IDE hover on those fields surfaces the reason inline (no need to context-switch into this table). Currently registered: `plugins`, `database`, `checkpointer`, `run_events`, `agent_storage`, `stream_bridge`, `sandbox`, `skills.container_path`, `log_level`, `logging`, `channels`, `channel_connections`, `scheduler`, `mcp_tasks`, `subagent_runtime`, `subagent_batches`, `run_ownership`, `dedupe_storage`, `auth.local.throttle_storage` (the login throttle store is installed once by `langgraph_runtime`; `max_login_attempts` / `lockout_seconds` stay live-read). Adding a new restart-required field requires updating the registry; drift is pinned by `tests/test_reload_boundary.py`. `scheduler.recursion_limit` is the exception inside that section: it is read from `get_app_config()` at each scheduled dispatch, so a YAML edit applies to the next run without restarting the poller.
**Persistence backend resolution**: the unified `database` section selects the
Gateway's LangGraph checkpointer, LangGraph Store, and DeerFlow SQL repositories.

View File

@ -2,10 +2,13 @@
from __future__ import annotations
from enum import StrEnum
from typing import Literal
from pydantic import BaseModel, Field
from deerflow.config.reload_boundary import format_field_description
class OIDCProviderConfig(BaseModel):
"""Configuration for a single OIDC identity provider (Keycloak, Google, Azure AD, etc.)."""
@ -67,6 +70,31 @@ class OIDCAuthConfig(BaseModel):
)
class LoginThrottleStorage(StrEnum):
"""Where the per-IP failed-login counter behind ``POST /api/v1/auth/login/local`` lives."""
AUTO = "auto"
MEMORY = "memory"
DB = "db"
def resolve_login_throttle_storage(selector: LoginThrottleStorage | str | None, database_backend: str | None) -> Literal["memory", "db"]:
"""Resolve ``auth.local.throttle_storage`` against ``database.backend``.
``auto`` (the default) shares the counter through the application database
whenever one exists (``sqlite`` or ``postgres``), so every Gateway replica
using that database enforces one lockout per IP; a ``memory`` database has
no shared table and falls back to the per-process counter. ``memory``
always keeps the per-process counter. ``db`` asks for the shared table and
degrades to ``memory`` when there is no database to hold it — the caller
warns about that misconfiguration.
"""
value = str(selector.value if isinstance(selector, StrEnum) else selector or LoginThrottleStorage.AUTO)
if value == LoginThrottleStorage.MEMORY:
return "memory"
return "db" if database_backend in ("sqlite", "postgres") else "memory"
class LocalAuthConfig(BaseModel):
"""Configuration for the built-in email/password authentication provider."""
@ -88,9 +116,10 @@ class LocalAuthConfig(BaseModel):
"Raise it when many users share an egress IP (corporate proxy / NAT); lower it for "
"a stricter posture. Minimum 2: one failed attempt must never lock an IP, or a "
"single typo would block everyone behind a shared egress — the strictest legal "
"value locks after the second failure. The counter is per-Gateway-worker "
"(in-process), so effective attempts in multi-worker deployments scale with "
"worker count."
"value locks after the second failure. Where the counter lives is decided by "
"throttle_storage: shared through the application database (the default whenever "
"one exists) every Gateway replica enforces one limit per IP; the in-process "
"counter is per worker, so effective attempts scale with the worker count."
),
)
lockout_seconds: float = Field(
@ -99,6 +128,21 @@ class LocalAuthConfig(BaseModel):
allow_inf_nan=False,
description=("Seconds an IP stays locked out after reaching auth.local.max_login_attempts. Defaults preserve the historical hardcoded policy (5 minutes)."),
)
throttle_storage: LoginThrottleStorage = Field(
default=LoginThrottleStorage.AUTO,
description=format_field_description(
"auth.local.throttle_storage",
field_doc=(
"Where failed-login counters and lockouts for POST /api/v1/auth/login/local are kept. "
"'auto' uses the application database whenever database.backend is sqlite or postgres, so every "
"Gateway replica sharing that database enforces one lockout per client IP; with database.backend=memory "
"it falls back to an in-process counter. 'memory' forces the in-process counter (per process: N replicas "
"give an attacker N x max_login_attempts guesses and a lockout on one replica is invisible to the others). "
"'db' forces the shared table: it falls back to memory with a warning when the database backend is memory, and refuses to start when the configured "
"database's engine is unavailable."
),
),
)
class AuthAppConfig(BaseModel):

View File

@ -95,6 +95,11 @@ STARTUP_ONLY_FIELDS: dict[str, str] = {
"make_inbound_dedupe_store() resolves the inbound dedupe store once when ChannelService is constructed at startup; the store "
"(in-process memory or shared Postgres) is captured onto ChannelManager and is not rebuilt on config.yaml edits."
),
"auth.local.throttle_storage": (
"langgraph_runtime() resolves the login throttle store once at startup from this selector and the persistence engine "
"(app.gateway.auth.login_throttle); the store (in-process counter or the shared login_throttle table) is not rebuilt on "
"config.yaml edits, so switching it needs a Gateway restart. auth.local.max_login_attempts and lockout_seconds stay live-read."
),
}

View File

@ -0,0 +1,21 @@
"""Per-IP failed-login counter behind local login (``auth.local.throttle_storage``)."""
from deerflow.persistence.login_throttle.base import CheckDecision, LoginThrottlePolicy, LoginThrottleRecord, LoginThrottleStore, evaluate_check
from deerflow.persistence.login_throttle.memory import MAX_TRACKED_IPS, MemoryLoginThrottleStore
from deerflow.persistence.login_throttle.model import LOGIN_THROTTLE_IP_LENGTH, LoginThrottleRow
from deerflow.persistence.login_throttle.sql import STALE_COUNTER_SECONDS, SWEEP_BATCH_SIZE, SqlLoginThrottleStore
__all__ = [
"LOGIN_THROTTLE_IP_LENGTH",
"MAX_TRACKED_IPS",
"STALE_COUNTER_SECONDS",
"SWEEP_BATCH_SIZE",
"CheckDecision",
"LoginThrottlePolicy",
"LoginThrottleRecord",
"LoginThrottleRow",
"LoginThrottleStore",
"MemoryLoginThrottleStore",
"SqlLoginThrottleStore",
"evaluate_check",
]

View File

@ -0,0 +1,148 @@
"""Contract for the per-IP failed-login counter behind ``POST /api/v1/auth/login/local``.
A store keeps one record per client IP: how many consecutive logins failed,
and — once that count reached ``max_attempts`` — when the lock started and
the sentence committed for it. The policy values (``max_attempts``,
``lockout_seconds``) are *not* stored as configuration: the caller passes the
live values on every call so a ``config.yaml`` edit applies to the next login
without a restart, and the semantics below decide how an in-flight record
follows a changed policy.
Semantics every implementation must share (pinned by
``tests/test_login_throttle_store.py`` against both stores):
- ``record_failure`` increments the count. A new count below ``max_attempts``
leaves the IP counting with no lock (so a failure after the operator
raised the threshold clears an existing lock). A new count at or over the
threshold locks: a failure recorded while a lock is *active* keeps the
lock's start and committed duration — the sentence is "N seconds after the
lock started", never "after the last attempt" — while a served or absent
lock starts anew *now* with ``lockout_seconds`` as its committed duration
(a record already over the threshold but never locked — the operator
tightened the policy mid-count — locks on that next failure).
- ``check`` allows a record below the current threshold (raising
``max_attempts`` releases a lower count immediately) and one that is over
it but never locked (the count is kept, not reset). An active lock first
serves the duration committed for it: once that elapsed the record is
cleared, so a later raise of ``lockout_seconds`` never resurrects a served
sentence. While the committed sentence still runs, the lock follows the
*current* duration — a lowered value releases early, a raised value
extends — and that evaluation is committed to the record (decreases
included), so the stored sentence always matches the policy the lock was
last evaluated under.
- ``reset`` forgets the IP (successful login).
- ``check`` takes the policy as an async callable and resolves it lazily:
a clean IP (no record — the overwhelming majority of logins) returns
``0.0`` without ever calling it, so the router's ``config.yaml`` read is
skipped there; a recorded IP resolves it exactly once, and the decision is
then made on a snapshot read *after* that resolution — the policy read is a
yield point, and a decision that needs no write has no compare-and-set to
catch a record a peer changed meanwhile. The SQL store does the probe,
the resolution and the decision in one session.
- ``get`` is a non-mutating probe (tests and anchors use it); the router no
longer needs it because ``check`` skips the policy for clean IPs itself.
``now`` is an epoch timestamp supplied by the caller (defaulting to
``time.time()``) so replicas compare the same clock the lock was stamped with
and tests can freeze it.
"""
from __future__ import annotations
from collections.abc import Awaitable, Callable
from dataclasses import dataclass
from typing import Protocol
#: Resolves ``(max_login_attempts, lockout_seconds)``; awaited by ``check`` at most once, never for a clean IP.
LoginThrottlePolicy = Callable[[], Awaitable[tuple[int, float]]]
@dataclass(frozen=True, slots=True)
class LoginThrottleRecord:
"""One IP's throttle state.
``locked_at == 0.0`` means "counting, never locked"; a lock stores the
epoch timestamp it started at and the duration committed for it.
"""
fail_count: int
locked_at: float = 0.0
lock_duration: float = 0.0
@property
def locked(self) -> bool:
return self.locked_at > 0.0
@property
def expires_at(self) -> float:
"""When the committed sentence ends (0.0 for a never-locked counter)."""
return self.locked_at + self.lock_duration if self.locked else 0.0
@dataclass(frozen=True, slots=True)
class CheckDecision:
"""What ``check`` concluded from one snapshot, and the write that must follow it.
``remaining`` is the answer (seconds still locked, ``0.0`` = allowed);
``discard`` asks the store to clear the snapshot's record (served or
released early); ``commit_duration`` asks it to store the live duration
on the still-active lock. At most one of the two writes is set. Stores
apply the write with a compare-and-set against the snapshot it was
decided on, so a record a peer changed meanwhile is never clobbered.
"""
remaining: float
discard: bool = False
commit_duration: float | None = None
def evaluate_check(record: LoginThrottleRecord, *, max_attempts: int, lockout_seconds: float, now: float) -> CheckDecision:
"""The ``check`` contract on one snapshot, shared by every store (see the module docstring)."""
if record.fail_count < max_attempts:
return CheckDecision(0.0)
if not record.locked:
# Over the current threshold but the lock never started under the
# threshold these failures accumulated under (the operator tightened
# max_login_attempts mid-count). Keep the record: the next failure
# starts the lock and a successful login clears it — deleting here
# would hand the IP a fresh budget under a stricter policy.
return CheckDecision(0.0)
if now >= record.expires_at:
# The lock served the full sentence of the duration in force when it
# started — a later duration increase must not resurrect it.
return CheckDecision(0.0, discard=True)
if now < record.locked_at + lockout_seconds:
# Still locked. The sentence now follows the current duration, and that
# evaluation is committed — including decreases — so the stored sentence
# always matches the policy the lock was last evaluated under; a later
# raise can never resurrect time the lock already served under a shorter
# policy.
commit = lockout_seconds if lockout_seconds != record.lock_duration else None
return CheckDecision(record.locked_at + lockout_seconds - now, commit_duration=commit)
# Original sentence still running, but the current (lowered) duration has
# already elapsed — release early.
return CheckDecision(0.0, discard=True)
class LoginThrottleStore(Protocol):
"""Async per-IP failed-login counter shared by every login replica using it."""
async def get(self, ip: str) -> LoginThrottleRecord | None:
"""Return the record for ``ip`` without mutating anything, or ``None`` for a clean IP."""
...
async def check(self, ip: str, *, policy: LoginThrottlePolicy, now: float | None = None) -> float:
"""Return the seconds the IP stays locked under the resolved policy, ``0.0`` when it may log in.
``policy`` is awaited at most once and never for a clean IP. Clears
served / released locks as a side effect (see the module docstring).
"""
...
async def record_failure(self, ip: str, *, max_attempts: int, lockout_seconds: float, now: float | None = None) -> LoginThrottleRecord:
"""Count one failed login under the given policy and return the new record."""
...
async def reset(self, ip: str) -> None:
"""Forget the IP after a successful login."""
...

View File

@ -0,0 +1,116 @@
"""In-process failed-login counter — one per Gateway process, not shared.
This is the historical ``app.gateway.routers.auth._login_attempts`` dict moved
behind :class:`~deerflow.persistence.login_throttle.base.LoginThrottleStore`.
With N Gateway replicas behind one load balancer each replica keeps its own
counter, so an attacker effectively gets N × ``max_login_attempts`` guesses
and a lockout on one replica is invisible to the others; use the SQL store
whenever an application database exists (``auth.local.throttle_storage``).
"""
from __future__ import annotations
import time
from dataclasses import replace
from deerflow.persistence.login_throttle.base import LoginThrottlePolicy, LoginThrottleRecord, evaluate_check
#: Upper bound on tracked IPs before the capacity sweep runs (historical constant).
MAX_TRACKED_IPS = 10000
class MemoryLoginThrottleStore:
"""Process-local counter with a bounded tracked-IP set."""
def __init__(self, *, max_tracked_ips: int = MAX_TRACKED_IPS) -> None:
self._records: dict[str, LoginThrottleRecord] = {}
self._max_tracked_ips = max_tracked_ips
async def get(self, ip: str) -> LoginThrottleRecord | None:
return self._records.get(ip)
async def check(self, ip: str, *, policy: LoginThrottlePolicy, now: float | None = None) -> float:
"""Apply the shared ``check`` contract to this process's record.
The policy is resolved lazily: a clean IP returns before ``policy`` is
awaited. That await is a yield point, so the record is read again
afterwards and the decision is made on that fresh snapshot — a record
a concurrent request cleared or replaced meanwhile is never judged
from the stale probe. The compare-and-set that follows cannot miss:
there is no ``await`` between the fresh read and the write and the
event loop thread holds the GIL, so no peer can change the record in
between — unlike the SQL store, whose writes race other replicas and
therefore check their affected-row count and re-evaluate. The guards
are kept only as a statement of the same discipline.
"""
if ip not in self._records:
return 0.0
max_attempts, lockout_seconds = await policy()
record = self._records.get(ip) # fresh: the policy read yielded the loop
if record is None:
return 0.0
now = time.time() if now is None else now
decision = evaluate_check(record, max_attempts=max_attempts, lockout_seconds=lockout_seconds, now=now)
if decision.discard:
self._discard(ip, record)
elif decision.commit_duration is not None and self._records.get(ip) == record:
self._records[ip] = replace(record, lock_duration=decision.commit_duration)
return decision.remaining
async def record_failure(self, ip: str, *, max_attempts: int, lockout_seconds: float, now: float | None = None) -> LoginThrottleRecord:
now = time.time() if now is None else now
self._sweep_if_full(now, keep=ip)
record = self._records.get(ip)
if record is None:
new_record = LoginThrottleRecord(fail_count=1)
else:
new_count = record.fail_count + 1
if new_count < max_attempts:
# Below the (possibly raised) threshold: counting, any lock is cleared.
new_record = LoginThrottleRecord(fail_count=new_count)
elif record.locked and now < record.expires_at:
# A failure during an active lock keeps the lock's start and its
# committed duration: the sentence is "N seconds after the lock
# started", not "after the last attempt" (#6501 review).
new_record = LoginThrottleRecord(fail_count=new_count, locked_at=record.locked_at, lock_duration=record.lock_duration)
else:
# Never locked, or the lock already served its sentence: start anew.
new_record = LoginThrottleRecord(fail_count=new_count, locked_at=now, lock_duration=lockout_seconds)
self._records[ip] = new_record
return new_record
async def reset(self, ip: str) -> None:
self._records.pop(ip, None)
def _discard(self, ip: str, snapshot: LoginThrottleRecord) -> None:
# Compare-and-delete: only remove the record the decision was made on.
if self._records.get(ip) == snapshot:
del self._records[ip]
def _sweep_if_full(self, now: float, *, keep: str) -> None:
"""Evict expired lockouts when the dict grows too large.
``keep`` is the IP whose failure is being recorded: its record is live
and ``record_failure`` decides its fate from its own values (a served
lock starts a fresh sentence), the same contract the SQL store keeps
by excluding the upserted row from its sweep.
Expiry is a property of each record's own committed sentence — ``locked
and now >= expires_at`` — independent of the live threshold: a record
locked under an old, lower threshold must still be swept once its
sentence is served, even if the current max has moved past its count.
Gating on the current threshold would retain expired records while the
capacity fallback below evicts live counters (they sort first),
granting active offenders fresh budgets.
"""
if len(self._records) < self._max_tracked_ips:
return
for key in [k for k, record in self._records.items() if k != keep and record.locked and now >= record.expires_at]:
del self._records[key]
# If still too large, evict the cheapest-to-lose half ordered by each
# record's own expiry: never-locked counters (expires_at == 0.0) first,
# then locked records whose committed sentence expires earliest.
if len(self._records) >= self._max_tracked_ips:
by_expiry = sorted(((k, record) for k, record in self._records.items() if k != keep), key=lambda kv: kv[1].expires_at)
for key, _ in by_expiry[: len(by_expiry) // 2]:
del self._records[key]

View File

@ -0,0 +1,39 @@
"""ORM model for the shared failed-login counter (``login_throttle``).
One row per client IP that failed at least one local login. Replicas sharing
the application database read and write the same row, so a lockout is
enforced wherever the load balancer sends the next attempt. Rows are swept
by :class:`~deerflow.persistence.login_throttle.sql.SqlLoginThrottleStore`
once a lock has served its sentence or a never-locked counter went stale.
"""
from __future__ import annotations
from datetime import UTC, datetime
from sqlalchemy import DateTime, Double, Index, Integer, PrimaryKeyConstraint, String
from sqlalchemy.orm import Mapped, mapped_column
from deerflow.persistence.base import Base
#: Longest row key. IPv6 fits in 45 characters; a trusted proxy may forward a
#: longer ``X-Real-IP`` and the store truncates it to this length.
LOGIN_THROTTLE_IP_LENGTH = 255
class LoginThrottleRow(Base):
__tablename__ = "login_throttle"
ip: Mapped[str] = mapped_column(String(LOGIN_THROTTLE_IP_LENGTH), nullable=False)
fail_count: Mapped[int] = mapped_column(Integer, nullable=False)
# Epoch seconds the lock started; NULL while the IP is only counting.
locked_at: Mapped[float | None] = mapped_column(Double, nullable=True)
# Sentence committed for the lock (follows the live policy while active).
lock_duration_seconds: Mapped[float | None] = mapped_column(Double, nullable=True)
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(UTC))
__table_args__ = (
PrimaryKeyConstraint("ip", name="pk_login_throttle"),
# Serves the sweep's stale-counter predicate (``locked_at IS NULL AND updated_at <= cutoff``).
Index("ix_login_throttle_updated_at", "updated_at"),
)

View File

@ -0,0 +1,286 @@
"""SQL-backed failed-login counter shared across Gateway replicas.
One ``login_throttle`` row per client IP in the application database (SQLite
or PostgreSQL). Every replica that shares the database sees the same row, so
``max_login_attempts`` is enforced once per IP instead of once per process.
Concurrency contract:
- ``record_failure`` is one dialect-native upsert (``INSERT ... ON CONFLICT
(ip) DO UPDATE``) that increments the counter and starts the lock in the
same statement, so racing failures never lose an increment and the
"count reached ``max_attempts``" decision is made on the row's own value,
not on a value read earlier.
- ``check`` works in one session (one pool checkout): it probes the row and
returns for a clean IP before resolving the policy, resolves the policy
exactly once otherwise, then re-reads the row — the policy read is a yield
point and a decision that needs no write has no compare-and-set to catch a
row a peer changed meanwhile — and applies its decision (clear a served
lock, commit a changed sentence) with a compare-and-set predicate on the
snapshot it decided on, in a *separate* write transaction.
Keeping the read transaction out of the write avoids SQLite's read→write
upgrade (``SQLITE_BUSY`` / ``BUSY_SNAPSHOT`` under contention) while the
predicate guarantees a racing success or failure is never clobbered — the
same compare-and-set discipline the memory store uses on its dict. Every
such write checks its affected-row count: a miss means a peer changed the
row between the read and the write (a failure incremented the count, a
success cleared it, a fresh lock started), and the decision is *not* taken
as applied — the row is re-read and re-evaluated, up to
:data:`CHECK_CAS_ATTEMPTS` times, so an extension or decrease of the
sentence that ``check`` reports is also what the row stores, a cleared row
is reported as allowed, and a fresh peer lock is reported as locked (#6501
review). Exhausting the bound under sustained contention fails closed:
nothing is written and the answer is the last snapshot's lock under the
live policy.
- Cleanup is amortized into ``record_failure``: a bounded ``DELETE`` removes
locks whose sentence has elapsed and never-locked counters idle for
:data:`STALE_COUNTER_SECONDS` (the shared-table equivalent of the memory
store's capacity eviction) so the table cannot grow without bound. It runs
in the upsert's own transaction — one commit (one WAL flush on PostgreSQL)
per failed login, not two; the ``keep`` exclusion protects the row being
recorded — and ``ix_login_throttle_updated_at`` serves its stale-counter
predicate, while the served-lock predicate (``locked_at +
lock_duration_seconds <= now``) scans a table the sweep itself keeps
small. The candidates come from an ``IN (SELECT ... LIMIT n)`` subquery bound to the
statement snapshot, and the same expiry predicate is repeated on the
``DELETE`` target: PostgreSQL READ COMMITTED re-evaluates only the
statement's *own* WHERE on a row it had to wait for, so without the outer
predicate a lock that a concurrent ``check`` extended (or a stale counter a
concurrent failure re-armed) between the snapshot and the row lock would
still be deleted. Every other write (``_discard``, the ``check`` UPDATE)
already carries its full compare-and-set predicate on the target row for
the same reason; ``reset`` is unconditional by design.
A failure recorded while a lock is active keeps the lock's ``locked_at`` and
committed duration (the sentence is "N seconds after the lock started", not
"after the last attempt"); only a never-locked or already-served row gets a
fresh stamp. The upsert decides that from the row's own values in one
statement, so it stays atomic under racing failures.
``locked_at`` is the wall clock (``time.time()``) of whichever replica
recorded the failure, and every replica evaluates expiry against its own
clock. Replicas sharing a database must therefore run NTP: a skew of a few
seconds shifts lock evaluation by that much, which is acceptable for
lockouts measured in minutes but is the inherent cost of a wall-clock
lockout shared across hosts.
Housekeeping constants: the shared table, unlike the memory store's bounded
dict, has no implicit size limit, so every ``record_failure`` sweeps.
:data:`STALE_COUNTER_SECONDS` is 24 hours — the same guard the upload-staging
and project-document sweeps use for leftovers that may still be in use on
another replica — long enough that a legitimate user's typos a day apart are
not chained together, short enough to keep scanner one-offs from piling up.
:data:`SWEEP_BATCH_SIZE` (200) bounds the rows one failed login may delete,
so the write transaction stays short even if a backlog exists.
Database errors propagate: the users table lives in the same database, so a
login cannot succeed without it anyway, and failing closed keeps the throttle
from silently handing out unlimited verification.
"""
from __future__ import annotations
import logging
import time
from datetime import UTC, datetime, timedelta
from sqlalchemy import and_, case, delete, or_, select, update
from sqlalchemy.dialects.postgresql import insert as pg_insert
from sqlalchemy.dialects.sqlite import insert as sqlite_insert
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker
from deerflow.persistence.login_throttle.base import LoginThrottlePolicy, LoginThrottleRecord, evaluate_check
from deerflow.persistence.login_throttle.model import LOGIN_THROTTLE_IP_LENGTH, LoginThrottleRow
logger = logging.getLogger(__name__)
#: Never-locked counters untouched for this long are dropped by the sweep.
STALE_COUNTER_SECONDS = 24 * 60 * 60
#: Rows one ``record_failure`` call may delete; bounds the write transaction.
SWEEP_BATCH_SIZE = 200
#: Snapshot reads one ``check`` may make before it stops retrying a missed compare-and-set.
CHECK_CAS_ATTEMPTS = 3
def _insert_for(session: AsyncSession):
dialect = session.get_bind().dialect.name
if dialect == "postgresql":
return pg_insert
if dialect == "sqlite":
return sqlite_insert
raise ValueError(f"Unsupported login throttle database dialect: {dialect}")
def _record(fail_count: int, locked_at: float | None, lock_duration: float | None) -> LoginThrottleRecord:
return LoginThrottleRecord(fail_count=int(fail_count), locked_at=float(locked_at or 0.0), lock_duration=float(lock_duration or 0.0))
def _key(ip: str) -> str:
"""Bound the row key to the column length; a trusted proxy may forward a token-like ``X-Real-IP``.
The key itself is never logged: it may be an identifier the proxy did not
mean to expose, and it is unbounded. Truncation keeps the throttle
fail-closed (colliding prefixes share a stricter budget).
"""
if len(ip) <= LOGIN_THROTTLE_IP_LENGTH:
return ip
logger.warning("Login throttle client key of %d characters truncated to %d; check the trusted proxy's X-Real-IP value.", len(ip), LOGIN_THROTTLE_IP_LENGTH)
return ip[:LOGIN_THROTTLE_IP_LENGTH]
def _timestamp(now: float) -> datetime:
return datetime.fromtimestamp(now, UTC)
class SqlLoginThrottleStore:
"""Persistence facade for ``login_throttle``."""
def __init__(self, session_factory: async_sessionmaker[AsyncSession]) -> None:
self._sf = session_factory
async def get(self, ip: str) -> LoginThrottleRecord | None:
async with self._sf() as session:
row = (await session.execute(self._select(_key(ip)))).first()
return None if row is None else _record(*row)
async def check(self, ip: str, *, policy: LoginThrottlePolicy, now: float | None = None) -> float:
key = _key(ip)
now = time.time() if now is None else now
async with self._sf() as session:
# Probe first: a clean IP never pays the policy read (the router's
# config.yaml stat + hash) nor a second query.
record = await self._read_snapshot(session, key)
if record is None:
return 0.0
max_attempts, lockout_seconds = await policy()
# The policy read yielded the loop; decide on a snapshot taken after
# it (a decision that needs no write has no compare-and-set to catch
# a row a peer changed meanwhile). Same session, same pool checkout.
for _attempt in range(CHECK_CAS_ATTEMPTS):
record = await self._read_snapshot(session, key)
if record is None:
return 0.0
decision = evaluate_check(record, max_attempts=max_attempts, lockout_seconds=lockout_seconds, now=now)
if decision.discard:
if await self._discard(session, key, record):
return 0.0
elif decision.commit_duration is not None:
if await self._commit_duration(session, key, record, decision.commit_duration, now):
return decision.remaining
else:
return decision.remaining
# The compare-and-set missed: a peer changed the row between the
# snapshot and the write. Never report the stale decision as
# applied — re-read and decide again on the fresh row (gone ->
# allowed; a fresh lock -> locked; an incremented count -> the
# extension or decrease is committed on it).
# Sustained contention exhausted the bound. Nothing was written; fail
# closed on the last snapshot under the live policy.
remaining = max(0.0, record.locked_at + lockout_seconds - now) if record.locked else 0.0
logger.debug("Login throttle check gave up after %d compare-and-set misses on a contended row; reporting %.1fs remaining from the last snapshot without writing.", CHECK_CAS_ATTEMPTS, remaining)
return remaining
async def record_failure(self, ip: str, *, max_attempts: int, lockout_seconds: float, now: float | None = None) -> LoginThrottleRecord:
key = _key(ip)
now = time.time() if now is None else now
stamp = _timestamp(now)
async with self._sf() as session:
# Housekeeping rides in the upsert's transaction (one commit per
# failed login); ``keep`` leaves this IP's row to the upsert below.
await session.execute(self.sweep_statement(now, keep=key))
insert = _insert_for(session)
# One atomic decision on the row's own values (see the module
# docstring): below the threshold -> counting, lock cleared; at or
# over it during an *active* lock -> keep the lock's start and
# committed duration; otherwise (never locked / served) -> stamp now.
reaches = LoginThrottleRow.fail_count + 1 >= max_attempts
active = and_(LoginThrottleRow.locked_at.is_not(None), LoginThrottleRow.locked_at + LoginThrottleRow.lock_duration_seconds > now)
stmt = insert(LoginThrottleRow).values(ip=key, fail_count=1, locked_at=None, lock_duration_seconds=None, updated_at=stamp)
stmt = stmt.on_conflict_do_update(
index_elements=[LoginThrottleRow.ip],
set_={
"fail_count": LoginThrottleRow.fail_count + 1,
"locked_at": case((and_(reaches, active), LoginThrottleRow.locked_at), (reaches, now), else_=None),
"lock_duration_seconds": case((and_(reaches, active), LoginThrottleRow.lock_duration_seconds), (reaches, lockout_seconds), else_=None),
"updated_at": stamp,
},
).returning(LoginThrottleRow.fail_count, LoginThrottleRow.locked_at, LoginThrottleRow.lock_duration_seconds)
row = (await session.execute(stmt)).one()
await session.commit()
return _record(*row)
async def reset(self, ip: str) -> None:
async with self._sf() as session:
await session.execute(delete(LoginThrottleRow).where(LoginThrottleRow.ip == _key(ip)))
await session.commit()
@staticmethod
def _select(key: str):
return select(LoginThrottleRow.fail_count, LoginThrottleRow.locked_at, LoginThrottleRow.lock_duration_seconds).where(LoginThrottleRow.ip == key)
async def _read_snapshot(self, session: AsyncSession, key: str) -> LoginThrottleRecord | None:
"""Read the row and end the read transaction before any write (see the module docstring)."""
row = (await session.execute(self._select(key))).first()
await session.rollback()
return None if row is None else _record(*row)
@staticmethod
def _matches(key: str, snapshot: LoginThrottleRecord):
"""Compare-and-set predicate: the row still is the one the decision was made on.
Carried by the target row of every ``check`` write, so a database
that re-evaluates the WHERE after waiting on a concurrent writer
(PostgreSQL READ COMMITTED) sees the changed tuple and skips the
stale decision instead of applying it.
"""
return and_(
LoginThrottleRow.ip == key,
LoginThrottleRow.fail_count == snapshot.fail_count,
LoginThrottleRow.locked_at == snapshot.locked_at,
LoginThrottleRow.lock_duration_seconds == snapshot.lock_duration,
)
async def _discard(self, session: AsyncSession, key: str, snapshot: LoginThrottleRecord) -> bool:
"""Compare-and-delete the snapshot's row; ``False`` when a peer changed it first."""
result = await session.execute(delete(LoginThrottleRow).where(self._matches(key, snapshot)))
await session.commit()
return result.rowcount == 1
async def _commit_duration(self, session: AsyncSession, key: str, snapshot: LoginThrottleRecord, lock_duration: float, now: float) -> bool:
"""Compare-and-set the live duration on the snapshot's row; ``False`` when a peer changed it first."""
result = await session.execute(update(LoginThrottleRow).where(self._matches(key, snapshot)).values(lock_duration_seconds=lock_duration, updated_at=_timestamp(now)))
await session.commit()
return result.rowcount == 1
@staticmethod
def _expired(now: float):
"""Rows the sweep may drop: served locks and never-locked counters idle past the stale window."""
served = and_(LoginThrottleRow.locked_at.is_not(None), LoginThrottleRow.locked_at + LoginThrottleRow.lock_duration_seconds <= now)
stale = and_(LoginThrottleRow.locked_at.is_(None), LoginThrottleRow.updated_at <= _timestamp(now) - timedelta(seconds=STALE_COUNTER_SECONDS))
return or_(served, stale)
@classmethod
def sweep_statement(cls, now: float, *, keep: str | None = None):
"""The bounded cleanup ``DELETE`` (see the module docstring's cleanup contract).
``keep`` is the key the calling ``record_failure`` is about to upsert:
that row is live and the upsert decides its fate from its own values
(a served lock starts a fresh sentence, a stale counter keeps counting),
exactly as the memory store does; sweeping it first would turn the
failure into a fresh ``(1, NULL, NULL)`` row and split the contract.
The expiry predicate appears twice on purpose: once inside the
``IN (SELECT ... LIMIT n)`` subquery that bounds the batch, and again
on the ``DELETE`` target. The subquery is evaluated against the
statement snapshot; PostgreSQL READ COMMITTED re-evaluates only the
outer WHERE on a row whose lock it had to wait for, and ``ip IN (...)``
still holds for that old candidate — so without the repeated predicate
a lock that a concurrent ``check`` just extended (its UPDATE committed
while this DELETE waited) would be deleted and the next attempt from
that IP would reach password verification. SQLite serializes writers,
so there the repetition is merely redundant.
"""
candidates = cls._expired(now) if keep is None else and_(LoginThrottleRow.ip != keep, cls._expired(now))
victims = select(LoginThrottleRow.ip).where(candidates).limit(SWEEP_BATCH_SIZE)
target = cls._expired(now) if keep is None else and_(LoginThrottleRow.ip != keep, cls._expired(now))
return delete(LoginThrottleRow).where(LoginThrottleRow.ip.in_(victims), target)

View File

@ -28,8 +28,8 @@ The empty-DB path keeps using `create_all` because `Base.metadata` is the only a
`0026_mcp_task_lease_tokens` → `0027_notification_deliveries` →
`0028_parked_attempts` → `0029_scheduler_agent_tasks` →
`0030_notification_claim_tokens` → `0031_scheduled_streak_boundary` →
`0032_activity_and_task_events` → `0033_batch_result_artifact` → `0034_run_event_seq_watermark`
(current head). The preference
`0032_activity_and_task_events` → `0033_batch_result_artifact` → `0034_run_event_seq_watermark` →
`0035_login_throttle` (current head). The preference
revision adds a separate owner/key table with a cascading users foreign key and
does not alter users; the project-documents revision adds a new owner-scoped
shelf table, and the MCP lease-token revision adds two nullable token columns to
@ -175,6 +175,7 @@ on installs that never enabled it. The convention is:
- `migrations/versions/0028_parked_attempts.py` — adds `notification_deliveries.parked_attempts` so channel-down parking is capped; backfills with a temporary `server_default="0"` then drops it so the durable schema matches `create_all` (ORM Python-side `default=0` only). Chains after `0027_notification_deliveries`
- `migrations/versions/0031_scheduled_streak_boundary.py` — adds nullable `scheduled_tasks.unmet_streak_after_seq` (occurrences at or below it never count toward the three-miss automatic pause; moved by goal/prompt/stop-condition/note edits, internal and never serialized) and nullable `scheduled_tasks.stop_condition` (the user's normalized "stop when …" rule, appended to the run message only at launch). Two nullable columns, so the bootstrap forward-compat floor is unchanged; chains after `0030_notification_claim_tokens`. Test: `tests/test_migration_0031_scheduled_streak_boundary.py`
- `migrations/versions/0032_activity_and_task_events.py` — all schema of the scheduled-signals change in one revision: nullable `runs.origin_kind` (server-owned run origin; never backfilled, so legacy runs stay NULL, the "interactive or unknown" value) plus `ix_runs_thread_change_seq`, the per-user `thread_read_markers` (`(user_id, thread_id)` key, `seen_change_seq`) and `thread_read_versions` (`user_id` key, `version`) tables, and `scheduled_task_events` (lifecycle rows for the originating chat, unique on `(task_id, anchor, event)` as `uq_scheduled_task_event`, indexed on `(thread_id, created_at)` and `user_id`). `_helpers.py` guards only columns, so the revision guards every table and index on its own with a fresh inspector in both directions: a partially applied upgrade completes on the next start and an interrupted downgrade can run again. `RunRow` declares the index and column, and both new models are registered in `persistence/models`, because the empty-DB bootstrap uses `create_all` + `stamp head`. New tables and a nullable column, so the bootstrap forward-compat floor is unchanged; chains after `0031_scheduled_streak_boundary`. The `runs` index is built without `CONCURRENTLY` (a brief write block on large Postgres tables) and the SQLite downgrade rebuilds `runs`. Test: `tests/test_migration_0032_activity_and_task_events.py`
- `migrations/versions/0035_login_throttle.py` — creates `login_throttle` (`ip` PK, `fail_count`, nullable epoch `locked_at`, nullable `lock_duration_seconds`, `updated_at` + `ix_login_throttle_updated_at` for the sweep's stale-counter predicate), the shared failed-login counter behind `POST /api/v1/auth/login/local` selected by `auth.local.throttle_storage` (`persistence/login_throttle/`: `SqlLoginThrottleStore` increments and starts the lock in one dialect-native upsert, applies check decisions with compare-and-set predicates in a separate write transaction, and sweeps served locks plus 24h-idle counters in bounded batches — the sweep's `DELETE` repeats the expiry predicate on its target, not only `ip IN (SELECT … LIMIT n)`, because PostgreSQL READ COMMITTED re-evaluates only the statement's own WHERE on a row it waited for and would otherwise delete a lock a concurrent `check` just extended). Table create/drop is guarded with a fresh inspector in both directions. New table, so the bootstrap forward-compat floor is unchanged; chains after `0034_run_event_seq_watermark`. Test: `tests/test_migration_0035_login_throttle.py`
- `persistence/bootstrap.py` — `bootstrap_schema(engine, backend=...)`, the three-branch provisioning decision, locked revision validation, and the narrow 0019 forward-compatibility exception
- `extensions/loader.py::load_extensions` — registers each spec's `table_prefix` with `register_extension_table_prefix()`
- Tests: `tests/test_persistence_bootstrap.py` (branches), `tests/test_persistence_bootstrap_concurrency.py` (concurrency), `tests/test_persistence_bootstrap_regression.py` (issue #3682), `tests/test_persistence_migrations_env.py` (filter, including extension-owned tables), `tests/test_extension_loader.py::TestTablePrefixRegistration` (spec-to-filter wiring), `tests/blocking_io/test_persistence_bootstrap.py` (asyncio.to_thread anchor), `tests/test_migration_0004_run_ownership_dedupe.py` + `tests/test_migration_0007_scheduled_run_active_dedupe.py` (dedupe-before-unique-index pre-steps), `tests/test_migration_0025_repair_run_change_seq.py` (issue #5516 skipped-revision heal)

View File

@ -0,0 +1,68 @@
"""Shared failed-login counter for cross-replica lockouts.
Revision ID: 0035_login_throttle
Revises: 0034_run_event_seq_watermark
Creates ``login_throttle``: one row per client IP that failed a local login,
keyed by ``ip``, with the consecutive failure count, the epoch timestamp the
lock started (NULL while only counting), the sentence committed for that lock
and ``updated_at``. Every Gateway replica sharing the application database
reads and writes the same row (``auth.local.throttle_storage``), replacing the
per-process dict that gave an attacker N x ``max_login_attempts`` guesses
behind a load balancer.
``_helpers.py`` only guards columns, so the table is guarded with a fresh
inspector in both directions: an upgrade that finds the table (an interrupted
earlier attempt, or an empty-database bootstrap that already ran
``create_all``) completes, and a downgrade after the table is gone still
moves the version row.
"""
from __future__ import annotations
from collections.abc import Sequence
import sqlalchemy as sa
from alembic import op
revision: str = "0035_login_throttle"
down_revision: str | Sequence[str] | None = "0034_run_event_seq_watermark"
branch_labels: str | Sequence[str] | None = None
depends_on: str | Sequence[str] | None = None
_TABLE = "login_throttle"
_INDEX = "ix_login_throttle_updated_at"
def _has_table() -> bool:
return sa.inspect(op.get_bind()).has_table(_TABLE)
def _has_index() -> bool:
if not _has_table():
return False
return _INDEX in {index["name"] for index in sa.inspect(op.get_bind()).get_indexes(_TABLE)}
def upgrade() -> None:
if not _has_table():
op.create_table(
_TABLE,
sa.Column("ip", sa.String(length=255), nullable=False),
sa.Column("fail_count", sa.Integer(), nullable=False),
sa.Column("locked_at", sa.Double(), nullable=True),
sa.Column("lock_duration_seconds", sa.Double(), nullable=True),
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
sa.PrimaryKeyConstraint("ip", name="pk_login_throttle"),
)
# Serves the sweep's stale-counter predicate; guarded separately so a
# table left behind by an interrupted attempt still gets its index.
if not _has_index():
op.create_index(_INDEX, _TABLE, ["updated_at"])
def downgrade() -> None:
if _has_index():
op.drop_index(_INDEX, table_name=_TABLE)
if _has_table():
op.drop_table(_TABLE)

View File

@ -22,6 +22,7 @@ from deerflow.persistence.channel_connections.model import (
ChannelOAuthStateRow,
)
from deerflow.persistence.feedback.model import FeedbackRow
from deerflow.persistence.login_throttle.model import LoginThrottleRow
from deerflow.persistence.managed_subagents.model import ManagedSubagentRow
from deerflow.persistence.mcp_tasks.model import McpTaskRow
from deerflow.persistence.models.run_event import RunEventRow, RunEventThreadSeqRow
@ -45,6 +46,7 @@ __all__ = [
"ChannelCredentialRow",
"ChannelOAuthStateRow",
"FeedbackRow",
"LoginThrottleRow",
"McpTaskRow",
"ManagedSubagentRow",
"NotificationDeliveryRow",

View File

@ -7,31 +7,69 @@ the policy — including an already-locked attacker flooding the endpoint on
the way to its 429. Both resolution points (the rate-limit check and the
failure recorder) offload via ``asyncio.to_thread``; if either regresses onto
the event loop, the strict Blockbuster gate raises ``BlockingError``.
The counter itself lives behind ``LoginThrottleStore``. The SQL store runs
every statement through the async session (aiosqlite / asyncpg), so the same
anchors run against it too: a synchronous driver call or file access sneaking
into the store would trip the gate here. The engine is built off the loop,
because ``create_async_engine`` resolves the SQLite path synchronously — test
fixture work, not the production path under test.
"""
from __future__ import annotations
import time
import asyncio
from collections.abc import AsyncIterator
from pathlib import Path
import pytest
import pytest_asyncio
import sqlalchemy as sa
from fastapi import HTTPException
from fastapi.responses import Response
from fastapi.security import OAuth2PasswordRequestForm
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from starlette.requests import Request
# Pre-import so the router's lazy imports are cached no-ops under the gate.
import deerflow.persistence.models # noqa: F401
from app.gateway.auth import login_throttle
from app.gateway.routers import auth as auth_router
from deerflow.persistence.base import Base
from deerflow.persistence.login_throttle import LoginThrottleRow, LoginThrottleStore, MemoryLoginThrottleStore, SqlLoginThrottleStore
pytestmark = pytest.mark.asyncio
_CLIENT_IP = "203.0.113.9"
_POLICY = {"max_attempts": 5, "lockout_seconds": 3600.0}
@pytest.fixture(autouse=True)
def _throttle_state(monkeypatch):
def _build_sqlite_engine(path: Path) -> AsyncEngine:
"""Create the schema with a sync engine and return the async one (runs in a worker thread)."""
sync_engine = sa.create_engine(f"sqlite:///{path.as_posix()}")
try:
Base.metadata.create_all(sync_engine, tables=[LoginThrottleRow.__table__])
finally:
sync_engine.dispose()
return create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
@pytest_asyncio.fixture(params=["memory", "sql"])
async def store(request, tmp_path, monkeypatch) -> AsyncIterator[LoginThrottleStore]:
monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False)
auth_router._login_attempts.clear()
yield
auth_router._login_attempts.clear()
engine: AsyncEngine | None = None
if request.param == "memory":
store: LoginThrottleStore = MemoryLoginThrottleStore()
else:
engine = await asyncio.to_thread(_build_sqlite_engine, tmp_path / "throttle.db")
store = SqlLoginThrottleStore(async_sessionmaker(engine, expire_on_commit=False))
login_throttle.install_login_throttle_store(store)
try:
yield store
finally:
login_throttle.reset_login_throttle_store()
if engine is not None:
await engine.dispose()
def _request() -> Request:
@ -52,10 +90,11 @@ def _form() -> OAuth2PasswordRequestForm:
return OAuth2PasswordRequestForm(username="user@example.com", password="wrong")
async def test_locked_ip_policy_resolution_does_not_block_loop() -> None:
async def test_locked_ip_policy_resolution_does_not_block_loop(store) -> None:
"""A locked IP floods the endpoint: every request resolves the policy on
the way to 429, and that resolution must stay off the event loop."""
auth_router._login_attempts[_CLIENT_IP] = (9, time.time(), 3600.0) # sentence running
for _ in range(_POLICY["max_attempts"]):
await store.record_failure(_CLIENT_IP, **_POLICY) # sentence running
with pytest.raises(HTTPException) as exc_info:
await auth_router.login_local(_request(), Response(), _form(), remember_me=True)
@ -63,7 +102,7 @@ async def test_locked_ip_policy_resolution_does_not_block_loop() -> None:
assert exc_info.value.status_code == 429
async def test_failed_login_recording_does_not_block_loop(monkeypatch) -> None:
async def test_failed_login_recording_does_not_block_loop(store, monkeypatch) -> None:
"""The wrong-password path resolves the policy again inside the recorder;
counting must happen without blocking IO on the loop."""
@ -72,10 +111,42 @@ async def test_failed_login_recording_does_not_block_loop(monkeypatch) -> None:
return None
monkeypatch.setattr(auth_router, "get_local_provider", lambda: _Provider())
auth_router._login_attempts[_CLIENT_IP] = (1, 0.0, 0.0) # counting, not locked
await store.record_failure(_CLIENT_IP, **_POLICY) # counting, not locked
with pytest.raises(HTTPException) as exc_info:
await auth_router.login_local(_request(), Response(), _form(), remember_me=True)
assert exc_info.value.status_code == 401
assert auth_router._login_attempts[_CLIENT_IP][0] == 2
assert (await store.get(_CLIENT_IP)).fail_count == 2
async def test_successful_login_reset_does_not_block_loop(store, monkeypatch) -> None:
"""The success path clears the counter through the store on the loop."""
from app.gateway.auth.config import AuthConfig, set_auth_config
from app.gateway.auth.models import User
class _Provider:
async def authenticate(self, credentials):
return User(email="user@example.com")
set_auth_config(AuthConfig(jwt_secret="blocking-io-throttle-test-secret"))
monkeypatch.setattr(auth_router, "get_local_provider", lambda: _Provider())
await store.record_failure(_CLIENT_IP, **_POLICY)
result = await auth_router.login_local(_request(), Response(), _form(), remember_me=True)
assert result.expires_in > 0
assert await store.get(_CLIENT_IP) is None
async def test_store_reset_and_probe_do_not_block_loop(store) -> None:
"""Store-level anchor for the success path: ``reset`` (DELETE) and the
``get`` probe run through the async session with no sync driver call or
file access on the loop — the SQL variant is the one that matters."""
await store.record_failure(_CLIENT_IP, **_POLICY)
assert (await store.get(_CLIENT_IP)).fail_count == 1
await store.reset(_CLIENT_IP)
assert await store.get(_CLIENT_IP) is None
await store.reset(_CLIENT_IP) # idempotent on a clean IP

View File

@ -6,6 +6,7 @@ from uuid import UUID, uuid4
import bcrypt
import pytest
import pytest_asyncio
from fastapi import FastAPI, HTTPException
from fastapi.testclient import TestClient
@ -1173,77 +1174,171 @@ def test_login_response_includes_needs_setup():
# ── Rate Limiting ──────────────────────────────────────────────────────────
#
# The router counts failed logins through a ``LoginThrottleStore`` resolved
# once per process (``app.gateway.auth.login_throttle``). These tests install
# a store through that hook instead of poking router state, and run the
# router helpers against both the in-process counter and the shared SQL table
# so the two cannot drift apart.
_THROTTLE_T0 = 1_700_000_000.0
_THROTTLE_429_DETAIL = "Too many login attempts. Try again later."
def _install_throttle_store(store):
from app.gateway.auth import login_throttle
login_throttle.install_login_throttle_store(store)
@pytest.fixture
def memory_throttle_store():
"""A fresh in-process counter installed as the router's store."""
from app.gateway.auth import login_throttle
from deerflow.persistence.login_throttle import MemoryLoginThrottleStore
store = MemoryLoginThrottleStore()
login_throttle.install_login_throttle_store(store)
try:
yield store
finally:
login_throttle.reset_login_throttle_store()
@pytest_asyncio.fixture(params=["memory", "sql"])
async def throttle_store(request, tmp_path):
"""The router's store, parametrized over both implementations."""
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.auth import login_throttle
from deerflow.persistence.base import Base
from deerflow.persistence.login_throttle import LoginThrottleRow, MemoryLoginThrottleStore, SqlLoginThrottleStore
engine = None
if request.param == "memory":
store = MemoryLoginThrottleStore()
else:
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'throttle.db'}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all, tables=[LoginThrottleRow.__table__])
store = SqlLoginThrottleStore(async_sessionmaker(engine, expire_on_commit=False))
login_throttle.install_login_throttle_store(store)
try:
yield store
finally:
login_throttle.reset_login_throttle_store()
if engine is not None:
await engine.dispose()
@pytest.fixture
def throttle_policy():
"""Set ``auth.local`` throttle knobs on the live app config; reset afterwards."""
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
def _set(*, max_attempts: int = 5, lockout_seconds: float = 60.0) -> None:
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="test"),
auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=max_attempts, lockout_seconds=lockout_seconds)),
)
)
try:
yield _set
finally:
reset_app_config()
class _MutateDuringPolicyResolution:
"""Store wrapper that mutates the record while ``check`` is suspended on the policy read.
``check`` probes the record (cheap clean-IP skip), resolves the policy —
an off-loop config read, so a yield point — and must then decide on a
fresh read. This wrapper runs ``mutation`` right after the policy callable
returns, standing in for a concurrent request that lands during the
resolution.
"""
def __init__(self, inner, mutation):
self._inner = inner
self._mutation = mutation
async def get(self, ip):
return await self._inner.get(ip)
async def check(self, ip, *, policy, now=None):
async def racing_policy():
resolved = await policy()
await self._mutation(self._inner)
return resolved
return await self._inner.check(ip, policy=racing_policy, now=now)
async def record_failure(self, ip, **kwargs):
return await self._inner.record_failure(ip, **kwargs)
async def reset(self, ip):
await self._inner.reset(ip)
@pytest.mark.asyncio
async def test_rate_limiter_allows_under_limit():
async def test_rate_limiter_allows_under_limit(throttle_store):
"""Requests under the limit are allowed."""
from app.gateway.routers.auth import _check_rate_limit, _login_attempts
from app.gateway.routers.auth import _check_rate_limit
_login_attempts.clear()
await _check_rate_limit("192.168.1.1") # Should not raise
@pytest.mark.asyncio
async def test_rate_limiter_blocks_after_max_failures():
"""IP is blocked after 5 consecutive failures."""
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure
async def test_rate_limiter_blocks_after_max_failures(throttle_store):
"""IP is blocked after 5 consecutive failures, with the historical 429 message."""
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure
_login_attempts.clear()
ip = "10.0.0.1"
for _ in range(5):
await _record_login_failure(ip)
with pytest.raises(HTTPException) as exc_info:
await _check_rate_limit(ip)
assert exc_info.value.status_code == 429
assert exc_info.value.detail == _THROTTLE_429_DETAIL
@pytest.mark.asyncio
async def test_rate_limiter_resets_on_success():
async def test_rate_limiter_resets_on_success(throttle_store):
"""Successful login clears the failure counter."""
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure, _record_login_success
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure, _record_login_success
_login_attempts.clear()
ip = "10.0.0.2"
for _ in range(4):
await _record_login_failure(ip)
_record_login_success(ip)
await _record_login_success(ip)
await _check_rate_limit(ip) # Should not raise
assert await throttle_store.get(ip) is None
@pytest.mark.asyncio
async def test_rate_limiter_honors_configured_attempts_and_lockout(monkeypatch):
async def test_rate_limiter_honors_configured_attempts_and_lockout(throttle_store, throttle_policy, monkeypatch):
"""auth.local.max_login_attempts / lockout_seconds drive the throttle policy."""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure
_login_attempts.clear()
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="test"),
auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=2, lockout_seconds=60.0)),
)
)
try:
ip = "10.0.0.3"
await _record_login_failure(ip)
await _check_rate_limit(ip) # 1 failure < 2: allowed
await _record_login_failure(ip)
with pytest.raises(HTTPException) as exc_info:
await _check_rate_limit(ip)
assert exc_info.value.status_code == 429
# The lockout window comes from lockout_seconds (60s), not the 300s
# default: at locked_at + 61 the lock must already be released.
_, locked_at, _ = _login_attempts[ip]
monkeypatch.setattr(auth_router.time, "time", lambda: locked_at + 61.0)
throttle_policy(max_attempts=2, lockout_seconds=60.0)
ip = "10.0.0.3"
await _record_login_failure(ip)
await _check_rate_limit(ip) # 1 failure < 2: allowed
await _record_login_failure(ip)
with pytest.raises(HTTPException) as exc_info:
await _check_rate_limit(ip)
assert ip not in _login_attempts
finally:
reset_app_config()
_login_attempts.clear()
assert exc_info.value.status_code == 429
# The lockout window comes from lockout_seconds (60s), not the 300s
# default: at locked_at + 61 the lock must already be released.
locked_at = (await throttle_store.get(ip)).locked_at
monkeypatch.setattr(auth_router.time, "time", lambda: locked_at + 61.0)
await _check_rate_limit(ip)
assert await throttle_store.get(ip) is None
def test_rate_limiter_uses_defaults_when_config_unavailable(monkeypatch):
@ -1282,7 +1377,7 @@ def test_rate_limiter_malformed_config_propagates(monkeypatch):
@pytest.mark.asyncio
async def test_rate_limiter_clean_ip_skips_config_read(monkeypatch):
async def test_rate_limiter_clean_ip_skips_config_read(throttle_store, monkeypatch):
"""A clean IP pays zero config reads: the record-None early return must
come before policy resolution (get_app_config re-hashes config.yaml on
every call, and login_local is an unauthenticated async endpoint)."""
@ -1293,12 +1388,11 @@ async def test_rate_limiter_clean_ip_skips_config_read(monkeypatch):
raise AssertionError("config must not be read for a clean IP")
monkeypatch.setattr(app_config_module, "get_app_config", _must_not_load)
auth_router._login_attempts.clear()
await auth_router._check_rate_limit("192.0.2.7") # returns quietly → no config read
@pytest.mark.asyncio
async def test_rate_limiter_policy_change_semantics():
async def test_rate_limiter_policy_change_semantics(throttle_store, throttle_policy):
"""Pin the emergent semantics of a live-read policy under config reload.
Raising max_login_attempts mid-lockout immediately unblocks IPs whose
@ -1307,51 +1401,35 @@ async def test_rate_limiter_policy_change_semantics():
Tightening the threshold keeps the accumulated count (see the dedicated
test below); subsequent failures lock under the new, stricter policy.
"""
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure
def _set_policy(max_attempts: int) -> None:
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="test"),
auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=max_attempts, lockout_seconds=60.0)),
)
)
_login_attempts.clear()
try:
ip = "10.0.0.4"
_set_policy(2)
for _ in range(2):
await _record_login_failure(ip)
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # locked under the old policy
_set_policy(5) # operator raises the ceiling mid-lockout
await _check_rate_limit(ip) # immediately allowed: 2 < 5, no restart needed
# max_login_attempts=1 never reaches the endpoint: config load rejects
# it (ge=2). A legal value of 1 previously disabled lockout entirely —
# the (1, 0.0) first-failure record expired immediately, and every
# subsequent failure re-created it, so the IP was never locked.
import pydantic
with pytest.raises(pydantic.ValidationError):
_set_policy(1)
# The strictest legal value still locks, at the second failure.
_login_attempts.pop(ip, None)
_set_policy(2)
ip = "10.0.0.4"
throttle_policy(max_attempts=2)
for _ in range(2):
await _record_login_failure(ip)
await _check_rate_limit(ip) # 1 failure < 2: allowed
await _record_login_failure(ip)
with pytest.raises(HTTPException):
await _check_rate_limit(ip)
finally:
reset_app_config()
_login_attempts.clear()
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # locked under the old policy
throttle_policy(max_attempts=5) # operator raises the ceiling mid-lockout
await _check_rate_limit(ip) # immediately allowed: 2 < 5, no restart needed
# max_login_attempts=1 never reaches the endpoint: config load rejects
# it (ge=2). A legal value of 1 previously disabled lockout entirely —
# the (1, 0.0) first-failure record expired immediately, and every
# subsequent failure re-created it, so the IP was never locked.
import pydantic
with pytest.raises(pydantic.ValidationError):
throttle_policy(max_attempts=1)
# The strictest legal value still locks, at the second failure.
await throttle_store.reset(ip)
throttle_policy(max_attempts=2)
await _record_login_failure(ip)
await _check_rate_limit(ip) # 1 failure < 2: allowed
await _record_login_failure(ip)
with pytest.raises(HTTPException):
await _check_rate_limit(ip)
def test_local_auth_throttle_config_validation():
@ -1375,7 +1453,7 @@ def test_local_auth_throttle_config_validation():
@pytest.mark.asyncio
async def test_rate_limiter_active_lockout_honors_live_lockout_seconds_change(monkeypatch):
async def test_rate_limiter_active_lockout_honors_live_lockout_seconds_change(throttle_store, throttle_policy, monkeypatch):
"""A live lockout_seconds change applies to in-flight lockouts, in the
direction that serves the operator, without resurrecting served sentences.
@ -1386,67 +1464,52 @@ async def test_rate_limiter_active_lockout_honors_live_lockout_seconds_change(mo
resurrection), and a raise while the lock is still active extends it.
"""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
def _set_policy(lockout_seconds: float) -> None:
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="test"),
auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=2, lockout_seconds=lockout_seconds)),
)
)
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure
def _freeze_clock_at(t: float) -> None:
monkeypatch.setattr(auth_router.time, "time", lambda: t)
_login_attempts.clear()
try:
ip = "10.0.0.5"
ip = "10.0.0.5"
# Lowering mid-lockout releases early.
_set_policy(60.0)
await _record_login_failure(ip)
await _record_login_failure(ip)
_, locked_at, _ = _login_attempts[ip]
_freeze_clock_at(locked_at + 2.0)
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # 2s into the 60s window: still locked
_set_policy(1.0) # operator shortens the window
await _check_rate_limit(ip) # 2s > 1s: unlocked on the very next login
assert ip not in _login_attempts
# Lowering mid-lockout releases early.
throttle_policy(max_attempts=2, lockout_seconds=60.0)
await _record_login_failure(ip)
await _record_login_failure(ip)
locked_at = (await throttle_store.get(ip)).locked_at
_freeze_clock_at(locked_at + 2.0)
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # 2s into the 60s window: still locked
throttle_policy(max_attempts=2, lockout_seconds=1.0) # operator shortens the window
await _check_rate_limit(ip) # 2s > 1s: unlocked on the very next login
assert await throttle_store.get(ip) is None
# Raising while the lock is still active extends it.
_set_policy(1.0)
await _record_login_failure(ip)
await _record_login_failure(ip)
_, locked_at, _ = _login_attempts[ip]
_freeze_clock_at(locked_at + 0.5) # still inside the 1s sentence
_set_policy(60.0) # operator lengthens the window mid-sentence
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # active, and 0.5 < 60: extended
_freeze_clock_at(locked_at + 2.0) # past the original 1s sentence
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # extended: 2 < 60, still locked
# Raising while the lock is still active extends it.
throttle_policy(max_attempts=2, lockout_seconds=1.0)
await _record_login_failure(ip)
await _record_login_failure(ip)
locked_at = (await throttle_store.get(ip)).locked_at
_freeze_clock_at(locked_at + 0.5) # still inside the 1s sentence
throttle_policy(max_attempts=2, lockout_seconds=60.0) # operator lengthens the window mid-sentence
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # active, and 0.5 < 60: extended
_freeze_clock_at(locked_at + 2.0) # past the original 1s sentence
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # extended: 2 < 60, still locked
# A sentence that already elapsed before the raise is not resurrected.
_set_policy(1.0)
await _record_login_failure(ip)
await _record_login_failure(ip)
_, locked_at, _ = _login_attempts[ip]
_freeze_clock_at(locked_at + 2.0) # past the 1s sentence, no check yet
_set_policy(60.0) # operator lengthens the window for *future* locks
await _check_rate_limit(ip) # the served 1s sentence is not resurrected
assert ip not in _login_attempts
finally:
reset_app_config()
_login_attempts.clear()
# A sentence that already elapsed before the raise is not resurrected.
throttle_policy(max_attempts=2, lockout_seconds=1.0)
await throttle_store.reset(ip)
await _record_login_failure(ip)
await _record_login_failure(ip)
locked_at = (await throttle_store.get(ip)).locked_at
_freeze_clock_at(locked_at + 2.0) # past the 1s sentence, no check yet
throttle_policy(max_attempts=2, lockout_seconds=60.0) # operator lengthens the window for *future* locks
await _check_rate_limit(ip) # the served 1s sentence is not resurrected
assert await throttle_store.get(ip) is None
@pytest.mark.asyncio
async def test_rate_limiter_lowered_then_raised_duration_not_resurrected(monkeypatch):
async def test_rate_limiter_lowered_then_raised_duration_not_resurrected(throttle_store, throttle_policy, monkeypatch):
"""A shortened duration observed during the sentence is committed, so a
later raise cannot resurrect time already served under the short policy.
@ -1455,220 +1518,147 @@ async def test_rate_limiter_lowered_then_raised_duration_not_resurrected(monkeyp
+10s under the last-evaluated policy, so the +20s request must be allowed.
"""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure
def _set_policy(lockout_seconds: float) -> None:
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="test"),
auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=2, lockout_seconds=lockout_seconds)),
)
)
def _freeze(t: float) -> None:
monkeypatch.setattr(auth_router.time, "time", lambda: t)
_login_attempts.clear()
try:
ip = "10.0.0.8"
_set_policy(60.0)
await _record_login_failure(ip)
await _record_login_failure(ip)
_, locked_at, _ = _login_attempts[ip]
ip = "10.0.0.8"
throttle_policy(max_attempts=2, lockout_seconds=60.0)
await _record_login_failure(ip)
await _record_login_failure(ip)
locked_at = (await throttle_store.get(ip)).locked_at
def _freeze(t: float) -> None:
monkeypatch.setattr(auth_router.time, "time", lambda: t)
_freeze(locked_at + 6.0)
throttle_policy(max_attempts=2, lockout_seconds=10.0)
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # 6 < 10: still locked, and the 10s sentence is committed
assert (await throttle_store.get(ip)).lock_duration == 10.0
_freeze(locked_at + 6.0)
_set_policy(10.0)
with pytest.raises(HTTPException):
await _check_rate_limit(ip) # 6 < 10: still locked, and the 10s sentence is committed
assert _login_attempts[ip][2] == 10.0
_freeze(locked_at + 20.0)
_set_policy(30.0) # raised after the 10s sentence was served at +10s
await _check_rate_limit(ip) # not resurrected: allowed
assert ip not in _login_attempts
finally:
reset_app_config()
_login_attempts.clear()
_freeze(locked_at + 20.0)
throttle_policy(max_attempts=2, lockout_seconds=30.0) # raised after the 10s sentence was served at +10s
await _check_rate_limit(ip) # not resurrected: allowed
assert await throttle_store.get(ip) is None
@pytest.mark.asyncio
async def test_concurrent_checks_on_expired_lock_are_race_free(monkeypatch):
async def test_concurrent_checks_on_expired_lock_are_race_free(throttle_store, monkeypatch):
"""Synchronized checks of the same expired lock must all resolve cleanly.
Policy resolution yields the event loop (asyncio.to_thread), so the sync
version's atomicity is gone: with a pre-await snapshot only, concurrent
checks of an expired record raced into double ``del`` — one request
returned normally and the others raised KeyError (review reproduction).
The SQL store must likewise tolerate racing deletes of the same row.
"""
import asyncio
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts
from app.gateway.routers.auth import _check_rate_limit
from deerflow.config.auth_config import LocalAuthConfig
def _defaults():
return LocalAuthConfig().max_login_attempts, LocalAuthConfig().lockout_seconds
monkeypatch.setattr(auth_router, "_login_throttle_policy", _defaults)
_login_attempts.clear()
try:
_login_attempts["10.0.0.9"] = (5, 1.0, 1.0) # sentence long expired
ip = "10.0.0.9"
for _ in range(5):
await throttle_store.record_failure(ip, max_attempts=5, lockout_seconds=1.0, now=1.0) # sentence long expired
results = await asyncio.gather(*[_check_rate_limit("10.0.0.9") for _ in range(8)], return_exceptions=True)
results = await asyncio.gather(*[_check_rate_limit(ip) for _ in range(8)], return_exceptions=True)
assert all(result is None for result in results), results
assert "10.0.0.9" not in _login_attempts
finally:
_login_attempts.clear()
assert all(result is None for result in results), results
assert await throttle_store.get(ip) is None
@pytest.mark.asyncio
async def test_check_survives_record_deleted_during_policy_resolution(monkeypatch):
"""A record removed while the checker is suspended must not KeyError.
async def test_check_survives_record_deleted_during_policy_resolution(throttle_store, monkeypatch):
"""A record removed while the checker is suspended must not fail.
Deterministic stand-in for the suspension-window interleaving: the policy
resolution itself removes the record, exactly like a concurrent success
login would while this coroutine sits in ``asyncio.to_thread``.
Deterministic stand-in for the suspension-window interleaving: a
concurrent successful login clears the record right after the pre-read,
exactly as it would while this coroutine sits in ``asyncio.to_thread``.
"""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_success
from app.gateway.routers.auth import _check_rate_limit
def _policy_that_deletes_the_record():
_record_login_success("10.0.0.10")
return 5, 300.0
ip = "10.0.0.10"
for _ in range(5):
await throttle_store.record_failure(ip, max_attempts=5, lockout_seconds=1.0, now=1.0) # expired
monkeypatch.setattr(auth_router, "_login_throttle_policy", _policy_that_deletes_the_record)
_login_attempts.clear()
try:
_login_attempts["10.0.0.10"] = (5, 1.0, 1.0) # expired
async def _concurrent_success(inner):
await inner.reset(ip)
await _check_rate_limit("10.0.0.10") # pre-fix: KeyError
_install_throttle_store(_MutateDuringPolicyResolution(throttle_store, _concurrent_success))
monkeypatch.setattr(auth_router, "_login_throttle_policy", lambda: (5, 300.0))
assert "10.0.0.10" not in _login_attempts
finally:
_login_attempts.clear()
await _check_rate_limit(ip) # pre-fix: KeyError
assert await throttle_store.get(ip) is None
@pytest.mark.asyncio
async def test_check_never_clobbers_record_replaced_during_policy_resolution(monkeypatch):
async def test_check_never_clobbers_record_replaced_during_policy_resolution(throttle_store, monkeypatch):
"""A record replaced while the checker is suspended must survive intact.
The pre-await snapshot said "locked"; while suspended, a successful login
plus one new failure replaced the record with a fresh counter. The checker
must re-read and leave the fresh record alone instead of writing its
stale-snapshot decision over it.
The pre-read said "locked"; while suspended, a successful login plus one
new failure replaced the record with a fresh counter. The checker must
decide on a fresh read and leave the fresh record alone instead of writing
its stale-snapshot decision over it.
"""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts
from app.gateway.routers.auth import _check_rate_limit
from deerflow.persistence.login_throttle import LoginThrottleRecord
def _policy_that_replaces_the_record():
_login_attempts["10.0.0.11"] = (1, 0.0, 0.0)
return 2, 60.0
ip = "10.0.0.11"
for _ in range(2):
await throttle_store.record_failure(ip, max_attempts=2, lockout_seconds=60.0, now=_THROTTLE_T0) # locked, sentence running
monkeypatch.setattr(auth_router.time, "time", lambda: _THROTTLE_T0 + 1.0)
monkeypatch.setattr(auth_router, "_login_throttle_policy", _policy_that_replaces_the_record)
_login_attempts.clear()
try:
_login_attempts["10.0.0.11"] = (2, 1.0, 1.0) # locked, sentence running
async def _success_then_one_failure(inner):
await inner.reset(ip)
await inner.record_failure(ip, max_attempts=2, lockout_seconds=60.0, now=_THROTTLE_T0 + 1.0)
await _check_rate_limit("10.0.0.11") # fresh read: (1, 0, 0) < max 2 → allowed
_install_throttle_store(_MutateDuringPolicyResolution(throttle_store, _success_then_one_failure))
monkeypatch.setattr(auth_router, "_login_throttle_policy", lambda: (2, 60.0))
assert _login_attempts["10.0.0.11"] == (1, 0.0, 0.0)
finally:
_login_attempts.clear()
await _check_rate_limit(ip) # fresh read: (1, 0, 0) < max 2 → allowed
assert await throttle_store.get(ip) == LoginThrottleRecord(fail_count=1, locked_at=0.0, lock_duration=0.0)
@pytest.mark.asyncio
async def test_rate_limiter_eviction_expires_by_stored_sentence_not_current_threshold(monkeypatch):
"""The capacity sweep must expire records by their own committed sentence,
with no gate on the current threshold.
A record locked under an old, lower threshold has a count below the live
max after the operator raises it; gating expiry on ``count >= max`` keeps
that served record resident while the capacity fallback evicts live
counters first (they sort earliest), handing an active offender a fresh
budget. Reproduction from review: cap 2, live ``(1, 0, 0)`` plus expired
``(2, 10, 1)`` under max=3, clock at 100.
"""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _login_attempts, _record_login_failure
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
monkeypatch.setattr(auth_router, "_MAX_TRACKED_IPS", 2)
monkeypatch.setattr(auth_router.time, "time", lambda: 100.0)
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="test"),
auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=3, lockout_seconds=60.0)),
)
)
_login_attempts.clear()
try:
_login_attempts["live-counter"] = (1, 0.0, 0.0) # active offender, counting
_login_attempts["expired-lock"] = (2, 10.0, 1.0) # locked under old max=2; served at 11.0
await _record_login_failure("fresh-ip") # hits the capacity sweep
assert "expired-lock" not in _login_attempts # served sentence is swept
assert _login_attempts["live-counter"] == (1, 0.0, 0.0) # live counter survives
assert _login_attempts["fresh-ip"][0] == 1
finally:
reset_app_config()
_login_attempts.clear()
@pytest.mark.asyncio
async def test_rate_limiter_tightened_threshold_preserves_failures():
async def test_rate_limiter_tightened_threshold_preserves_failures(throttle_store, throttle_policy):
"""Tightening max_login_attempts mid-count keeps the accumulated failures.
An IP with four failures under max_login_attempts=5 must not get a fresh
budget when the operator lowers the threshold to 2: the count stays, the
next failure starts the lock, and a successful login still clears it.
"""
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure, _record_login_success
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure, _record_login_success
def _set_policy(max_attempts: int) -> None:
set_app_config(
AppConfig(
sandbox=SandboxConfig(use="test"),
auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=max_attempts, lockout_seconds=60.0)),
)
)
ip = "10.0.0.6"
throttle_policy(max_attempts=5)
for _ in range(4):
await _record_login_failure(ip) # 4 failures: counting, never locked
_login_attempts.clear()
try:
ip = "10.0.0.6"
_set_policy(5)
for _ in range(4):
await _record_login_failure(ip) # 4 failures: counting, never locked
throttle_policy(max_attempts=2) # operator tightens the policy mid-count
await _check_rate_limit(ip) # allowed this once — but the count survives
assert (await throttle_store.get(ip)).fail_count == 4
_set_policy(2) # operator tightens the policy mid-count
await _check_rate_limit(ip) # allowed this once — but the count survives
assert _login_attempts[ip][0] == 4
await _record_login_failure(ip) # 4 + 1 >= 2: locks on the very next failure
with pytest.raises(HTTPException):
await _check_rate_limit(ip)
# A correct password still clears everything (no retroactive lockout
# of a legitimate user who fat-fingered the password four times).
_record_login_success(ip)
await _record_login_failure(ip) # 4 + 1 >= 2: locks on the very next failure
with pytest.raises(HTTPException):
await _check_rate_limit(ip)
assert ip not in _login_attempts
finally:
reset_app_config()
_login_attempts.clear()
# A correct password still clears everything (no retroactive lockout
# of a legitimate user who fat-fingered the password four times).
await _record_login_success(ip)
await _check_rate_limit(ip)
assert await throttle_store.get(ip) is None
@pytest.mark.asyncio
async def test_rate_limiter_counts_failure_when_config_breaks(monkeypatch):
async def test_rate_limiter_counts_failure_when_config_breaks(throttle_store, monkeypatch):
"""A malformed config hot-edit must not hand out unlimited verification.
Endpoint order per failed login: _check_rate_limit (clean IPs skip the
@ -1679,33 +1669,30 @@ async def test_rate_limiter_counts_failure_when_config_breaks(monkeypatch):
re-raises; from then on the dirty IP's own check reads the broken config
and fails closed — before authenticate.
"""
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure
from app.gateway.routers.auth import _check_rate_limit, _record_login_failure
from deerflow.config import app_config as app_config_module
from deerflow.persistence.login_throttle import LoginThrottleRecord
def _malformed():
raise ValueError("config validation error")
_login_attempts.clear()
monkeypatch.setattr(app_config_module, "get_app_config", _malformed)
try:
ip = "10.0.0.7"
ip = "10.0.0.7"
# First failed login: still allowed through to authenticate(), then
# the record call fails loudly — but the failure is counted first.
await _check_rate_limit(ip) # clean IP: no config read, allowed
with pytest.raises(ValueError, match="config validation error"):
await _record_login_failure(ip)
assert _login_attempts[ip] == (1, 0.0, 0.0)
# First failed login: still allowed through to authenticate(), then
# the record call fails loudly — but the failure is counted first.
await _check_rate_limit(ip) # clean IP: no config read, allowed
with pytest.raises(ValueError, match="config validation error"):
await _record_login_failure(ip)
assert await throttle_store.get(ip) == LoginThrottleRecord(fail_count=1, locked_at=0.0, lock_duration=0.0)
# Second login: the now-dirty IP's check reads the broken config and
# fails closed *before* authenticate() — no more password verification.
with pytest.raises(ValueError, match="config validation error"):
await _check_rate_limit(ip)
finally:
_login_attempts.clear()
# Second login: the now-dirty IP's check reads the broken config and
# fails closed *before* authenticate() — no more password verification.
with pytest.raises(ValueError, match="config validation error"):
await _check_rate_limit(ip)
def test_login_local_broken_config_fails_closed_after_first_failure(monkeypatch):
def test_login_local_broken_config_fails_closed_after_first_failure(memory_throttle_store, monkeypatch):
"""Route-level pin of the same sequence, through POST /login/local.
The first wrong password is verified once and counted (the 500 comes from
@ -1714,6 +1701,8 @@ def test_login_local_broken_config_fails_closed_after_first_failure(monkeypatch)
authenticate() is reached — no unlimited password verification while
config.yaml stays malformed.
"""
import asyncio
from fastapi import FastAPI
from fastapi.testclient import TestClient
@ -1734,22 +1723,74 @@ def test_login_local_broken_config_fails_closed_after_first_failure(monkeypatch)
monkeypatch.setattr(auth_router, "get_local_provider", lambda: _Provider())
monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False)
auth_router._login_attempts.clear()
app = FastAPI()
app.include_router(auth_router.router)
with TestClient(app, raise_server_exceptions=False) as client:
first = client.post("/api/v1/auth/login/local", data={"username": "user@example.com", "password": "wrong"})
assert first.status_code == 500
assert calls["authenticate"] == 1 # verified once — and counted despite the raise
assert asyncio.run(memory_throttle_store.get("testclient")).fail_count == 1
second = client.post("/api/v1/auth/login/local", data={"username": "user@example.com", "password": "wrong-again"})
assert second.status_code == 500
assert calls["authenticate"] == 1 # fail-closed: no second verification
def test_login_local_lockout_follows_the_shared_database_across_replicas(tmp_path, monkeypatch):
"""Route-level multi-replica reproduction through POST /login/local.
Two Gateway replicas are modelled as two SQL stores over one SQLite file,
swapped in between requests: the failures counted by replica A lock the
client on replica B, and a lockout is enforced wherever the load balancer
sends the next attempt. Before this change each replica's in-process dict
gave an attacker N x max_login_attempts guesses.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from sqlalchemy import create_engine
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.auth import login_throttle
from app.gateway.routers import auth as auth_router
from deerflow.config.app_config import AppConfig, reset_app_config, set_app_config
from deerflow.config.auth_config import AuthAppConfig, LocalAuthConfig
from deerflow.config.sandbox_config import SandboxConfig
from deerflow.persistence.base import Base
from deerflow.persistence.login_throttle import LoginThrottleRow, SqlLoginThrottleStore
class _Provider:
async def authenticate(self, credentials):
return None # every password is wrong
monkeypatch.setattr(auth_router, "get_local_provider", lambda: _Provider())
monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False)
set_app_config(AppConfig(sandbox=SandboxConfig(use="test"), auth=AuthAppConfig(local=LocalAuthConfig(max_login_attempts=3, lockout_seconds=300.0))))
db_path = tmp_path / "shared-throttle.db"
sync_engine = create_engine(f"sqlite:///{db_path.as_posix()}")
Base.metadata.create_all(sync_engine, tables=[LoginThrottleRow.__table__])
sync_engine.dispose()
engines = [create_async_engine(f"sqlite+aiosqlite:///{db_path.as_posix()}") for _ in range(2)]
replica_a, replica_b = (SqlLoginThrottleStore(async_sessionmaker(engine, expire_on_commit=False)) for engine in engines)
app = FastAPI()
app.include_router(auth_router.router)
try:
with TestClient(app, raise_server_exceptions=False) as client:
first = client.post("/api/v1/auth/login/local", data={"username": "user@example.com", "password": "wrong"})
assert first.status_code == 500
assert calls["authenticate"] == 1 # verified once — and counted despite the raise
assert auth_router._login_attempts["testclient"][0] == 1
with TestClient(app) as client:
login_throttle.install_login_throttle_store(replica_a)
for _ in range(3):
assert client.post("/api/v1/auth/login/local", data={"username": "user@example.com", "password": "wrong"}).status_code == 401
second = client.post("/api/v1/auth/login/local", data={"username": "user@example.com", "password": "wrong-again"})
assert second.status_code == 500
assert calls["authenticate"] == 1 # fail-closed: no second verification
login_throttle.install_login_throttle_store(replica_b)
blocked = client.post("/api/v1/auth/login/local", data={"username": "user@example.com", "password": "wrong"})
assert blocked.status_code == 429
assert blocked.json()["detail"] == _THROTTLE_429_DETAIL
finally:
auth_router._login_attempts.clear()
login_throttle.reset_login_throttle_store()
reset_app_config()
for engine in engines:
engine.sync_engine.dispose()
# ── Client IP extraction ─────────────────────────────────────────────────

View File

@ -0,0 +1,213 @@
"""``auth.local.throttle_storage``: selector semantics and store resolution.
``auto`` (the default) puts failed-login counters in the application database
whenever one exists (``database.backend`` sqlite or postgres), so every
Gateway replica sharing that database enforces one lockout per IP; a
``memory`` database has no shared table and falls back to the per-process
counter. ``memory`` forces the per-process counter; ``db`` forces the table
and degrades to memory with a warning when there is no database to hold it.
"""
from __future__ import annotations
import logging
from types import SimpleNamespace
import pydantic
import pytest
from sqlalchemy.ext.asyncio import async_sessionmaker, create_async_engine
from app.gateway.auth import login_throttle
from deerflow.config.auth_config import LocalAuthConfig, LoginThrottleStorage, resolve_login_throttle_storage
from deerflow.config.reload_boundary import STARTUP_ONLY_FIELDS, STARTUP_ONLY_PREFIX
from deerflow.persistence.login_throttle import MemoryLoginThrottleStore, SqlLoginThrottleStore
@pytest.fixture(autouse=True)
def _no_installed_store():
login_throttle.reset_login_throttle_store()
yield
login_throttle.reset_login_throttle_store()
@pytest.mark.parametrize(
("selector", "database_backend", "expected"),
[
("auto", "sqlite", "db"),
("auto", "postgres", "db"),
("auto", "memory", "memory"),
("auto", None, "memory"),
("memory", "postgres", "memory"),
("memory", "sqlite", "memory"),
("db", "sqlite", "db"),
("db", "postgres", "db"),
("db", "memory", "memory"),
(LoginThrottleStorage.AUTO, "sqlite", "db"),
(LoginThrottleStorage.DB, "memory", "memory"),
],
)
def test_resolve_login_throttle_storage(selector, database_backend, expected):
assert resolve_login_throttle_storage(selector, database_backend) == expected
def test_local_auth_config_selector_defaults_to_auto_and_validates():
assert LocalAuthConfig().throttle_storage == LoginThrottleStorage.AUTO
assert LocalAuthConfig(throttle_storage="db").throttle_storage == LoginThrottleStorage.DB
assert LocalAuthConfig(throttle_storage="memory").throttle_storage == LoginThrottleStorage.MEMORY
with pytest.raises(pydantic.ValidationError):
LocalAuthConfig(throttle_storage="redis")
def test_throttle_storage_is_registered_as_startup_only():
"""The store is resolved once at Gateway startup; the policy knobs stay live-read."""
field_path = "auth.local.throttle_storage"
assert field_path in STARTUP_ONLY_FIELDS
description = LocalAuthConfig.model_fields["throttle_storage"].description or ""
assert description.startswith(STARTUP_ONLY_PREFIX)
assert STARTUP_ONLY_FIELDS[field_path] in description
for live_field in ("max_login_attempts", "lockout_seconds"):
assert not (LocalAuthConfig.model_fields[live_field].description or "").startswith(STARTUP_ONLY_PREFIX)
def _config(selector: str, database_backend: str) -> SimpleNamespace:
return SimpleNamespace(
auth=SimpleNamespace(local=LocalAuthConfig(throttle_storage=selector)),
database=SimpleNamespace(backend=database_backend),
)
@pytest.fixture
def session_factory(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'resolver.db'}")
yield async_sessionmaker(engine, expire_on_commit=False)
engine.sync_engine.dispose()
def test_auto_with_a_database_resolves_the_sql_store(session_factory, caplog):
with caplog.at_level(logging.WARNING):
store = login_throttle.resolve_login_throttle_store(_config("auto", "sqlite"), session_factory=session_factory)
assert isinstance(store, SqlLoginThrottleStore)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
def test_auto_with_postgres_resolves_the_sql_store(session_factory):
store = login_throttle.resolve_login_throttle_store(_config("auto", "postgres"), session_factory=session_factory)
assert isinstance(store, SqlLoginThrottleStore)
def test_auto_with_a_memory_database_resolves_the_memory_store(caplog):
with caplog.at_level(logging.WARNING):
store = login_throttle.resolve_login_throttle_store(_config("auto", "memory"), session_factory=None)
assert isinstance(store, MemoryLoginThrottleStore)
assert not [r for r in caplog.records if r.levelno >= logging.WARNING]
def test_explicit_memory_ignores_the_database(session_factory):
store = login_throttle.resolve_login_throttle_store(_config("memory", "postgres"), session_factory=session_factory)
assert isinstance(store, MemoryLoginThrottleStore)
def test_explicit_db_without_a_database_falls_back_to_memory_and_warns(caplog):
with caplog.at_level(logging.WARNING):
store = login_throttle.resolve_login_throttle_store(_config("db", "memory"), session_factory=None)
assert isinstance(store, MemoryLoginThrottleStore)
assert any("auth.local.throttle_storage=db" in r.message and "database.backend" in r.message for r in caplog.records)
def test_auto_resolution_without_an_engine_falls_back_to_memory_and_warns(caplog):
"""``auto`` with a configured database but no initialised engine (bare app) keeps the login path working."""
with caplog.at_level(logging.WARNING):
store = login_throttle.resolve_login_throttle_store(_config("auto", "sqlite"), session_factory=None)
assert isinstance(store, MemoryLoginThrottleStore)
assert any("engine" in r.message and "throttle_storage=auto" in r.message for r in caplog.records)
def test_explicit_db_without_an_engine_fails_closed_at_startup():
"""An operator who asked for the shared table must not silently get per-process counters.
Same failure type as the other startup validations in ``app.gateway.deps``
(``_validate_agent_storage`` / the multi-process gate), so ``langgraph_runtime``
surfaces it as a startup failure instead of a first-login surprise.
"""
with pytest.raises(SystemExit, match="auth.local.throttle_storage='db'"):
login_throttle.resolve_login_throttle_store(_config("db", "sqlite"), session_factory=None)
with pytest.raises(SystemExit, match="database.backend"):
login_throttle.resolve_login_throttle_store(_config("db", "postgres"), session_factory=None)
assert login_throttle.installed_login_throttle_store() is None
def test_lazy_resolution_explicit_db_without_an_engine_fails_closed(monkeypatch):
"""The bare-app path applies the same rule and installs nothing."""
from deerflow.config import app_config as app_config_module
from deerflow.persistence import engine as engine_module
monkeypatch.setattr(app_config_module, "get_app_config", lambda: _config("db", "sqlite"))
monkeypatch.setattr(engine_module, "get_session_factory", lambda: None)
with pytest.raises(SystemExit, match="auth.local.throttle_storage='db'"):
login_throttle.resolve_and_install_from_live_config()
assert login_throttle.installed_login_throttle_store() is None
def test_lazy_resolution_auto_without_an_engine_falls_back_to_memory(monkeypatch, caplog):
from deerflow.config import app_config as app_config_module
from deerflow.persistence import engine as engine_module
monkeypatch.setattr(app_config_module, "get_app_config", lambda: _config("auto", "sqlite"))
monkeypatch.setattr(engine_module, "get_session_factory", lambda: None)
with caplog.at_level(logging.WARNING):
store = login_throttle.resolve_and_install_from_live_config()
assert isinstance(store, MemoryLoginThrottleStore)
assert login_throttle.installed_login_throttle_store() is store
assert any("throttle_storage=auto" in r.message for r in caplog.records)
def test_missing_config_resolves_the_memory_store():
assert isinstance(login_throttle.resolve_login_throttle_store(None, session_factory=None), MemoryLoginThrottleStore)
def test_install_and_reset_hooks():
assert login_throttle.installed_login_throttle_store() is None
store = MemoryLoginThrottleStore()
login_throttle.install_login_throttle_store(store)
assert login_throttle.installed_login_throttle_store() is store
login_throttle.reset_login_throttle_store()
assert login_throttle.installed_login_throttle_store() is None
def test_lazy_resolution_uses_live_config_and_installs_once(monkeypatch, session_factory):
"""A bare app (no Gateway lifespan) resolves on first use and keeps that store."""
from deerflow.config import app_config as app_config_module
from deerflow.persistence import engine as engine_module
monkeypatch.setattr(app_config_module, "get_app_config", lambda: _config("auto", "sqlite"))
monkeypatch.setattr(engine_module, "get_session_factory", lambda: session_factory)
first = login_throttle.resolve_and_install_from_live_config()
assert isinstance(first, SqlLoginThrottleStore)
assert login_throttle.installed_login_throttle_store() is first
# A second resolver racing the first keeps the already installed store.
assert login_throttle.resolve_and_install_from_live_config() is first
def test_lazy_resolution_without_config_file_uses_defaults(monkeypatch):
from deerflow.config import app_config as app_config_module
from deerflow.persistence import engine as engine_module
def _missing():
raise FileNotFoundError("no config.yaml")
monkeypatch.setattr(app_config_module, "get_app_config", _missing)
monkeypatch.setattr(engine_module, "get_session_factory", lambda: None)
assert isinstance(login_throttle.resolve_and_install_from_live_config(), MemoryLoginThrottleStore)
def test_lazy_resolution_propagates_a_malformed_config(monkeypatch):
"""Like the policy read: a broken config.yaml fails loudly rather than silently picking a store."""
from deerflow.config import app_config as app_config_module
def _malformed():
raise ValueError("config validation error")
monkeypatch.setattr(app_config_module, "get_app_config", _malformed)
with pytest.raises(ValueError, match="config validation error"):
login_throttle.resolve_and_install_from_live_config()
assert login_throttle.installed_login_throttle_store() is None

View File

@ -0,0 +1,807 @@
"""Login-throttle store contract: the in-process counter and the shared SQL table.
Both stores must implement the semantics the auth router relied on while the
counter was a module-level dict: a lock starts when the failure count reaches
``max_attempts``; the duration committed at lock time is honored even when the
policy shrinks mid-lock; an active lock follows the live duration (decreases
included); a served sentence is never resurrected by a later raise; a
successful login resets the IP; and the memory store bounds its tracked-IP
set. The SQL store additionally shares that state across every Gateway
replica using one database, which is the whole point of the change (and the
red-on-main reproduction here: two SQL stores over one SQLite file agree on
the lock, two memory stores do not).
"""
from __future__ import annotations
import asyncio
import os
import time
import uuid
from collections.abc import AsyncIterator, Awaitable, Callable
from datetime import UTC, datetime
from pathlib import Path
import pytest
import pytest_asyncio
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql, sqlite
from sqlalchemy.ext.asyncio import AsyncEngine, async_sessionmaker, create_async_engine
from support.postgres import asyncpg_test_url
from deerflow.persistence.base import Base
from deerflow.persistence.login_throttle import (
MAX_TRACKED_IPS,
STALE_COUNTER_SECONDS,
LoginThrottleRecord,
LoginThrottleRow,
LoginThrottleStore,
MemoryLoginThrottleStore,
SqlLoginThrottleStore,
)
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
pytestmark = pytest.mark.asyncio
T0 = 1_700_000_000.0
async def _sqlite_engine(path: Path) -> AsyncEngine:
engine = create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
async with engine.begin() as conn:
await conn.run_sync(Base.metadata.create_all, tables=[LoginThrottleRow.__table__], checkfirst=True)
return engine
def _sql_store(engine: AsyncEngine) -> SqlLoginThrottleStore:
return SqlLoginThrottleStore(async_sessionmaker(engine, expire_on_commit=False))
@pytest_asyncio.fixture(params=["memory", "sql"])
async def store(request, tmp_path) -> AsyncIterator[LoginThrottleStore]:
if request.param == "memory":
yield MemoryLoginThrottleStore()
return
engine = await _sqlite_engine(tmp_path / "throttle.db")
try:
yield _sql_store(engine)
finally:
await engine.dispose()
def _policy(max_attempts: int, lockout_seconds: float) -> Callable[[], Awaitable[tuple[int, float]]]:
"""A ``check`` policy callable for a known policy (the router wraps its config read the same way)."""
async def resolve() -> tuple[int, float]:
return max_attempts, lockout_seconds
return resolve
class _CountingPolicy:
"""Policy callable that counts how often ``check`` resolved it."""
def __init__(self, max_attempts: int, lockout_seconds: float) -> None:
self.calls = 0
self._policy = (max_attempts, lockout_seconds)
async def __call__(self) -> tuple[int, float]:
self.calls += 1
return self._policy
async def _lock(store: LoginThrottleStore, ip: str, *, max_attempts: int = 2, lockout_seconds: float = 60.0, now: float = T0) -> LoginThrottleRecord:
record = None
for _ in range(max_attempts):
record = await store.record_failure(ip, max_attempts=max_attempts, lockout_seconds=lockout_seconds, now=now)
assert record is not None
return record
# ── shared contract ─────────────────────────────────────────────────────────
async def test_clean_ip_has_no_record_and_is_allowed(store):
assert await store.get("192.0.2.1") is None
assert await store.check("192.0.2.1", policy=_policy(5, 300.0), now=T0) == 0.0
async def test_lock_starts_when_failures_reach_max_attempts(store):
ip = "10.0.0.1"
for n in range(1, 5):
record = await store.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0 + n)
assert record == LoginThrottleRecord(fail_count=n, locked_at=0.0, lock_duration=0.0)
assert await store.check(ip, policy=_policy(5, 300.0), now=T0 + n) == 0.0
record = await store.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0 + 5)
assert record == LoginThrottleRecord(fail_count=5, locked_at=T0 + 5, lock_duration=300.0)
assert await store.get(ip) == record
assert await store.check(ip, policy=_policy(5, 300.0), now=T0 + 6) == pytest.approx(299.0)
async def test_reset_clears_the_counter(store):
ip = "10.0.0.2"
for _ in range(4):
await store.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0)
await store.reset(ip)
assert await store.get(ip) is None
assert await store.check(ip, policy=_policy(5, 300.0), now=T0) == 0.0
# Resetting an unknown IP is a no-op.
await store.reset("203.0.113.77")
async def test_expired_lock_is_cleared_on_check(store):
ip = "10.0.0.3"
await _lock(store, ip, lockout_seconds=60.0, now=T0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 59.0) > 0.0
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 61.0) == 0.0
assert await store.get(ip) is None
async def test_raised_threshold_unblocks_and_keeps_the_count(store):
"""Raising max_login_attempts mid-lock immediately unblocks a lower count (#5108)."""
ip = "10.0.0.4"
await _lock(store, ip, max_attempts=2, lockout_seconds=60.0, now=T0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 1) > 0.0
assert await store.check(ip, policy=_policy(5, 60.0), now=T0 + 1) == 0.0
assert (await store.get(ip)).fail_count == 2
async def test_tightened_threshold_preserves_failures_and_locks_on_next(store):
ip = "10.0.0.5"
for _ in range(4):
await store.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0)
# Over the new threshold but never locked under it: allowed once, count kept.
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 1) == 0.0
assert (await store.get(ip)).fail_count == 4
record = await store.record_failure(ip, max_attempts=2, lockout_seconds=60.0, now=T0 + 2)
assert record == LoginThrottleRecord(fail_count=5, locked_at=T0 + 2, lock_duration=60.0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 3) > 0.0
await store.reset(ip)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 3) == 0.0
async def test_lowered_duration_releases_an_active_lock_early(store):
ip = "10.0.0.6"
await _lock(store, ip, lockout_seconds=60.0, now=T0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 2) > 0.0
assert await store.check(ip, policy=_policy(2, 1.0), now=T0 + 2) == 0.0
assert await store.get(ip) is None
async def test_raised_duration_extends_an_active_lock(store):
ip = "10.0.0.7"
await _lock(store, ip, lockout_seconds=1.0, now=T0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 0.5) == pytest.approx(59.5)
# The raise was committed while the lock was active, so it outlives the original 1s.
assert (await store.get(ip)).lock_duration == 60.0
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 2.0) == pytest.approx(58.0)
async def test_served_sentence_is_not_resurrected_by_a_later_raise(store):
ip = "10.0.0.8"
await _lock(store, ip, lockout_seconds=1.0, now=T0)
# No check happened while the 1s sentence ran; raising afterwards must not revive it.
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 2.0) == 0.0
assert await store.get(ip) is None
async def test_lowered_then_raised_duration_is_not_resurrected(store):
ip = "10.0.0.9"
await _lock(store, ip, lockout_seconds=60.0, now=T0)
assert await store.check(ip, policy=_policy(2, 10.0), now=T0 + 6.0) == pytest.approx(4.0)
assert (await store.get(ip)).lock_duration == 10.0
assert await store.check(ip, policy=_policy(2, 30.0), now=T0 + 20.0) == 0.0
assert await store.get(ip) is None
async def test_concurrent_failures_do_not_lose_increments(store):
"""The upsert must be atomic: N racing failures count N, never fewer."""
ip = "10.0.0.10"
await asyncio.gather(*[store.record_failure(ip, max_attempts=100, lockout_seconds=60.0, now=T0 + i) for i in range(25)])
assert (await store.get(ip)).fail_count == 25
async def test_concurrent_checks_of_an_expired_lock_all_resolve_cleanly(store):
ip = "10.0.0.11"
await _lock(store, ip, lockout_seconds=1.0, now=T0)
results = await asyncio.gather(*[store.check(ip, policy=_policy(2, 1.0), now=T0 + 5.0) for _ in range(8)], return_exceptions=True)
assert results == [0.0] * 8, results
assert await store.get(ip) is None
async def test_now_defaults_to_the_wall_clock(store):
ip = "10.0.0.12"
record = await store.record_failure(ip, max_attempts=2, lockout_seconds=60.0)
assert record.fail_count == 1 and record.locked_at == 0.0
record = await store.record_failure(ip, max_attempts=2, lockout_seconds=60.0)
assert record.locked_at > T0 # a real timestamp, after this test was written
assert 0.0 < await store.check(ip, policy=_policy(2, 60.0)) <= 60.0
async def test_failures_during_an_active_lock_do_not_slide_the_sentence(store):
"""A failure recorded while the lock is active keeps the lock's start and committed duration.
Review of #6501: re-stamping ``locked_at = now`` on every failure turned
"N seconds after the lock started" into "N seconds after the last
attempt", so a sustained attacker never served the sentence.
"""
ip = "10.0.0.20"
locked = await _lock(store, ip, max_attempts=2, lockout_seconds=60.0, now=T0)
assert locked == LoginThrottleRecord(fail_count=2, locked_at=T0, lock_duration=60.0)
for n, offset in enumerate((10.0, 20.0, 50.0), start=3):
record = await store.record_failure(ip, max_attempts=2, lockout_seconds=60.0, now=T0 + offset)
assert record == LoginThrottleRecord(fail_count=n, locked_at=T0, lock_duration=60.0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 59.0) == pytest.approx(1.0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 61.0) == 0.0 # expires at the original time
assert await store.get(ip) is None
async def test_failure_during_an_active_lock_keeps_the_committed_duration_not_the_live_one(store):
"""The duration a ``check`` committed (here a lowered one) is what a later failure preserves."""
ip = "10.0.0.21"
await _lock(store, ip, max_attempts=2, lockout_seconds=60.0, now=T0)
assert await store.check(ip, policy=_policy(2, 10.0), now=T0 + 2.0) == pytest.approx(8.0) # commits 10s
record = await store.record_failure(ip, max_attempts=2, lockout_seconds=300.0, now=T0 + 5.0)
assert record == LoginThrottleRecord(fail_count=3, locked_at=T0, lock_duration=10.0)
async def test_failure_after_a_served_lock_starts_a_fresh_sentence(store):
"""A lock whose sentence elapsed (no check cleared it yet) is restarted by the next failure."""
ip = "10.0.0.22"
await _lock(store, ip, max_attempts=2, lockout_seconds=1.0, now=T0)
record = await store.record_failure(ip, max_attempts=2, lockout_seconds=60.0, now=T0 + 5.0)
assert record == LoginThrottleRecord(fail_count=3, locked_at=T0 + 5.0, lock_duration=60.0)
assert await store.check(ip, policy=_policy(2, 60.0), now=T0 + 6.0) == pytest.approx(59.0)
async def test_raised_threshold_clears_the_lock_below_the_new_max_and_restarts_it_when_reached(store):
"""Raising max_login_attempts mid-lock: a failure below the new threshold leaves the IP counting;
the failure that reaches the new threshold starts a fresh lock at that moment."""
ip = "10.0.0.23"
await _lock(store, ip, max_attempts=2, lockout_seconds=60.0, now=T0) # (2, T0, 60)
record = await store.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0 + 1.0)
assert record == LoginThrottleRecord(fail_count=3, locked_at=0.0, lock_duration=0.0) # counting again under the raised max
assert await store.check(ip, policy=_policy(5, 60.0), now=T0 + 1.5) == 0.0
record = await store.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0 + 2.0)
assert record == LoginThrottleRecord(fail_count=4, locked_at=0.0, lock_duration=0.0)
record = await store.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0 + 3.0)
assert record == LoginThrottleRecord(fail_count=5, locked_at=T0 + 3.0, lock_duration=60.0) # fresh lock, not the stale T0
assert await store.check(ip, policy=_policy(5, 60.0), now=T0 + 4.0) == pytest.approx(59.0)
async def test_check_never_resolves_the_policy_for_a_clean_ip(store):
"""The clean-IP skip survives the lazy policy: no record, no config read."""
policy = _CountingPolicy(5, 300.0)
assert await store.check("192.0.2.200", policy=policy, now=T0) == 0.0
assert policy.calls == 0
async def test_check_resolves_the_policy_exactly_once_for_a_recorded_ip(store):
ip = "10.0.0.30"
await store.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0)
policy = _CountingPolicy(5, 300.0)
assert await store.check(ip, policy=policy, now=T0 + 1) == 0.0
assert policy.calls == 1
await _lock(store, ip, max_attempts=2, lockout_seconds=60.0, now=T0 + 2)
policy = _CountingPolicy(2, 60.0)
assert await store.check(ip, policy=policy, now=T0 + 3) > 0.0
assert policy.calls == 1
# ── memory store specifics ──────────────────────────────────────────────────
async def test_memory_eviction_expires_by_stored_sentence_not_current_threshold():
"""The capacity sweep expires records by their own committed sentence.
A record locked under an old, lower threshold has a count below the live
max; gating expiry on ``count >= max`` would keep that served record
resident while the capacity fallback evicts live counters first (they sort
earliest), handing an active offender a fresh budget.
"""
store = MemoryLoginThrottleStore(max_tracked_ips=2)
# Seed the lock first: the capacity sweep runs inside record_failure once
# the set is full, and this test wants the sweep to run on the fresh IP.
await _lock(store, "expired-lock", max_attempts=2, lockout_seconds=1.0, now=10.0) # served at 11.0
await store.record_failure("live-counter", max_attempts=3, lockout_seconds=60.0, now=90.0)
await store.record_failure("fresh-ip", max_attempts=3, lockout_seconds=60.0, now=100.0) # hits the sweep
assert await store.get("expired-lock") is None
assert await store.get("live-counter") == LoginThrottleRecord(fail_count=1, locked_at=0.0, lock_duration=0.0)
assert (await store.get("fresh-ip")).fail_count == 1
async def test_memory_eviction_drops_the_cheapest_half_when_nothing_expired():
store = MemoryLoginThrottleStore(max_tracked_ips=4)
await _lock(store, "lock-early", lockout_seconds=60.0, now=T0)
await _lock(store, "lock-late", lockout_seconds=600.0, now=T0)
await store.record_failure("counter-a", max_attempts=5, lockout_seconds=60.0, now=T0)
await store.record_failure("counter-b", max_attempts=5, lockout_seconds=60.0, now=T0)
await store.record_failure("fresh-ip", max_attempts=5, lockout_seconds=60.0, now=T0 + 1)
# Never-locked counters sort first (expiry key 0.0) and go; locks survive.
assert await store.get("counter-a") is None
assert await store.get("counter-b") is None
assert (await store.get("lock-early")).locked_at == T0
assert (await store.get("lock-late")).locked_at == T0
assert (await store.get("fresh-ip")).fail_count == 1
async def test_memory_store_default_capacity_matches_the_historical_constant():
assert MAX_TRACKED_IPS == 10000
assert MemoryLoginThrottleStore()._max_tracked_ips == MAX_TRACKED_IPS
# ── SQL store specifics ─────────────────────────────────────────────────────
class _CountingSession:
"""Session proxy counting ``commit()`` calls."""
def __init__(self, inner, counters: dict) -> None:
self._inner = inner
self._counters = counters
async def __aenter__(self):
await self._inner.__aenter__()
return self
async def __aexit__(self, *exc):
return await self._inner.__aexit__(*exc)
async def commit(self) -> None:
self._counters["commits"] += 1
await self._inner.commit()
def __getattr__(self, name):
return getattr(self._inner, name)
def _counting_store(engine: AsyncEngine) -> tuple[SqlLoginThrottleStore, dict]:
"""A SQL store whose session factory counts sessions opened and commits issued."""
real = async_sessionmaker(engine, expire_on_commit=False)
counters = {"sessions": 0, "commits": 0}
def factory():
counters["sessions"] += 1
return _CountingSession(real(), counters)
return SqlLoginThrottleStore(factory), counters
async def test_sql_check_opens_exactly_one_session_per_call(tmp_path):
"""A recorded IP no longer costs two pool checkouts (probe + decision) on the unauthenticated login path."""
engine = await _sqlite_engine(tmp_path / "sessions.db")
try:
store, counters = _counting_store(engine)
ip = "10.0.0.31"
assert await store.check(ip, policy=_policy(5, 300.0), now=T0) == 0.0 # clean IP
assert counters["sessions"] == 1
await _lock(store, ip, max_attempts=2, lockout_seconds=60.0, now=T0)
counters["sessions"] = 0
assert await store.check(ip, policy=_policy(2, 300.0), now=T0 + 1) > 0.0 # recorded IP, duration committed
assert counters["sessions"] == 1
counters["sessions"] = 0
assert await store.check(ip, policy=_policy(2, 300.0), now=T0 + 2) > 0.0 # recorded IP, nothing to write
assert counters["sessions"] == 1
finally:
await engine.dispose()
async def test_sql_record_failure_commits_one_transaction(tmp_path):
"""Housekeeping rides in the upsert's transaction: one commit per failed login, not two."""
engine = await _sqlite_engine(tmp_path / "commits.db")
try:
store, counters = _counting_store(engine)
await _lock(store, "other-served-lock", lockout_seconds=1.0, now=T0 - 10.0)
counters["commits"] = 0
await store.record_failure("10.0.0.32", max_attempts=5, lockout_seconds=300.0, now=T0)
assert counters["commits"] == 1
assert await store.get("other-served-lock") is None # the sweep still ran, inside that transaction
finally:
await engine.dispose()
async def test_sql_record_failure_sweeps_expired_locks_and_stale_counters(tmp_path):
engine = await _sqlite_engine(tmp_path / "sweep.db")
try:
store = _sql_store(engine)
await _lock(store, "expired-lock", lockout_seconds=1.0, now=T0) # served at T0 + 1
await store.record_failure("stale-counter", max_attempts=5, lockout_seconds=60.0, now=T0 - STALE_COUNTER_SECONDS - 1)
await store.record_failure("live-counter", max_attempts=5, lockout_seconds=60.0, now=T0)
await _lock(store, "live-lock", lockout_seconds=600.0, now=T0)
await store.record_failure("fresh-ip", max_attempts=5, lockout_seconds=60.0, now=T0 + 5)
async with engine.connect() as conn:
ips = set((await conn.execute(sa.select(LoginThrottleRow.ip))).scalars())
assert ips == {"live-counter", "live-lock", "fresh-ip"}
finally:
await engine.dispose()
async def test_sql_sweep_leaves_the_row_being_recorded_to_the_upsert(tmp_path):
"""The sweep excludes the IP whose failure is being recorded, so the upsert applies the shared rule.
A stale counter keeps counting (the memory store never expires counters on
its own either) instead of collapsing into a fresh ``(1, NULL, NULL)``
row, while other expired rows are still swept by the same call. The
served-lock half of the rule is pinned for both stores by
``test_failure_after_a_served_lock_starts_a_fresh_sentence``.
"""
engine = await _sqlite_engine(tmp_path / "keep.db")
try:
store = _sql_store(engine)
stale_now = T0 - STALE_COUNTER_SECONDS - 1
for _ in range(4):
await store.record_failure("stale-counter", max_attempts=5, lockout_seconds=60.0, now=stale_now)
await _lock(store, "other-served-lock", lockout_seconds=1.0, now=T0 - 10.0)
record = await store.record_failure("stale-counter", max_attempts=5, lockout_seconds=60.0, now=T0)
assert record == LoginThrottleRecord(fail_count=5, locked_at=T0, lock_duration=60.0) # 4 + 1 reaches the threshold
assert await store.get("other-served-lock") is None # swept by that same call
finally:
await engine.dispose()
async def test_sql_stores_truncate_overlong_keys_consistently(tmp_path, caplog):
"""A trusted proxy can forward an arbitrarily long X-Real-IP; the row key is bounded and the truncation is logged."""
import logging
engine = await _sqlite_engine(tmp_path / "long.db")
try:
store = _sql_store(engine)
key = "x" * 400
with caplog.at_level(logging.WARNING):
await _lock(store, key, lockout_seconds=60.0, now=T0)
truncation_warnings = [r.message for r in caplog.records if "truncat" in r.message.lower()]
assert truncation_warnings, caplog.records
assert "400" in truncation_warnings[0] and str(LoginThrottleRow.ip.type.length) in truncation_warnings[0]
assert key not in truncation_warnings[0] # never log the (possibly token-like) key itself
assert await store.check(key, policy=_policy(2, 60.0), now=T0 + 1) > 0.0
async with engine.connect() as conn:
stored = (await conn.execute(sa.select(LoginThrottleRow.ip))).scalar_one()
assert len(stored) == LoginThrottleRow.ip.type.length
await store.reset(key)
assert await store.get(key) is None
finally:
await engine.dispose()
async def test_two_sql_stores_over_one_database_share_the_lock(tmp_path):
"""Multi-replica reproduction: replica B sees the failures replica A counted.
Two engines over one SQLite file stand in for two Gateway Pods sharing one
database. Before this change each Pod kept its own counter, so an attacker
behind a load balancer got N x max_login_attempts guesses and a lockout on
one Pod was invisible to the others.
"""
path = tmp_path / "shared.db"
engine_a = await _sqlite_engine(path)
engine_b = create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
try:
replica_a, replica_b = _sql_store(engine_a), _sql_store(engine_b)
ip = "198.51.100.7"
for _ in range(3):
await replica_a.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0)
for _ in range(2):
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0 + 1)
assert (await replica_a.get(ip)).fail_count == 5
assert await replica_a.check(ip, policy=_policy(5, 300.0), now=T0 + 2) > 0.0
assert await replica_b.check(ip, policy=_policy(5, 300.0), now=T0 + 2) > 0.0
# A successful login on one replica releases the IP everywhere.
await replica_b.reset(ip)
assert await replica_a.check(ip, policy=_policy(5, 300.0), now=T0 + 3) == 0.0
finally:
await engine_a.dispose()
await engine_b.dispose()
async def test_failure_committing_after_a_reset_counts_as_a_fresh_first_failure(tmp_path):
"""A reset and a peer's failure serialize at the database; whichever commits last defines the state.
A failure that commits after the successful login's reset leaves
``fail_count = 1`` — one genuine failed attempt after the history was
cleared, not a resurrected counter or lock. (A failure that commits
before the reset is cleared by it.) The memory store has the same ordering
semantics; "a successful login clears the IP everywhere" holds either way.
"""
path = tmp_path / "reset-race.db"
engine_a = await _sqlite_engine(path)
engine_b = create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
try:
replica_a, replica_b = _sql_store(engine_a), _sql_store(engine_b)
ip = "198.51.100.9"
await _lock(replica_a, ip, max_attempts=5, lockout_seconds=300.0, now=T0)
await replica_a.reset(ip) # successful login on replica A
record = await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0 + 1.0) # peer's wrong password lands after
assert record == LoginThrottleRecord(fail_count=1, locked_at=0.0, lock_duration=0.0)
assert await replica_a.get(ip) == record
assert await replica_a.check(ip, policy=_policy(5, 300.0), now=T0 + 2.0) == 0.0
finally:
await engine_a.dispose()
await engine_b.dispose()
async def test_two_memory_stores_do_not_share_the_lock():
"""Documents the per-process behavior the SQL store exists to replace."""
replica_a, replica_b = MemoryLoginThrottleStore(), MemoryLoginThrottleStore()
ip = "198.51.100.8"
for _ in range(5):
await replica_a.record_failure(ip, max_attempts=5, lockout_seconds=300.0, now=T0)
assert await replica_a.check(ip, policy=_policy(5, 300.0), now=T0 + 1) > 0.0
assert await replica_b.check(ip, policy=_policy(5, 300.0), now=T0 + 1) == 0.0
assert await replica_b.get(ip) is None
# ── sweep versus a concurrent lock extension ─────────────────────────────────
def _outer_where_without_candidate_subquery(sql: str) -> str:
"""The DELETE's own WHERE clause with the ``IN (SELECT ...)`` candidate subquery replaced by ``(...)``."""
outer = sql[sql.index("WHERE") :]
start = outer.index("IN (") + len("IN ")
depth, end = 0, start
for end in range(start, len(outer)):
depth += {"(": 1, ")": -1}.get(outer[end], 0)
if depth == 0:
break
return outer[:start] + "(...)" + outer[end + 1 :]
@pytest.mark.parametrize("dialect", [postgresql.dialect(), sqlite.dialect()], ids=["postgresql", "sqlite"])
async def test_sweep_delete_repeats_the_expiry_predicate_on_the_deleted_row(dialect):
"""The sweep's DELETE must re-check expiry on the row it deletes, not only IP membership.
PostgreSQL READ COMMITTED re-evaluates only the DELETE's *own* WHERE on a
row it had to wait for; the ``IN (SELECT ... LIMIT n)`` candidate subquery is
bound to the statement snapshot. With ``ip IN (...)`` alone, a lock that a
concurrent ``check`` extended between the snapshot and the row lock would
still be deleted (review of #6501). Compiling the statement pins the guard
without a live PostgreSQL.
"""
sql = str(SqlLoginThrottleStore.sweep_statement(T0).compile(dialect=dialect))
assert sql.startswith("DELETE FROM login_throttle")
assert "LIMIT" in sql # bounded candidate selection is kept
outer = _outer_where_without_candidate_subquery(sql)
assert "login_throttle.ip IN" in outer
assert "login_throttle.locked_at IS NOT NULL" in outer
assert "login_throttle.locked_at + login_throttle.lock_duration_seconds <=" in outer
assert "login_throttle.locked_at IS NULL" in outer
assert "login_throttle.updated_at <=" in outer
# The row being recorded is excluded on the target too, not only among the candidates.
with_keep = str(SqlLoginThrottleStore.sweep_statement(T0, keep="203.0.113.1").compile(dialect=dialect))
assert "login_throttle.ip !=" in _outer_where_without_candidate_subquery(with_keep)
async def _wait_until_a_session_waits_on_a_lock(engine: AsyncEngine, *, timeout: float = 15.0) -> None:
"""Block until another backend of this database is waiting on a lock (pg_stat_activity)."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
async with engine.connect() as conn:
waiting = await conn.scalar(sa.text("SELECT count(*) FROM pg_stat_activity WHERE datname = current_database() AND wait_event_type = 'Lock' AND pid <> pg_backend_pid()"))
if waiting:
return
await asyncio.sleep(0.05)
raise AssertionError("the sweeping replica never blocked on the extending replica's row lock")
async def test_postgres_sweep_keeps_a_lock_extended_by_a_concurrent_check():
"""Reproduces the #6501 review interleaving on a real PostgreSQL.
Replica A evaluates an expiring lock under a raised ``lockout_seconds``
and extends it (the UPDATE ``check`` issues) without committing yet. An
unrelated failed login on replica B sweeps: its snapshot still lists the IP
as expired, its DELETE waits on A's row lock, and once A commits the
re-evaluated row must survive — otherwise the next attempt from that IP
gets a clean record and reaches password verification.
"""
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (PostgreSQL READ COMMITTED sweep/update interleaving)")
schema = f"login_throttle_sweep_{uuid.uuid4().hex}"
engines = [create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema)) for _ in range(2)]
engine_a, engine_b = engines
try:
async with engine_a.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await conn.run_sync(Base.metadata.create_all, tables=[LoginThrottleRow.__table__])
session_factory_a = async_sessionmaker(engine_a, expire_on_commit=False)
replica_a, replica_b = _sql_store(engine_a), _sql_store(engine_b)
ip = "198.51.100.42"
await _lock(replica_a, ip, lockout_seconds=1.0, now=T0) # sentence ends at T0 + 1
async with session_factory_a() as extending:
# Replica A at T0 + 0.5 under lockout_seconds=60: the lock is still
# active, so check() commits the longer sentence — held open here.
await extending.execute(sa.update(LoginThrottleRow).where(LoginThrottleRow.ip == ip).values(lock_duration_seconds=60.0, updated_at=datetime.fromtimestamp(T0 + 0.5, UTC)))
# Replica B at T0 + 2: an unrelated failure sweeps; its snapshot sees
# the IP as expired and the DELETE blocks on A's row lock.
sweeping = asyncio.create_task(replica_b.record_failure("203.0.113.5", max_attempts=5, lockout_seconds=60.0, now=T0 + 2.0))
await _wait_until_a_session_waits_on_a_lock(engine_a)
await extending.commit()
await sweeping
assert await replica_a.get(ip) == LoginThrottleRecord(fail_count=2, locked_at=T0, lock_duration=60.0)
assert await replica_b.check(ip, policy=_policy(2, 60.0), now=T0 + 2.0) == pytest.approx(58.0)
assert (await replica_b.get("203.0.113.5")).fail_count == 1 # the sweeping failure was still counted
finally:
async with engine_a.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
for engine in engines:
await engine.dispose()
# ── check(): compare-and-set misses between the snapshot read and the write ──
class _RacingSession:
"""Session proxy that lets a peer act right after ``check`` ends its read transaction.
``check`` reads its snapshot and ends that transaction with ``rollback()``
before writing. Running the peer mutation at that point — on another
engine over the same database — is exactly the reviewer's interleaving
(#6501): the compare-and-set that follows sees a row that changed since
the snapshot. The hook fires after the first read only unless
``every_time`` is set, which forces a miss on every attempt.
"""
def __init__(self, inner, on_read_end: Callable[[], Awaitable[None]], state, *, every_time: bool) -> None:
self._inner = inner
self._on_read_end = on_read_end
self._state = state
self._every_time = every_time
async def __aenter__(self):
await self._inner.__aenter__()
return self
async def __aexit__(self, *exc):
return await self._inner.__aexit__(*exc)
async def rollback(self) -> None:
await self._inner.rollback()
if self._every_time or self._state["fired"] == 0:
self._state["fired"] += 1
await self._on_read_end()
def __getattr__(self, name):
return getattr(self._inner, name)
def _racing_store(engine: AsyncEngine, on_read_end: Callable[[], Awaitable[None]], *, every_time: bool = False) -> tuple[SqlLoginThrottleStore, dict]:
real = async_sessionmaker(engine, expire_on_commit=False)
state = {"fired": 0}
return SqlLoginThrottleStore(lambda: _RacingSession(real(), on_read_end, state, every_time=every_time)), state
async def _two_replicas(path: Path):
engine_a = await _sqlite_engine(path)
engine_b = create_async_engine(f"sqlite+aiosqlite:///{path.as_posix()}")
return engine_a, engine_b
async def test_check_recommits_a_raised_duration_after_a_concurrent_increment(tmp_path):
"""Reviewer's interleaving: the extension must be committed, not merely reported.
Lock (5, T, 1s); operator raises lockout_seconds to 60. Replica A reads
the row at T+0.5; before A's UPDATE an admitted wrong password on replica
B increments the count (keeping the active lock's 1s duration). A's
compare-and-set misses on ``fail_count``; it must re-read and commit the
60s sentence on the fresh row, so a check at T+2 still reports locked.
"""
engine_a, engine_b = await _two_replicas(tmp_path / "raise.db")
try:
replica_b = _sql_store(engine_b)
ip = "198.51.100.50"
for _ in range(5):
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=1.0, now=T0)
async def peer_failure() -> None:
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=1.0, now=T0 + 0.5)
replica_a, state = _racing_store(engine_a, peer_failure)
policy = _CountingPolicy(5, 60.0)
assert await replica_a.check(ip, policy=policy, now=T0 + 0.5) == pytest.approx(59.5)
assert state["fired"] == 1
assert policy.calls == 1 # resolved once, even though the compare-and-set retried
assert await replica_b.get(ip) == LoginThrottleRecord(fail_count=6, locked_at=T0, lock_duration=60.0) # increment kept, extension committed
assert await replica_b.check(ip, policy=_policy(5, 60.0), now=T0 + 2.0) == pytest.approx(58.0)
finally:
await engine_a.dispose()
await engine_b.dispose()
async def test_check_recommits_a_lowered_duration_after_a_concurrent_increment(tmp_path):
engine_a, engine_b = await _two_replicas(tmp_path / "lower.db")
try:
replica_b = _sql_store(engine_b)
ip = "198.51.100.51"
for _ in range(5):
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0)
async def peer_failure() -> None:
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0 + 6.0)
replica_a, state = _racing_store(engine_a, peer_failure)
assert await replica_a.check(ip, policy=_policy(5, 10.0), now=T0 + 6.0) == pytest.approx(4.0)
assert state["fired"] == 1
assert await replica_b.get(ip) == LoginThrottleRecord(fail_count=6, locked_at=T0, lock_duration=10.0) # the decrease is committed
assert await replica_b.check(ip, policy=_policy(5, 30.0), now=T0 + 12.0) == 0.0 # 10s sentence served; not resurrected
finally:
await engine_a.dispose()
await engine_b.dispose()
async def test_check_releases_early_on_the_fresh_row_after_a_concurrent_increment(tmp_path):
engine_a, engine_b = await _two_replicas(tmp_path / "release.db")
try:
replica_b = _sql_store(engine_b)
ip = "198.51.100.52"
for _ in range(5):
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0)
async def peer_failure() -> None:
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=60.0, now=T0 + 20.0)
replica_a, state = _racing_store(engine_a, peer_failure)
# Lowered to 10s and 20s have passed: release early — on the row as it is now.
assert await replica_a.check(ip, policy=_policy(5, 10.0), now=T0 + 20.0) == 0.0
assert state["fired"] == 1
assert await replica_b.get(ip) is None
finally:
await engine_a.dispose()
await engine_b.dispose()
async def test_check_allows_when_a_reset_lands_between_read_and_write(tmp_path):
"""A successful login on a peer clears the row mid-check: allowed, and nothing is recreated."""
engine_a, engine_b = await _two_replicas(tmp_path / "reset.db")
try:
replica_b = _sql_store(engine_b)
ip = "198.51.100.53"
for _ in range(5):
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=1.0, now=T0)
async def peer_success() -> None:
await replica_b.reset(ip)
replica_a, state = _racing_store(engine_a, peer_success)
assert await replica_a.check(ip, policy=_policy(5, 60.0), now=T0 + 0.5) == 0.0
assert state["fired"] == 1
assert await replica_b.get(ip) is None
finally:
await engine_a.dispose()
await engine_b.dispose()
async def test_check_fails_closed_when_the_compare_and_set_keeps_missing(tmp_path, caplog):
"""Sustained contention exhausts the bounded retries: report the last snapshot under the live policy, write nothing."""
import logging
from deerflow.persistence.login_throttle.sql import CHECK_CAS_ATTEMPTS
engine_a, engine_b = await _two_replicas(tmp_path / "exhaust.db")
try:
replica_b = _sql_store(engine_b)
ip = "198.51.100.54"
for _ in range(5):
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=1.0, now=T0)
async def peer_failure() -> None:
await replica_b.record_failure(ip, max_attempts=5, lockout_seconds=1.0, now=T0 + 0.5)
replica_a, state = _racing_store(engine_a, peer_failure, every_time=True)
with caplog.at_level(logging.DEBUG, logger="deerflow.persistence.login_throttle.sql"):
remaining = await replica_a.check(ip, policy=_policy(5, 60.0), now=T0 + 0.5)
assert remaining == pytest.approx(59.5) # locked snapshot under the live 60s policy: fail closed
# One probe read before the policy is resolved, then one read per compare-and-set attempt.
assert state["fired"] == 1 + CHECK_CAS_ATTEMPTS
# Nothing was committed by the losing checks; every peer increment survived.
assert await replica_b.get(ip) == LoginThrottleRecord(fail_count=5 + 1 + CHECK_CAS_ATTEMPTS, locked_at=T0, lock_duration=1.0)
assert any("compare-and-set" in r.message for r in caplog.records if r.levelno == logging.DEBUG)
finally:
await engine_a.dispose()
await engine_b.dispose()

View File

@ -48,7 +48,7 @@ def _seed_database_that_skipped_0023(db_path) -> None:
# change_seq, so it goes before the column.
conn.execute(sa.text("DROP INDEX IF EXISTS ix_runs_thread_change_seq"))
conn.execute(sa.text("ALTER TABLE runs DROP COLUMN origin_kind"))
for table in ("thread_read_markers", "thread_read_versions", "scheduled_task_events"):
for table in ("login_throttle", "thread_read_markers", "thread_read_versions", "scheduled_task_events"):
conn.execute(sa.text(f"DROP TABLE IF EXISTS {table}"))
# Remove everything 0023_run_change_seq owns.
conn.execute(sa.text("DROP INDEX IF EXISTS ix_runs_change_seq"))

View File

@ -23,7 +23,7 @@ OUTBOX = "0027_notification_deliveries"
PARKED = "0028_parked_attempts"
SCHEDULER = "0029_scheduler_agent_tasks"
CLAIM_TOKENS = "0030_notification_claim_tokens"
HEAD = "0034_run_event_seq_watermark"
HEAD = "0035_login_throttle"
PREVIOUS = "0026_mcp_task_lease_tokens"
TABLE = "notification_deliveries"

View File

@ -23,7 +23,7 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
from deerflow.persistence.scheduled_tasks import ScheduledTaskRepository
REVISION = "0029_scheduler_agent_tasks"
CURRENT_HEAD = "0034_run_event_seq_watermark"
CURRENT_HEAD = "0035_login_throttle"
NEXT = "0030_notification_claim_tokens"
PREVIOUS = "0028_parked_attempts"
TASK_FIELDS = {"origin_thread_id", "goal_objective", "max_runs", "end_at", "standing_notes"}

View File

@ -18,7 +18,8 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0032_activity_and_task_events"
NEXT = "0033_batch_result_artifact"
CURRENT_HEAD = "0034_run_event_seq_watermark"
AFTER_NEXT = "0034_run_event_seq_watermark"
CURRENT_HEAD = "0035_login_throttle"
PREVIOUS = "0031_scheduled_streak_boundary"
TABLES = {"thread_read_markers", "thread_read_versions", "scheduled_task_events"}
INDEXES = {
@ -34,7 +35,8 @@ async def test_0032_remains_in_the_single_migration_chain():
assert script.get_heads() == [CURRENT_HEAD]
assert script.get_revision(REVISION).down_revision == PREVIOUS
assert script.get_revision(NEXT).down_revision == REVISION
assert script.get_revision(CURRENT_HEAD).down_revision == NEXT
assert script.get_revision(AFTER_NEXT).down_revision == NEXT
assert script.get_revision(CURRENT_HEAD).down_revision == AFTER_NEXT
# alembic_version.version_num is VARCHAR(32).
assert len(REVISION) <= 32

View File

@ -19,14 +19,16 @@ from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0034_run_event_seq_watermark"
PREVIOUS = "0033_batch_result_artifact"
CURRENT_HEAD = "0035_login_throttle"
TABLE = "run_event_thread_seq"
pytestmark = pytest.mark.asyncio
async def test_0034_is_the_single_migration_head():
async def test_0034_remains_in_the_single_migration_chain():
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
assert script.get_heads() == [REVISION]
assert script.get_heads() == [CURRENT_HEAD]
assert script.get_revision(REVISION).down_revision == PREVIOUS
assert script.get_revision(CURRENT_HEAD).down_revision == REVISION
assert len(REVISION) <= 32

View File

@ -0,0 +1,182 @@
"""Migration 0035: the shared ``login_throttle`` table for cross-replica lockouts."""
from __future__ import annotations
import asyncio
import os
import uuid
import pytest
import sqlalchemy as sa
from alembic import command
from alembic.autogenerate import compare_metadata
from alembic.migration import MigrationContext
from alembic.script import ScriptDirectory
from sqlalchemy.ext.asyncio import create_async_engine
from support.postgres import asyncpg_test_url
from deerflow.persistence import bootstrap
from deerflow.persistence.base import Base
from deerflow.persistence.login_throttle import LoginThrottleRow
from deerflow.persistence.postgres_schema import build_asyncpg_connect_args
REVISION = "0035_login_throttle"
PREVIOUS = "0034_run_event_seq_watermark"
TABLE = "login_throttle"
COLUMNS = {"ip", "fail_count", "locked_at", "lock_duration_seconds", "updated_at"}
INDEX = "ix_login_throttle_updated_at"
pytestmark = pytest.mark.asyncio
async def test_0035_is_the_single_head_and_chains_after_0034():
script = ScriptDirectory(str(bootstrap._MIGRATIONS_DIR))
assert script.get_heads() == [REVISION]
assert script.get_revision(REVISION).down_revision == PREVIOUS
# alembic_version.version_num is VARCHAR(32).
assert len(REVISION) <= 32
def _engine(tmp_path, backend):
if backend == "sqlite":
return create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'migration.db'}"), None
uri = os.environ.get("TEST_POSTGRES_URI")
if not uri:
pytest.skip("requires TEST_POSTGRES_URI (real Postgres migration 0035)")
schema = f"login_throttle_{uuid.uuid4().hex}"
return create_async_engine(asyncpg_test_url(uri), connect_args=build_asyncpg_connect_args(schema)), schema
async def _shape(engine) -> dict:
async with engine.connect() as conn:
def read(sync):
inspector = sa.inspect(sync)
tables = set(inspector.get_table_names())
if TABLE not in tables:
return {"tables": tables}
return {
"tables": tables,
"columns": {col["name"]: col for col in inspector.get_columns(TABLE)},
"pk": inspector.get_pk_constraint(TABLE)["constrained_columns"],
"indexes": {index["name"]: index["column_names"] for index in inspector.get_indexes(TABLE)},
}
return await conn.run_sync(read)
async def _orm_diff(engine) -> list:
"""Alembic's own drift check, restricted to the table this revision owns."""
def only_login_throttle(obj, name, type_, reflected, compare_to):
if type_ == "table":
return name == TABLE
return getattr(getattr(obj, "table", None), "name", TABLE) == TABLE
async with engine.connect() as conn:
def diff(sync):
context = MigrationContext.configure(sync, opts={"include_object": only_login_throttle, "compare_type": True, "compare_server_default": True})
return compare_metadata(context, Base.metadata)
return await conn.run_sync(diff)
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_0035_creates_the_table_matching_the_orm_and_downgrades_cleanly(tmp_path, backend):
engine, schema = _engine(tmp_path, backend)
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
try:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
assert TABLE not in (await _shape(engine))["tables"]
await asyncio.to_thread(command.upgrade, cfg, REVISION)
shape = await _shape(engine)
assert set(shape["columns"]) == COLUMNS
assert shape["pk"] == ["ip"]
assert shape["columns"]["fail_count"]["nullable"] is False
assert shape["columns"]["locked_at"]["nullable"] is True
assert shape["columns"]["lock_duration_seconds"]["nullable"] is True
assert shape["columns"]["updated_at"]["nullable"] is False
assert shape["indexes"][INDEX] == ["updated_at"] # serves the sweep's stale-counter predicate
# ``make migrate-rev`` would propose nothing: the revision matches the ORM model.
assert await _orm_diff(engine) == []
async with engine.begin() as conn:
await conn.execute(sa.insert(LoginThrottleRow).values(ip="198.51.100.1", fail_count=5, locked_at=1_700_000_000.5, lock_duration_seconds=300.0, updated_at=sa.func.now()))
with pytest.raises(sa.exc.IntegrityError):
async with engine.begin() as conn:
await conn.execute(sa.insert(LoginThrottleRow).values(ip="198.51.100.1", fail_count=1, updated_at=sa.func.now()))
async with engine.connect() as conn:
row = (await conn.execute(sa.text(f"SELECT fail_count, locked_at, lock_duration_seconds FROM {TABLE}"))).one()
assert tuple(row) == (5, 1_700_000_000.5, 300.0)
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
shape = await _shape(engine)
assert TABLE not in shape["tables"]
async with engine.connect() as conn:
# The index goes with the table on downgrade (named, so a leftover would be visible).
leftover = await conn.run_sync(lambda sync: [name for table in sa.inspect(sync).get_table_names() for name in {index["name"] for index in sa.inspect(sync).get_indexes(table)} if name == INDEX])
assert leftover == []
# Downgrade then upgrade again is clean.
await asyncio.to_thread(command.upgrade, cfg, REVISION)
shape = await _shape(engine)
assert TABLE in shape["tables"]
assert INDEX in shape["indexes"]
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
@pytest.mark.parametrize("backend", ["sqlite", "postgres"])
async def test_0035_completes_a_partially_applied_upgrade(tmp_path, backend):
"""The table already exists (an interrupted earlier attempt): upgrade still lands."""
engine, schema = _engine(tmp_path, backend)
cfg = bootstrap._get_alembic_config(engine, postgres_schema=schema or "")
try:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'CREATE SCHEMA "{schema}"'))
await asyncio.to_thread(command.upgrade, cfg, PREVIOUS)
async with engine.begin() as conn:
# The table exists but its index was never built (an interrupted earlier attempt).
await conn.run_sync(Base.metadata.create_all, tables=[LoginThrottleRow.__table__])
await conn.execute(sa.text(f"DROP INDEX {INDEX}"))
await conn.execute(sa.insert(LoginThrottleRow).values(ip="203.0.113.9", fail_count=2, updated_at=sa.func.now()))
await asyncio.to_thread(command.upgrade, cfg, REVISION)
assert INDEX in (await _shape(engine))["indexes"] # the partial upgrade is completed
async with engine.connect() as conn:
assert (await conn.execute(sa.text(f"SELECT fail_count FROM {TABLE} WHERE ip='203.0.113.9'"))).scalar_one() == 2
assert (await conn.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == REVISION
# A downgrade after someone already dropped the table still completes.
async with engine.begin() as conn:
await conn.execute(sa.text(f"DROP TABLE {TABLE}"))
await asyncio.to_thread(command.downgrade, cfg, PREVIOUS)
async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == PREVIOUS
finally:
if schema:
async with engine.begin() as conn:
await conn.execute(sa.text(f'DROP SCHEMA IF EXISTS "{schema}" CASCADE'))
await engine.dispose()
async def test_bootstrap_provisions_the_table_on_an_empty_database_and_is_idempotent(tmp_path):
engine = create_async_engine(f"sqlite+aiosqlite:///{tmp_path / 'bootstrap.db'}")
try:
await bootstrap.bootstrap_schema(engine, backend="sqlite")
await bootstrap.bootstrap_schema(engine, backend="sqlite")
shape = await _shape(engine)
assert set(shape["columns"]) == COLUMNS
assert INDEX in shape["indexes"]
async with engine.connect() as conn:
assert (await conn.execute(sa.text("SELECT version_num FROM alembic_version"))).scalar_one() == REVISION
finally:
await engine.dispose()

View File

@ -18,7 +18,7 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from fastapi import FastAPI
from app.gateway.deps import _enforce_postgres_for_multi_worker, _validate_agent_storage, _validate_memory_retrieval_index, langgraph_runtime
from app.gateway.deps import _enforce_postgres_for_multi_worker, _validate_agent_storage, _validate_login_throttle_storage, _validate_memory_retrieval_index, langgraph_runtime
from app.gateway.routers.browser import _browser_tools_enabled
from deerflow.config.database_config import DatabaseConfig
from deerflow.config.deployment_config import MULTI_INSTANCE_ENV_VAR, DeploymentConfig, multi_instance_declaration
@ -710,6 +710,60 @@ def test_deployment_declaration_helpers(monkeypatch):
# ---------------------------------------------------------------------------
# Login throttle storage warning (auth.local.throttle_storage)
# ---------------------------------------------------------------------------
def _with_throttle_storage(config, selector):
config.auth = SimpleNamespace(local=SimpleNamespace(throttle_storage=selector))
return config
def _throttle_warnings(caplog):
return [r.message for r in caplog.records if "auth.local.throttle_storage" in r.message]
def test_login_throttle_warning_fires_for_a_declared_multi_instance_deployment_on_memory(caplog):
"""Explicit memory counters under N replicas hand an attacker N x max_login_attempts guesses."""
from deerflow.config.auth_config import LoginThrottleStorage
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(deployment_multi_instance=True), LoginThrottleStorage.MEMORY))
messages = _throttle_warnings(caplog)
assert messages and "deployment.multi_instance=true" in messages[0]
assert "max_login_attempts" in messages[0]
# The selector is a StrEnum; the warning must render its value, not "LoginThrottleStorage.MEMORY".
assert "auth.local.throttle_storage=memory:" in messages[0]
assert "LoginThrottleStorage" not in messages[0]
def test_login_throttle_warning_names_the_worker_variable(monkeypatch, caplog):
monkeypatch.setenv("WEB_CONCURRENCY", "2")
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(), "memory"))
messages = _throttle_warnings(caplog)
assert messages and "WEB_CONCURRENCY=2" in messages[0]
def test_login_throttle_auto_resolves_to_the_database_under_multi_instance_without_warning(caplog):
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(deployment_multi_instance=True), "auto"))
_validate_login_throttle_storage(_with_throttle_storage(_cluster_ready(deployment_multi_instance=True), "db"))
assert _throttle_warnings(caplog) == []
def test_login_throttle_memory_is_silent_for_a_single_instance(caplog):
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_with_throttle_storage(_config_with_backend("sqlite"), "memory"))
assert _throttle_warnings(caplog) == []
def test_login_throttle_gate_tolerates_a_config_without_an_auth_section(caplog):
with caplog.at_level("WARNING"):
_validate_login_throttle_storage(_cluster_ready(deployment_multi_instance=True))
assert _throttle_warnings(caplog) == []
# DeerMem retrieval index: a declared multi-instance deployment must keep the
# derived SQLite index off the shared memory volume.
# ---------------------------------------------------------------------------

View File

@ -20,7 +20,7 @@
# ============================================================================
# Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 56
config_version: 57
# Optional operator-owned literal prompt extensions (see CONFIGURATION.md):
# lead_prompt_overlay:
@ -3380,19 +3380,33 @@ authorization:
# auth:
# local:
# allow_registration: false
# # Login throttling per client IP (in-process, per worker). Raise
# # max_login_attempts when many users share one egress IP (corporate
# # proxy / NAT) so shared-IP lockouts don't block the whole office;
# # lower it for a stricter posture (minimum 2 — one failed attempt must
# # never lock an IP, or a single typo would block the shared egress).
# # Defaults preserve the historical hardcoded policy (5 failures /
# # 5 minutes). Live-read per login, so a config reload applies without
# # a Gateway restart: lowering lockout_seconds releases an active lock
# # early and tightening max_login_attempts keeps already-counted
# # failures; a raised lockout_seconds extends a still-active lock but
# # never resurrects one that already expired.
# # Login throttling per client IP. Raise max_login_attempts when many
# # users share one egress IP (corporate proxy / NAT) so shared-IP
# # lockouts don't block the whole office; lower it for a stricter
# # posture (minimum 2 — one failed attempt must never lock an IP, or a
# # single typo would block the shared egress). Defaults preserve the
# # historical hardcoded policy (5 failures / 5 minutes). Live-read per
# # login, so a config reload applies without a Gateway restart: lowering
# # lockout_seconds releases an active lock early and tightening
# # max_login_attempts keeps already-counted failures; a raised
# # lockout_seconds extends a still-active lock but never resurrects one
# # that already expired.
# # max_login_attempts: 5
# # lockout_seconds: 300
# # Where the failed-login counters and lockouts live (restart required).
# # auto -- (default) the application database whenever
# # database.backend is sqlite or postgres, so every Gateway
# # replica sharing that database enforces one lockout per IP;
# # with database.backend: memory it falls back to an
# # in-process counter.
# # memory -- in-process counter only. Per process: N replicas give an
# # attacker N x max_login_attempts guesses and a lockout on
# # one replica is invisible to the others (a multi-instance
# # deployment logs a startup WARNING).
# # db -- the shared login_throttle table; falls back to memory with
# # a WARNING when database.backend is memory, and refuses to
# # start when the configured database's engine is unavailable.
# # throttle_storage: auto
#
# oidc:
# enabled: true

View File

@ -135,7 +135,7 @@ they resolve from the `secrets` map):
```yaml
config: |
config_version: 56
config_version: 57
models:
- name: gpt-4
use: langchain_openai:ChatOpenAI

View File

@ -329,7 +329,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox.
config: |
config_version: 56
config_version: 57
log_level: info
recursion_limit: 100
max_recursion_limit: 1000