diff --git a/app/main.py b/app/main.py index 829f71e..e31806d 100644 --- a/app/main.py +++ b/app/main.py @@ -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 ":", 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') diff --git a/app/templates/error.html b/app/templates/error.html new file mode 100644 index 0000000..17eb49b --- /dev/null +++ b/app/templates/error.html @@ -0,0 +1,21 @@ + + + + + + {{ code }} — DynDNS Manager + + + + + + + diff --git a/app/templates/login.html b/app/templates/login.html index aff9433..f5339c1 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -23,6 +23,7 @@ {% endwith %}
+
diff --git a/manage.sh b/manage.sh new file mode 100755 index 0000000..c8224b2 --- /dev/null +++ b/manage.sh @@ -0,0 +1,102 @@ +#!/usr/bin/env sh +# Verwaltungsskript für den DynDNS Manager: +# - Login-/DynDNS-Sperren aufheben (Brute-Force-Lockout) +# - Admin-Passwort zurücksetzen +# +# Läuft über einen kurzlebigen Container (docker-compose run --rm) und greift +# damit auf dieselbe SQLite-DB (./data) zu — funktioniert auch, wenn der +# laufende Container gerade gestoppt ist. + +set -eu + +SERVICE=dyndns + +# In das Skriptverzeichnis wechseln (dort liegt die docker-compose.yml) +cd "$(dirname "$0")" + +# docker compose v2 ("docker compose") oder v1 ("docker-compose")? +if docker compose version >/dev/null 2>&1; then + DC="docker compose" +elif command -v docker-compose >/dev/null 2>&1; then + DC="docker-compose" +else + echo "Fehler: weder 'docker compose' noch 'docker-compose' gefunden." >&2 + exit 1 +fi + +usage() { + cat <