"""CSRF protection (session-scoped token, no external dependency). Only enforced on POST/PUT/DELETE methods. All dashboard APIs are GET-only. """ import hmac import logging import secrets from flask import abort, request, session logger = logging.getLogger(__name__) def get_csrf_token() -> str: """Return the per-session CSRF token, generating one on first use.""" token = session.get("_csrf_token") if not token: token = secrets.token_hex(32) session["_csrf_token"] = token return token def validate_csrf() -> None: """Abort 403 if the CSRF token doesn't match the session token. Token may arrive via (checked in order): - X-CSRF-Token request header (fetch API) - csrf_token form field (classic HTML form POST) - csrf_token JSON body field (fetch JSON POST) """ expected = session.get("_csrf_token") provided = request.headers.get("X-CSRF-Token", "") if not provided: provided = request.form.get("csrf_token", "") if not provided and request.is_json: body = request.get_json(silent=True) or {} provided = body.get("csrf_token", "") if not expected or not provided or not hmac.compare_digest(expected, provided): logger.warning( "CSRF validation failed for %s from %s", request.path, request.remote_addr, ) abort(403) def init_app(app) -> None: """Expose csrf_token() to Jinja templates as a global.""" app.jinja_env.globals["csrf_token"] = get_csrf_token