Spaces:
Sleeping
Sleeping
Download app/security.py from stevafernandes/CURAS: direct link, hf CLI and curl.
- Browser
- Download file 4.77 kB
-
https://huggingface.co/spaces/stevafernandes/CURAS/resolve/main/app/security.py
- Command line
-
hf download hf://spaces/stevafernandes/CURAS/app/security.py
-
curl -L -o security.py https://huggingface.co/spaces/stevafernandes/CURAS/resolve/main/app/security.py
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 | |