CURAS / app /security.py
stevafernandes's picture
Deploy CURAS Research Database (FastAPI Docker Space)
d3295cf verified
Raw History Blame Contribute Delete
4.77 kB
"""Admin session cookies, CSRF tokens, rate limiting and password checks."""
from __future__ import annotations
import hmac
import ipaddress
import secrets
import threading
import time
from collections import defaultdict, deque
from typing import Optional
from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer
SESSION_COOKIE = "curas_admin_session"
class SessionManager:
def __init__(self, secret: str, max_age_seconds: int):
self._s = URLSafeTimedSerializer(secret, salt="curas-admin-session")
self.max_age = max_age_seconds
def create(self) -> tuple[str, str]:
"""Return (cookie_value, csrf_token) for a fresh admin session."""
csrf = secrets.token_urlsafe(32)
return self._s.dumps({"csrf": csrf, "v": 1}), csrf
def read(self, cookie_value: Optional[str]) -> Optional[dict]:
if not cookie_value:
return None
try:
data = self._s.loads(cookie_value, max_age=self.max_age)
except (BadSignature, SignatureExpired):
return None
if not isinstance(data, dict) or "csrf" not in data:
return None
return data
class FormTokens:
"""Signed, time-limited tokens for the anonymous public form.
The token is carried only in a hidden input (no cookie), so the form also
works when the site is shown inside another site's frame, such as the
Space page on huggingface.co. The form is anonymous, so there is no
session for a cross-site request to ride on; the token's job is to
accept posts only from a form this server rendered recently.
"""
def __init__(self, secret: str, max_age_seconds: int = 6 * 3600):
self._s = URLSafeTimedSerializer(secret, salt="curas-form-token")
self.max_age = max_age_seconds
def issue(self) -> str:
return self._s.dumps({"n": secrets.token_urlsafe(16)})
def verify(self, form_value: Optional[str]) -> bool:
if not form_value:
return False
try:
data = self._s.loads(str(form_value), max_age=self.max_age)
except (BadSignature, SignatureExpired):
return False
return isinstance(data, dict) and "n" in data
class RateLimiter:
"""In-memory sliding-window limiter. Adequate for a single-instance Space."""
def __init__(self):
self._events: dict[tuple[str, str], deque] = defaultdict(deque)
self._lock = threading.Lock()
def hit(self, bucket: str, key: str, limit: int, window_seconds: int) -> bool:
"""Record an event; return True if still within the limit."""
now = time.monotonic()
with self._lock:
q = self._events[(bucket, key)]
while q and now - q[0] > window_seconds:
q.popleft()
if len(q) >= limit:
return False
q.append(now)
if len(self._events) > 10000:
# Drop stale buckets so memory cannot grow without bound.
for k in [k for k, v in self._events.items() if not v or now - v[-1] > window_seconds]:
del self._events[k]
return True
def is_limited(self, bucket: str, key: str, limit: int, window_seconds: int) -> bool:
now = time.monotonic()
with self._lock:
q = self._events.get((bucket, key))
if not q:
return False
while q and now - q[0] > window_seconds:
q.popleft()
return len(q) >= limit
def reset(self, bucket: str, key: str) -> None:
with self._lock:
self._events.pop((bucket, key), None)
def password_matches(expected: str, supplied: str) -> bool:
if not expected or supplied is None:
return False
return hmac.compare_digest(expected.encode("utf-8"), supplied.encode("utf-8"))
def client_ip(headers, fallback: str) -> str:
"""Client address as seen by the proxy in front of the app.
Proxies append the real peer address to whatever X-Forwarded-For the
client sent (Hugging Face Spaces does this, one hop), so the LAST entry is
the trustworthy one; the first entry can be chosen by the client.
"""
xff = headers.get("x-forwarded-for")
host = fallback or "unknown"
if xff:
hosts = [h.strip() for h in xff.split(",") if h.strip()]
if hosts:
host = hosts[-1]
return rate_limit_key(host)
def rate_limit_key(host: str) -> str:
"""IPv6 clients are keyed by their /64 so one network cannot mint endless addresses."""
try:
ip = ipaddress.ip_address(host)
except ValueError:
return host
if ip.version == 6:
return str(ipaddress.ip_network((ip, 64), strict=False))
return host