Ctrl K
ComunidadGuías…

simple_nas/app/ratelimit.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env python3
"""Sliding-window rate limiting and lockouts, in memory, stdlib only.

One process serves both ports, so a single shared LIMITER is enough. State
is lost on restart, which is fine: the point is to make guessing slow, not
to keep a permanent record.
"""

import threading
import time
from collections import deque


def _split_key(key):
    """authfail:ip:1.2.3.4 -> ("ip", "1.2.3.4"); ban:1.2.3.4 -> ("ban", "1.2.3.4")"""
    parts = key.split(":", 2)
    if parts[0] == "authfail" and len(parts) == 3:
        return parts[1], parts[2]
    return parts[0], parts[-1] if len(parts) > 1 else ""


class Limiter:
    def __init__(self, clock=None):
        self._clock = clock or time.monotonic
        self._buckets = {}
        self._locks = {}          # key -> (until, escalation count, last lock time)
        self._lock = threading.Lock()
        self._ops = 0

    def _prune(self, key, window, now):
        q = self._buckets.get(key)
        if q is None:
            return None
        while q and q[0] <= now - window:
            q.popleft()
        if not q:
            self._buckets.pop(key, None)
            return None
        return q

    def _sweep(self, now):
        """Every few hundred calls drop buckets whose newest entry is old."""
        self._ops += 1
        if self._ops % 200:
            return
        for key in list(self._buckets):
            q = self._buckets[key]
            if not q or q[-1] <= now - 3600:
                self._buckets.pop(key, None)

    def hit(self, key, limit, window):
        """Record one event. Returns (allowed, retry_after_seconds)."""
        now = self._clock()
        with self._lock:
            self._sweep(now)
            q = self._prune(key, window, now)
            if q is None:
                q = self._buckets[key] = deque()
            if len(q) >= limit:
                return False, max(1, int(q[0] + window - now) + 1)
            q.append(now)
            return True, 0

    def count(self, key, window):
        now = self._clock()
        with self._lock:
            q = self._prune(key, window, now)
            return len(q) if q else 0

    def record(self, key):
        """Add an event without checking a limit (for failure counters)."""
        with self._lock:
            self._buckets.setdefault(key, deque()).append(self._clock())

    def locked(self, key, limit, window):
        return self.banned(key) or self.count(key, window) >= limit

    # ── escalating lockouts ──────────────────────────────────────────────
    def lock(self, key, base, cap, decay=24 * 3600):
        """Lock key for base * 2^(n-1) seconds, n = lockouts within `decay`.
        First lockout 15 min, second 30, then 1 h, 2 h ... up to cap."""
        now = self._clock()
        with self._lock:
            until, n, last = self._locks.get(key, (0, 0, 0))
            if last and now - last > decay:
                n = 0
            n += 1
            dur = min(cap, base * (2 ** (n - 1)))
            self._locks[key] = (now + dur, n, now)
            return dur

    def banned(self, key):
        now = self._clock()
        with self._lock:
            entry = self._locks.get(key)
            if not entry:
                return False
            if entry[0] > now:
                return True
            return False

    def ban_remaining(self, key):
        now = self._clock()
        with self._lock:
            entry = self._locks.get(key)
            return max(0, int(entry[0] - now)) if entry else 0

    def retry_after(self, key, window):
        now = self._clock()
        with self._lock:
            q = self._prune(key, window, now)
            return max(1, int(q[0] + window - now) + 1) if q else 0

    def snapshot(self, window=15 * 60):
        """Active lockouts and current failure counters, for the admin UI.

        Without this the protection is invisible: a user who types a wrong
        password five times sees nothing happen and cannot tell whether the
        lockout is broken or simply not reached yet.
        """
        now = self._clock()
        locks, counters = [], []
        with self._lock:
            for key, (until, n, _last) in list(self._locks.items()):
                if until > now:
                    kind, target = _split_key(key)
                    locks.append({"key": key, "kind": kind, "target": target,
                                  "remaining": int(until - now), "strikes": n})
            for key in list(self._buckets):
                if not key.startswith("authfail:"):
                    continue
                q = self._prune(key, window, now)
                if q:
                    kind, target = _split_key(key)
                    counters.append({"key": key, "kind": kind, "target": target, "count": len(q)})
        locks.sort(key=lambda x: -x["remaining"])
        counters.sort(key=lambda x: -x["count"])
        return {"locks": locks, "counters": counters}

    def clear(self, key=None, prefix=None):
        with self._lock:
            if key is not None:
                self._buckets.pop(key, None)
                self._locks.pop(key, None)
            if prefix is not None:
                for k in list(self._buckets):
                    if k.startswith(prefix):
                        self._buckets.pop(k, None)
                for k in list(self._locks):
                    if k.startswith(prefix):
                        self._locks.pop(k, None)


# Limits for the public share site. (limit, window seconds)
REQ_PER_IP = (240, 60)
AUTHFAIL_IP = (10, 15 * 60)
AUTHFAIL_LINK = (20, 15 * 60)
AUTHFAIL_USER = (10, 15 * 60)
UPLOAD_PER_IP = (30, 60 * 60)
SCAN_PER_IP = (20, 10 * 60)         # requests for unknown links -> token scanner
LOCK_BASE = 15 * 60                 # first lockout
LOCK_CAP = 24 * 3600                # repeat offenders end up here

LIMITER = Limiter()