M39: harden application and acceptance gates
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
"""Small in-process failed-attempt limiter for credential endpoints.
|
||||
|
||||
Fleet Ops runs as a single API process per deployment, so an in-memory sliding window
|
||||
is sufficient to blunt online password guessing (and the scrypt CPU amplification that
|
||||
comes with it) without adding Redis. Only *failed* attempts count, so legitimate users
|
||||
and the automated test suite are never throttled.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
import time
|
||||
from collections import deque
|
||||
|
||||
|
||||
class FailedAttemptLimiter:
|
||||
def __init__(self, *, max_failures: int, window_seconds: float) -> None:
|
||||
self.max_failures = max_failures
|
||||
self.window_seconds = window_seconds
|
||||
self._failures: dict[str, deque[float]] = {}
|
||||
self._lock = threading.Lock()
|
||||
|
||||
def _prune(self, key: str, now: float) -> deque[float]:
|
||||
bucket = self._failures.setdefault(key, deque())
|
||||
cutoff = now - self.window_seconds
|
||||
while bucket and bucket[0] <= cutoff:
|
||||
bucket.popleft()
|
||||
if not bucket:
|
||||
self._failures.pop(key, None)
|
||||
return bucket
|
||||
|
||||
def retry_after_seconds(self, key: str) -> int:
|
||||
"""Return >0 seconds to wait when the key is currently blocked, else 0."""
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
bucket = self._prune(key, now)
|
||||
if len(bucket) < self.max_failures:
|
||||
return 0
|
||||
return max(1, int(bucket[0] + self.window_seconds - now + 0.999))
|
||||
|
||||
def record_failure(self, key: str) -> None:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
self._prune(key, now)
|
||||
self._failures.setdefault(key, deque()).append(now)
|
||||
|
||||
def reset(self, key: str) -> None:
|
||||
with self._lock:
|
||||
self._failures.pop(key, None)
|
||||
Reference in New Issue
Block a user