"""Deliberately light auth: no password, no email verification. An annotator types their email once; we sign it into a cookie (so it can't be trivially forged/edited by hand) and treat whoever holds that cookie as that annotator from then on. This is enough to (a) attribute labels for inter-annotator agreement and (b) stop one browser session from re-labeling an item it's already done -- it is NOT identity verification, and the project's docs should say so plainly (anyone can type anyone's email). Upgrading later to a verified magic-link flow only requires replacing `create_session_cookie`/`read_session_cookie` with a real token-issuance step; nothing else in the app depends on how the email was obtained. """ from __future__ import annotations import hashlib import hmac import os import secrets from pathlib import Path from fastapi import Request, Response COOKIE_NAME = "evasion_annotator_session" CSRF_COOKIE_NAME = "evasion_annotator_csrf" # Real browsers treat localhost/127.0.0.1 as a "potentially trustworthy" # origin, so Secure cookies work fine there even over plain http:// -- but # curl and some other HTTP clients/proxies enforce Secure strictly by URL # scheme and will silently refuse to send the cookie back, which breaks # scripted testing against a local plain-http server. Default to secure # (correct for the real HF Space / any real HTTPS deployment); only flip # this for local scripted testing, e.g. `ANNOTATOR_COOKIE_SECURE=false`. COOKIE_SECURE = os.environ.get("ANNOTATOR_COOKIE_SECURE", "true").strip().lower() != "false" # Deliberately NOT a hardcoded default string: this file is meant to be read # on public GitHub, and a well-known default HMAC secret would let anyone # forge a session cookie for ANY email, not just skip verifying their own -- # a materially worse hole than the "light auth" design intends. If # ANNOTATOR_SECRET isn't set (the expected path for a real deployment -- # e.g. a Hugging Face Space secret), generate a random one on first run and # persist it next to the DB, so local/dev usage stays zero-config without # ever shipping a guessable secret. _SECRET_FILE = Path( os.environ.get("ANNOTATOR_SECRET_FILE", os.path.join(os.path.dirname(__file__), "..", "data", ".session_secret")) ) def _load_or_create_secret() -> str: env_secret = os.environ.get("ANNOTATOR_SECRET") if env_secret: return env_secret if _SECRET_FILE.exists(): return _SECRET_FILE.read_text(encoding="utf-8").strip() _SECRET_FILE.parent.mkdir(parents=True, exist_ok=True) value = secrets.token_hex(32) # os.open with O_CREAT|O_EXCL|0o600 rather than Path.write_text: the file # is created owner-read/write-only from the instant it exists (no window # where a default umask -- e.g. 0644 under the common 022 -- makes it # world-readable), and O_EXCL makes two processes racing to initialize # the secret fail closed (one raises FileExistsError) rather than one # silently overwriting the other's already-in-use signing key. try: fd = os.open(str(_SECRET_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600) with os.fdopen(fd, "w") as f: f.write(value) return value except FileExistsError: return _SECRET_FILE.read_text(encoding="utf-8").strip() SECRET = _load_or_create_secret() def _sign(email: str) -> str: return hmac.new(SECRET.encode(), email.encode(), hashlib.sha256).hexdigest() def create_session_cookie(email: str) -> str: email = email.strip().lower() return f"{email}|{_sign(email)}" def read_session_cookie(value: str | None) -> str | None: if not value or "|" not in value: return None email, sig = value.rsplit("|", 1) if hmac.compare_digest(sig, _sign(email)): return email return None def current_email(request: Request) -> str | None: return read_session_cookie(request.cookies.get(COOKIE_NAME)) # --- CSRF (synchronizer token pattern) ------------------------------------- # # /login is unauthenticated (that's the whole point -- there's no pre-existing # session to check), so the session cookie's SameSite=Lax setting can't # protect it: a cross-site auto-submitting
to /login carries no cookie # and needs none, since login() only reads the posted email. That's login # CSRF -- a forged page can silently hand a victim's browser a session for an # attacker-chosen identity. /submit has the same shape of gap for POSTs. # # Fix: every GET that renders a form gets (or reuses) a random per-browser # CSRF cookie, and the SAME value is rendered into that form as a hidden # field. On POST, the cookie value and the posted field must match. An # attacker's forged cross-site form can copy the *shape* of our form but # can't read the victim's httponly CSRF cookie to put the right value in it # (browser same-origin policy blocks that, and SameSite=Lax additionally # stops the cookie itself from ever reaching a forged cross-site POST) -- so # the two values won't match and the request is rejected. def get_csrf_token(request: Request) -> str: """The CSRF token for this browser: whatever it already has, or a fresh one if not. Pure/read-only -- call this before rendering a template (so the value can go into the form's hidden field), then pass the result to `set_csrf_cookie` once you have the Response object to set it on.""" return request.cookies.get(CSRF_COOKIE_NAME) or secrets.token_hex(32) def set_csrf_cookie(response: Response, token: str) -> None: response.set_cookie( CSRF_COOKIE_NAME, token, max_age=60 * 60 * 24 * 365, httponly=True, samesite="lax", secure=COOKIE_SECURE ) def verify_csrf(request: Request, posted_token: str | None) -> bool: cookie_token = request.cookies.get(CSRF_COOKIE_NAME) if not cookie_token or not posted_token: return False return hmac.compare_digest(cookie_token, posted_token)