Deep-review round 3 over main..improvement/watch-background-audio. Account boot - The notification history and settings, and the hints toggle, are read when their modules load, which in a new tab (bookmark, typed URL) is before BootGate pins the account: they read as nobody's, and the tab's first notice replaced the account's stored history (up to 100 entries) with a one-item list. They now read again when the tab is pinned (onAccountPinned, run by setActiveAccount); a notice raised before the pin stays, after the history. - A tab pinned to an account still in this browser's wallet but whose session ended (suspended, deleted, or revoked by "log out everywhere") got "signed out" from /api/me while the default account was still signed in, and signing in again as anyone else left it there. A refused per-tab account now leaves the wallet, and the probe answers as the default, so the tab reloads onto it. Share page (/watch) - The password unlock is limited per viewer (link + client IP), under a per-link ceiling that counts only wrong passwords. Keyed on the link alone, ten wrong guesses every five minutes locked out every viewer, the ones with the password included. client_ip moves to app.ratelimit so the public routes can key on it. - Its failure messages and the load-failure screen are announced (role=alert, the password input described by its error). The sign-in, passphrase, password-change and setup form errors get role=alert too. Comments that still described removed machinery are corrected: App's collapse flags, SettingsPanel's Save bar, ConfigPanel, authed_format_sig. Tests: the account's history and hints across the pin; a revoked and a suspended pinned account; the per-viewer limit and the ceiling; the limiter's exhausted(); the share page's announced failures; a share link's feed filters persisted. Each mutation-checked RED for its own reason.
96 lines
4.3 KiB
Python
96 lines
4.3 KiB
Python
"""A tiny in-process sliding-window rate limiter.
|
|
|
|
Generic groundwork: keyed by an arbitrary string (e.g. a client IP — `client_ip`), so it can throttle any
|
|
endpoint. Single-worker uvicorn → in-memory state is sufficient; it resets on restart, which
|
|
is fine for abuse throttling (not for anything that must survive a deploy). Not shared across
|
|
processes — if we ever run multiple workers, swap the backing store for Redis behind the same
|
|
``allow()`` interface.
|
|
"""
|
|
|
|
import ipaddress
|
|
import threading
|
|
import time
|
|
|
|
from fastapi import Request
|
|
|
|
from app.config import settings
|
|
|
|
|
|
class RateLimiter:
|
|
def __init__(self, max_events: int, window_seconds: float):
|
|
self.max_events = max_events
|
|
self.window = window_seconds
|
|
self._hits: dict[str, list[float]] = {}
|
|
self._lock = threading.Lock()
|
|
|
|
def reset(self) -> None:
|
|
"""Forget every recorded attempt. Each limiter is a module-level singleton, so a test suite
|
|
sharing one process would otherwise pool all of its attempts into a single window and
|
|
eventually trip an unrelated test with a 429."""
|
|
with self._lock:
|
|
self._hits.clear()
|
|
|
|
def exhausted(self, key: str) -> bool:
|
|
"""Whether ``key``'s window is already full (``allow`` would refuse it), WITHOUT counting an
|
|
attempt — for a limit that counts only some outcomes: ask it first, ``allow`` the ones that
|
|
count."""
|
|
cutoff = time.monotonic() - self.window
|
|
with self._lock:
|
|
return sum(1 for t in self._hits.get(key, []) if t > cutoff) >= self.max_events
|
|
|
|
def allow(self, key: str) -> bool:
|
|
"""Record an attempt for ``key`` and return whether it is within the limit.
|
|
|
|
True -> under the cap (the attempt is counted).
|
|
False -> the cap for the current window is already reached (attempt NOT counted, so a
|
|
blocked caller can't keep pushing the window forward)."""
|
|
now = time.monotonic()
|
|
cutoff = now - self.window
|
|
with self._lock:
|
|
hits = [t for t in self._hits.get(key, []) if t > cutoff]
|
|
if len(hits) >= self.max_events:
|
|
self._hits[key] = hits
|
|
return False
|
|
hits.append(now)
|
|
self._hits[key] = hits
|
|
# Opportunistic cleanup so abandoned keys don't accumulate unboundedly.
|
|
if len(self._hits) > 4096:
|
|
for k in list(self._hits):
|
|
fresh = [t for t in self._hits[k] if t > cutoff]
|
|
if fresh:
|
|
self._hits[k] = fresh
|
|
else:
|
|
del self._hits[k]
|
|
return True
|
|
|
|
|
|
def _norm_ip(s: str) -> ipaddress.IPv4Address | ipaddress.IPv6Address | None:
|
|
"""Parse an IP for comparison, unwrapping IPv4-mapped IPv6 (`::ffff:10.0.0.1` → `10.0.0.1`) so a
|
|
plain-IPv4 allowlist still matches a peer that surfaces in mapped form. None for a non-IP string."""
|
|
try:
|
|
ip = ipaddress.ip_address(s.strip())
|
|
except ValueError:
|
|
return None
|
|
return ip.ipv4_mapped if getattr(ip, "ipv4_mapped", None) else ip
|
|
|
|
|
|
def client_ip(request: Request) -> str:
|
|
"""Best-effort client IP for rate limiting. X-Forwarded-For is client-controlled, so we trust it
|
|
ONLY when the request actually arrived from a configured reverse proxy (settings.trusted_proxy_ips
|
|
— the address the proxy connects FROM, e.g. the VPS Caddy's WireGuard peer IP). Our proxy APPENDS
|
|
the client it saw to XFF, so the RIGHTMOST entry is the real client and can't be forged by a
|
|
client pre-seeding the header. Any other peer (someone hitting the app port directly, bypassing the
|
|
proxy) is untrusted — we use its real socket IP, so it can't spoof its rate-limit identity.
|
|
|
|
SAFETY: this relies on `request.client` being the TRUE socket peer. uvicorn is started WITHOUT
|
|
`--proxy-headers` (see entrypoint.sh) precisely so it never rewrites `request.client` from XFF —
|
|
do NOT add that flag, or the trust check below would compare against a forgeable value."""
|
|
peer = request.client.host if request.client else "unknown"
|
|
trusted = {_norm_ip(p) for p in settings.trusted_proxy_ip_set} - {None}
|
|
if _norm_ip(peer) in trusted:
|
|
xff = request.headers.get("x-forwarded-for")
|
|
parts = [p.strip() for p in (xff or "").split(",") if p.strip()]
|
|
if parts:
|
|
return parts[-1]
|
|
return peer
|