F-10/F-11 + Verwaltungsskript

- F-10 Open Redirect: is_safe_url()-Pruefung des next-Parameters beim Login;
  login_required gibt next weiter, Login-Formular traegt es als Hidden-Feld.
  Externe/protokoll-relative Ziele werden ignoriert -> Dashboard.
- F-11 Info-Leak: eigene Fehlerseiten (400/403/404/405/500) ohne Framework-
  Hinweis oder Stacktrace (templates/error.html).
- manage.sh: 'unlock' (Brute-Force-Sperren aufheben) und 'reset-password'
  (Admin-Passwort setzen/zufaellig erzeugen) via docker-compose run.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-06-06 15:49:04 +02:00
parent a5787a5393
commit 91993eb487
4 changed files with 180 additions and 3 deletions
+56 -3
View File
@@ -7,7 +7,7 @@ import socket
import sqlite3
from datetime import datetime, timedelta
from functools import wraps
from urllib.parse import urlparse
from urllib.parse import urljoin, urlparse
from flask import (Flask, Response, abort, flash, redirect, render_template,
request, session, url_for)
@@ -82,6 +82,49 @@ def _inject_csrf():
return {'csrf_token': lambda: session.get('_csrf_token', '')}
# ---------------------------------------------------------------------------
# Eigene Fehlerseiten (verraten kein Framework, kein Stacktrace)
# ---------------------------------------------------------------------------
ERROR_MESSAGES = {
400: 'Ungültige Anfrage.',
403: 'Zugriff verweigert.',
404: 'Seite nicht gefunden.',
405: 'Methode nicht erlaubt.',
500: 'Interner Serverfehler.',
}
def _render_error(code):
return render_template('error.html', code=code,
message=ERROR_MESSAGES.get(code, 'Fehler')), code
@app.errorhandler(400)
def _err_400(e):
return _render_error(400)
@app.errorhandler(403)
def _err_403(e):
return _render_error(403)
@app.errorhandler(404)
def _err_404(e):
return _render_error(404)
@app.errorhandler(405)
def _err_405(e):
return _render_error(405)
@app.errorhandler(500)
def _err_500(e):
return _render_error(500)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
@@ -90,11 +133,20 @@ def login_required(f):
@wraps(f)
def wrapped(*args, **kwargs):
if 'admin_id' not in session:
return redirect(url_for('login'))
return redirect(url_for('login', next=request.path))
return f(*args, **kwargs)
return wrapped
def is_safe_url(target):
"""True nur für relative Ziele auf derselben Origin (Open-Redirect-Schutz)."""
if not target:
return False
ref = urlparse(request.host_url)
test = urlparse(urljoin(request.host_url, target))
return test.scheme in ('http', 'https') and ref.netloc == test.netloc
# --- Brute-Force-Schutz (DB-gestützt, pro Scope + Client-IP) ---------------
# Der Schlüssel ist "<scope>:<ip>", damit Admin-Login ('login') und
# DynDNS-Update ('dyndns') getrennt gezählt werden.
@@ -194,7 +246,8 @@ def login():
session.clear()
session['admin_id'] = admin['id']
session['admin_username'] = admin['username']
return redirect(url_for('dashboard'))
nxt = request.form.get('next') or request.args.get('next')
return redirect(nxt if is_safe_url(nxt) else url_for('dashboard'))
_record_login_fail('login', ip)
flash('Benutzername oder Passwort falsch.', 'danger')