feat(auth): make login rate-limit parameters configurable, fixes #5108 (#5110)

* feat(auth): make login rate-limit parameters configurable, fixes #5108

Add auth.local.max_login_attempts (default 5) and auth.local.lockout_seconds
(default 300) so operators can tune the per-IP login throttle: raise the
ceiling for shared-egress-IP offices behind proxies/NAT, or tighten it for
stricter posture. Policy is live-read per call (matching the
_local_registration_enabled precedent), so a config reload applies without a
Gateway restart; raising the threshold mid-lockout immediately unblocks
affected IPs.

Review feedback addressed (willem-bd):
- Only FileNotFoundError falls back to the hardcoded defaults; a malformed
  config propagates, mirroring _local_registration_enabled, so an operator
  who tightened the policy never silently gets the more permissive defaults.
- _check_rate_limit looks up the record before resolving the policy, so a
  clean IP pays zero config reads (get_app_config re-hashes config.yaml per
  call and login_local is an unauthenticated async endpoint).

Bumps config_version to 39 in config.example.yaml and the Helm chart
(values.yaml + README example) so the chart drift check stays green.

* fix(auth): reject max_login_attempts=1 and honor live lockout_seconds for active lockouts

* fix(auth): close live-policy state gaps in login throttle (resurrection, count reset, broken-config verification)

* fix(auth): commit evaluated lockout duration on decreases too, preventing raise-resurrection

* test(auth): pin broken-config fail-closed sequence through the login route

* fix(auth): sweep expired locks by stored sentence and keep policy reads off the event loop

* fix(auth): re-read throttle record after the policy-resolution yield point

---------

Co-authored-by: Willem Jiang <willem.jiang@gmail.com>
This commit is contained in:
hataa 2026-09-02 19:16:56 +08:00 committed by GitHub
parent bbcfd368bf
commit 08b27aef73
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
8 changed files with 809 additions and 38 deletions

View File

@ -164,15 +164,47 @@ def _set_session_cookie(response: Response, token: str, request: Request, *, rem
# #
# **Limitation**: with multi-worker deployments (e.g., gunicorn -w N), each # **Limitation**: with multi-worker deployments (e.g., gunicorn -w N), each
# worker maintains its own lockout table, so an attacker effectively gets # worker maintains its own lockout table, so an attacker effectively gets
# N × _MAX_LOGIN_ATTEMPTS guesses before being locked out everywhere. For # N × max_login_attempts guesses before being locked out everywhere. For
# production multi-worker setups, replace this with a shared store (Redis, # production multi-worker setups, replace this with a shared store (Redis,
# database-backed counter) to enforce a true per-IP limit. # database-backed counter) to enforce a true per-IP limit.
#
# 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.
_MAX_LOGIN_ATTEMPTS = 5 # ip → (fail_count, locked_at, locked_duration). The stored duration always
_LOCKOUT_SECONDS = 300 # 5 minutes # 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]] = {}
# ip → (fail_count, lock_until_timestamp)
_login_attempts: dict[str, tuple[int, float]] = {} def _login_throttle_policy() -> tuple[int, float]:
"""(max_login_attempts, lockout_seconds) from auth.local config, read live.
Only ``FileNotFoundError`` falls back to the model defaults, matching
``_local_registration_enabled``: ``config.yaml`` is absent in bare-app
contexts that never load it (tests build the gateway without one), and the
throttle must keep its pre-config-era behavior there. A malformed config
propagates instead like every other config consumer, and so an operator
who tightened the policy never silently gets the more permissive defaults.
Callers on request paths resolve this at most once per helper invocation;
``get_app_config`` re-hashes the config file on every call, and the login
endpoint is unauthenticated.
"""
from deerflow.config.app_config import get_app_config
from deerflow.config.auth_config import LocalAuthConfig
try:
local = get_app_config().auth.local
except FileNotFoundError:
local = LocalAuthConfig()
return local.max_login_attempts, local.lockout_seconds
def _trusted_proxies() -> list: def _trusted_proxies() -> list:
@ -236,46 +268,127 @@ def _get_client_ip(request: Request) -> str:
return peer_host or "unknown" return peer_host or "unknown"
def _check_rate_limit(ip: str) -> None: async def _check_rate_limit(ip: str) -> None:
"""Raise 429 if the IP is currently locked out.""" """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.
"""
record = _login_attempts.get(ip) record = _login_attempts.get(ip)
if record is None: if record is None:
return return
fail_count, lock_until = record max_attempts, lockout_seconds = await asyncio.to_thread(_login_throttle_policy)
if fail_count >= _MAX_LOGIN_ATTEMPTS: # The await above is a yield point: while this coroutine was suspended,
if time.time() < lock_until: # another request for the same IP may have deleted or replaced the record
raise HTTPException( # (the pre-async version was atomic on the loop). The pre-read served only
status_code=429, # as the cheap clean-IP skip; decide on a fresh snapshot from here on —
detail="Too many login attempts. Try again later.", # 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)
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] del _login_attempts[ip]
_MAX_TRACKED_IPS = 10000 _MAX_TRACKED_IPS = 10000
def _record_login_failure(ip: str) -> None: def _record_failure_under_policy(ip: str, max_attempts: int, lockout_seconds: float) -> None:
"""Record a failed login attempt for the given IP.""" """Apply one failed login to the counter under an explicit policy."""
# Evict expired lockouts when dict grows too large # 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: if len(_login_attempts) >= _MAX_TRACKED_IPS:
now = time.time() now = time.time()
expired = [k for k, (c, t) in _login_attempts.items() if c >= _MAX_LOGIN_ATTEMPTS and now >= t] expired = [k for k, (c, t, d) in _login_attempts.items() if t > 0.0 and now >= t + d]
for k in expired: for k in expired:
del _login_attempts[k] del _login_attempts[k]
# If still too large, evict cheapest-to-lose half: below-threshold # If still too large, evict cheapest-to-lose half ordered by each
# IPs (lock_until=0.0) sort first, then earliest-expiring lockouts. # 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: if len(_login_attempts) >= _MAX_TRACKED_IPS:
by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1]) by_time = sorted(_login_attempts.items(), key=lambda kv: kv[1][1] + kv[1][2])
for k, _ in by_time[: len(by_time) // 2]: for k, _ in by_time[: len(by_time) // 2]:
del _login_attempts[k] del _login_attempts[k]
record = _login_attempts.get(ip) record = _login_attempts.get(ip)
if record is None: if record is None:
_login_attempts[ip] = (1, 0.0) _login_attempts[ip] = (1, 0.0, 0.0)
else: else:
new_count = record[0] + 1 new_count = record[0] + 1
lock_until = time.time() + _LOCKOUT_SECONDS if new_count >= _MAX_LOGIN_ATTEMPTS else 0.0 if new_count >= max_attempts:
_login_attempts[ip] = (new_count, lock_until) _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:
"""Record a failed login attempt for the given IP.
Policy resolution runs off the event loop (see ``_check_rate_limit``):
this is the first config read for a previously clean IP, and the login
endpoint is unauthenticated.
"""
try:
max_attempts, lockout_seconds = await asyncio.to_thread(_login_throttle_policy)
except Exception:
# A malformed config keeps failing loudly, but dropping the failure
# here would leave the IP clean — and a clean IP skips the config
# read in _check_rate_limit, so every subsequent wrong password would
# reach authenticate() again: unlimited password verification for as
# long as the file stays broken. Count under the model defaults so
# the throttle fails closed (the next check reads the broken config
# before authenticate), then re-raise.
from deerflow.config.auth_config import LocalAuthConfig
fallback = LocalAuthConfig()
_record_failure_under_policy(ip, fallback.max_login_attempts, fallback.lockout_seconds)
raise
_record_failure_under_policy(ip, max_attempts, lockout_seconds)
def _record_login_success(ip: str) -> None: def _record_login_success(ip: str) -> None:
@ -295,12 +408,12 @@ async def login_local(
): ):
"""Local email/password login.""" """Local email/password login."""
client_ip = _get_client_ip(request) client_ip = _get_client_ip(request)
_check_rate_limit(client_ip) await _check_rate_limit(client_ip)
user = await get_local_provider().authenticate({"email": form_data.username, "password": form_data.password}) user = await get_local_provider().authenticate({"email": form_data.username, "password": form_data.password})
if user is None: if user is None:
_record_login_failure(client_ip) await _record_login_failure(client_ip)
raise HTTPException( raise HTTPException(
status_code=status.HTTP_401_UNAUTHORIZED, status_code=status.HTTP_401_UNAUTHORIZED,
detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(), detail=AuthErrorResponse(code=AuthErrorCode.INVALID_CREDENTIALS, message="Incorrect email or password").model_dump(),

View File

@ -110,7 +110,7 @@ enum UserScope:
- 成功后签发 JWT放入 `access_token` HttpOnly cookie。 - 成功后签发 JWT放入 `access_token` HttpOnly cookie。
- 响应体只返回 `expires_in``needs_setup`,不返回 token。 - 响应体只返回 `expires_in``needs_setup`,不返回 token。
登录失败会按客户端 IP 计数。IP 解析只在 TCP peer 属于 `AUTH_TRUSTED_PROXIES` 时信任 `X-Real-IP`,不使用 `X-Forwarded-For` 登录失败会按客户端 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。时长热改按方向生效下调可提前释放进行中的锁定、收紧阈值会保留已计数的失败上调只延长仍在锁定期内的锁定不会复活已服满原时长的锁定
### 注册 ### 注册

View File

@ -79,6 +79,26 @@ class LocalAuthConfig(BaseModel):
"does not apply to local registration." "does not apply to local registration."
), ),
) )
max_login_attempts: int = Field(
default=5,
ge=2,
description=(
"Failed login attempts allowed from one client IP before it is locked out of "
"POST /api/v1/auth/login/local. Defaults preserve the historical hardcoded policy. "
"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."
),
)
lockout_seconds: float = Field(
default=300.0,
gt=0,
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)."),
)
class AuthAppConfig(BaseModel): class AuthAppConfig(BaseModel):

View File

@ -0,0 +1,81 @@
"""Regression anchors: the login throttle must not block the event loop.
``_login_throttle_policy`` resolves the live policy via ``get_app_config()``,
which stats and re-hashes ``config.yaml`` on every call. ``login_local`` is an
unauthenticated async endpoint, and every request from a recorded IP resolves
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``.
"""
from __future__ import annotations
import time
import pytest
from fastapi import HTTPException
from fastapi.responses import Response
from fastapi.security import OAuth2PasswordRequestForm
from starlette.requests import Request
from app.gateway.routers import auth as auth_router
pytestmark = pytest.mark.asyncio
_CLIENT_IP = "203.0.113.9"
@pytest.fixture(autouse=True)
def _throttle_state(monkeypatch):
monkeypatch.delenv("AUTH_TRUSTED_PROXIES", raising=False)
auth_router._login_attempts.clear()
yield
auth_router._login_attempts.clear()
def _request() -> Request:
return Request(
{
"type": "http",
"method": "POST",
"path": "/api/v1/auth/login/local",
"headers": [],
"query_string": b"",
"client": (_CLIENT_IP, 44000),
"server": ("testserver", 80),
}
)
def _form() -> OAuth2PasswordRequestForm:
return OAuth2PasswordRequestForm(username="user@example.com", password="wrong")
async def test_locked_ip_policy_resolution_does_not_block_loop() -> 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
with pytest.raises(HTTPException) as exc_info:
await auth_router.login_local(_request(), Response(), _form(), remember_me=True)
assert exc_info.value.status_code == 429
async def test_failed_login_recording_does_not_block_loop(monkeypatch) -> None:
"""The wrong-password path resolves the policy again inside the recorder;
counting must happen without blocking IO on the loop."""
class _Provider:
async def authenticate(self, credentials):
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
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

View File

@ -882,37 +882,581 @@ def test_login_response_includes_needs_setup():
# ── Rate Limiting ────────────────────────────────────────────────────────── # ── Rate Limiting ──────────────────────────────────────────────────────────
def test_rate_limiter_allows_under_limit(): @pytest.mark.asyncio
async def test_rate_limiter_allows_under_limit():
"""Requests under the limit are allowed.""" """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
_login_attempts.clear() _login_attempts.clear()
_check_rate_limit("192.168.1.1") # Should not raise await _check_rate_limit("192.168.1.1") # Should not raise
def test_rate_limiter_blocks_after_max_failures(): @pytest.mark.asyncio
async def test_rate_limiter_blocks_after_max_failures():
"""IP is blocked after 5 consecutive failures.""" """IP is blocked after 5 consecutive failures."""
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_failure
_login_attempts.clear() _login_attempts.clear()
ip = "10.0.0.1" ip = "10.0.0.1"
for _ in range(5): for _ in range(5):
_record_login_failure(ip) await _record_login_failure(ip)
with pytest.raises(HTTPException) as exc_info: with pytest.raises(HTTPException) as exc_info:
_check_rate_limit(ip) await _check_rate_limit(ip)
assert exc_info.value.status_code == 429 assert exc_info.value.status_code == 429
def test_rate_limiter_resets_on_success(): @pytest.mark.asyncio
async def test_rate_limiter_resets_on_success():
"""Successful login clears the failure counter.""" """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, _login_attempts, _record_login_failure, _record_login_success
_login_attempts.clear() _login_attempts.clear()
ip = "10.0.0.2" ip = "10.0.0.2"
for _ in range(4): for _ in range(4):
_record_login_failure(ip) await _record_login_failure(ip)
_record_login_success(ip) _record_login_success(ip)
_check_rate_limit(ip) # Should not raise await _check_rate_limit(ip) # Should not raise
@pytest.mark.asyncio
async def test_rate_limiter_honors_configured_attempts_and_lockout(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
_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)
await _check_rate_limit(ip)
assert ip not in _login_attempts
finally:
reset_app_config()
_login_attempts.clear()
def test_rate_limiter_uses_defaults_when_config_unavailable(monkeypatch):
"""An absent config.yaml falls back to the built-in (5 attempts / 300s) policy.
Mirrors ``_local_registration_enabled``: only FileNotFoundError is caught.
A malformed config must NOT silently change the throttle policy pinned
to propagate by the test below.
"""
from app.gateway.routers import auth as auth_router
from deerflow.config import app_config as app_config_module
def _missing():
raise FileNotFoundError("no config.yaml")
monkeypatch.setattr(app_config_module, "get_app_config", _missing)
assert auth_router._login_throttle_policy() == (5, 300)
def test_rate_limiter_malformed_config_propagates(monkeypatch):
"""A malformed config.yaml fails loudly instead of fail-opening the throttle.
Every other config consumer 500s on a validation failure; silently
substituting the (possibly more permissive) defaults here would diverge
an operator who set max_login_attempts=2 must never silently get 5.
"""
from app.gateway.routers import auth as auth_router
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"):
auth_router._login_throttle_policy()
@pytest.mark.asyncio
async def test_rate_limiter_clean_ip_skips_config_read(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)."""
from app.gateway.routers import auth as auth_router
from deerflow.config import app_config as app_config_module
def _must_not_load():
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():
"""Pin the emergent semantics of a live-read policy under config reload.
Raising max_login_attempts mid-lockout immediately unblocks IPs whose
fail_count falls below the new threshold that is the issue #5108 use
case (shared-egress-IP office unblocked by raising the limit, no restart).
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
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)
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()
def test_local_auth_throttle_config_validation():
"""Throttle knobs reject degenerate operator values at config load."""
import pydantic
from deerflow.config.auth_config import LocalAuthConfig
with pytest.raises(pydantic.ValidationError):
LocalAuthConfig(max_login_attempts=0)
with pytest.raises(pydantic.ValidationError):
# 1 would mean a single typo locks the whole shared egress, and before
# ge=2 it actually disabled lockout entirely (the (1, 0.0) record
# expired immediately and every failure re-created it).
LocalAuthConfig(max_login_attempts=1)
LocalAuthConfig(max_login_attempts=2) # strictest legal value
with pytest.raises(pydantic.ValidationError):
LocalAuthConfig(lockout_seconds=0)
with pytest.raises(pydantic.ValidationError):
LocalAuthConfig(lockout_seconds=float("inf"))
@pytest.mark.asyncio
async def test_rate_limiter_active_lockout_honors_live_lockout_seconds_change(monkeypatch):
"""A live lockout_seconds change applies to in-flight lockouts, in the
direction that serves the operator, without resurrecting served sentences.
A lock records the duration in force when it started. At check time an
active sentence follows the *currently* configured duration lowering
60 1 releases early while a lock that already served its original
sentence stays expired even if the duration is later raised (no
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)),
)
)
def _freeze_clock_at(t: float) -> None:
monkeypatch.setattr(auth_router.time, "time", lambda: t)
_login_attempts.clear()
try:
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
# 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
# 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()
@pytest.mark.asyncio
async def test_rate_limiter_lowered_then_raised_duration_not_resurrected(monkeypatch):
"""A shortened duration observed during the sentence is committed, so a
later raise cannot resurrect time already served under the short policy.
60s lock, evaluated at +6s under a lowered 10s policy (still locked the
sentence becomes 10s), then raised to 30s at +20s: the lock expired at
+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
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)),
)
)
_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]
def _freeze(t: float) -> None:
monkeypatch.setattr(auth_router.time, "time", lambda: t)
_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()
@pytest.mark.asyncio
async def test_concurrent_checks_on_expired_lock_are_race_free(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).
"""
import asyncio
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts
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
results = await asyncio.gather(*[_check_rate_limit("10.0.0.9") 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()
@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.
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``.
"""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts, _record_login_success
def _policy_that_deletes_the_record():
_record_login_success("10.0.0.10")
return 5, 300.0
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
await _check_rate_limit("10.0.0.10") # pre-fix: KeyError
assert "10.0.0.10" not in _login_attempts
finally:
_login_attempts.clear()
@pytest.mark.asyncio
async def test_check_never_clobbers_record_replaced_during_policy_resolution(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.
"""
from app.gateway.routers import auth as auth_router
from app.gateway.routers.auth import _check_rate_limit, _login_attempts
def _policy_that_replaces_the_record():
_login_attempts["10.0.0.11"] = (1, 0.0, 0.0)
return 2, 60.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
await _check_rate_limit("10.0.0.11") # fresh read: (1, 0, 0) < max 2 → allowed
assert _login_attempts["10.0.0.11"] == (1, 0.0, 0.0)
finally:
_login_attempts.clear()
@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():
"""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
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.6"
_set_policy(5)
for _ in range(4):
await _record_login_failure(ip) # 4 failures: counting, never locked
_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 _check_rate_limit(ip)
assert ip not in _login_attempts
finally:
reset_app_config()
_login_attempts.clear()
@pytest.mark.asyncio
async def test_rate_limiter_counts_failure_when_config_breaks(monkeypatch):
"""A malformed config hot-edit must not hand out unlimited verification.
Endpoint order per failed login: _check_rate_limit (clean IPs skip the
config read) authenticate() _record_login_failure. If the record call
raises on the broken config *before* mutating state, the IP stays clean
and every subsequent wrong password reaches authenticate() again. The
failure must land (counted under the model defaults) before the error
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 deerflow.config import app_config as app_config_module
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"
# 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)
# 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()
def test_login_local_broken_config_fails_closed_after_first_failure(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
the re-raised config error, not from a skipped record); the second request
from the same client IP must 500 in _check_rate_limit *before*
authenticate() is reached no unlimited password verification while
config.yaml stays malformed.
"""
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.gateway.routers import auth as auth_router
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)
calls = {"authenticate": 0}
class _Provider:
async def authenticate(self, credentials):
calls["authenticate"] += 1
return None # wrong password
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)
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
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
finally:
auth_router._login_attempts.clear()
# ── Client IP extraction ───────────────────────────────────────────────── # ── Client IP extraction ─────────────────────────────────────────────────

View File

@ -15,7 +15,7 @@
# ============================================================================ # ============================================================================
# Bump this number when the config schema changes. # Bump this number when the config schema changes.
# Run `make config-upgrade` to merge new fields into your local config.yaml. # Run `make config-upgrade` to merge new fields into your local config.yaml.
config_version: 38 config_version: 39
# ============================================================================ # ============================================================================
# Logging # Logging
@ -2722,6 +2722,19 @@ authorization:
# auth: # auth:
# local: # local:
# allow_registration: false # 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.
# # max_login_attempts: 5
# # lockout_seconds: 300
# #
# oidc: # oidc:
# enabled: true # enabled: true

View File

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

View File

@ -249,7 +249,7 @@ ingress:
# -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never # -- DeerFlow config.yaml content. Secrets MUST stay as $VAR references — never
# inline literal secret values here. The default enables provisioner sandbox. # inline literal secret values here. The default enables provisioner sandbox.
config: | config: |
config_version: 38 config_version: 39
log_level: info log_level: info
models: [] models: []