50 lines
1.7 KiB
Python
50 lines
1.7 KiB
Python
"""CSRF-Schutz – leichtgewichtig, ohne Flask-WTF (siehe CLAUDE.md: „so wenig wie möglich").
|
||
|
||
Ein Session-gebundenes Token wird in jedes POST-Formular geschrieben und bei jeder
|
||
unsicheren Anfrage geprüft. Reicht für eine interne App mit Session-Cookies.
|
||
|
||
Nutzung im Template:
|
||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||
"""
|
||
|
||
import hmac
|
||
import secrets
|
||
|
||
from flask import abort, render_template, request, session
|
||
|
||
CSRF_SESSION_KEY = "_csrf_token"
|
||
CSRF_FORM_FIELD = "csrf_token"
|
||
CSRF_HEADER = "X-CSRFToken"
|
||
SAFE_METHODS = {"GET", "HEAD", "OPTIONS", "TRACE"}
|
||
|
||
|
||
def generate_csrf():
|
||
"""Token der aktuellen Session zurückgeben (beim ersten Aufruf erzeugen)."""
|
||
if CSRF_SESSION_KEY not in session:
|
||
session[CSRF_SESSION_KEY] = secrets.token_urlsafe(32)
|
||
return session[CSRF_SESSION_KEY]
|
||
|
||
|
||
def _validate_csrf():
|
||
erwartet = session.get(CSRF_SESSION_KEY)
|
||
gesendet = request.form.get(CSRF_FORM_FIELD) or request.headers.get(CSRF_HEADER)
|
||
# compare_digest: konstante Laufzeit, verträgt aber keine None-Werte.
|
||
if not erwartet or not gesendet or not hmac.compare_digest(erwartet, gesendet):
|
||
abort(400, description="csrf")
|
||
|
||
|
||
def init_csrf(app):
|
||
@app.before_request
|
||
def csrf_protect():
|
||
if request.method not in SAFE_METHODS:
|
||
_validate_csrf()
|
||
|
||
@app.errorhandler(400)
|
||
def csrf_fehler(err):
|
||
# Nur die CSRF-Fälle bekommen die freundliche Seite; andere 400 unverändert.
|
||
if getattr(err, "description", None) == "csrf":
|
||
return render_template("400_csrf.html"), 400
|
||
return err
|
||
|
||
# Im Template als csrf_token() verfügbar machen.
|
||
app.jinja_env.globals["csrf_token"] = generate_csrf
|