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:
+56
-3
@@ -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')
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{{ code }} — DynDNS Manager</title>
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
|
||||
</head>
|
||||
<body class="justify-content-center">
|
||||
<div class="login-card text-center">
|
||||
<div class="card shadow">
|
||||
<div class="card-body p-5">
|
||||
<div class="display-4 fw-bold text-primary mb-2">{{ code }}</div>
|
||||
<p class="text-muted mb-4">{{ message }}</p>
|
||||
<a href="{{ url_for('dashboard') }}" class="btn btn-primary">Zur Startseite</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
@@ -23,6 +23,7 @@
|
||||
{% endwith %}
|
||||
<form method="post">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="hidden" name="next" value="{{ request.args.get('next', '') }}">
|
||||
<div class="mb-3">
|
||||
<label class="form-label fw-semibold">Benutzername</label>
|
||||
<input name="username" type="text" class="form-control" autofocus required>
|
||||
|
||||
@@ -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 <<EOF
|
||||
Verwaltung DynDNS Manager
|
||||
|
||||
$0 unlock [IP]
|
||||
Hebt Login-/DynDNS-Sperren auf. Ohne IP werden ALLE Sperren entfernt,
|
||||
mit IP nur die Einträge dieser Client-IP (Login und DynDNS).
|
||||
|
||||
$0 reset-password [BENUTZER] [PASSWORT]
|
||||
Setzt das Admin-Passwort. BENUTZER ist standardmäßig "admin".
|
||||
Ohne PASSWORT wird ein sicheres Zufallspasswort erzeugt und angezeigt.
|
||||
|
||||
Beispiele:
|
||||
$0 unlock
|
||||
$0 unlock 203.0.113.7
|
||||
$0 reset-password
|
||||
$0 reset-password admin MeinNeuesPW
|
||||
EOF
|
||||
}
|
||||
|
||||
case "${1:-}" in
|
||||
unlock)
|
||||
IP="${2:-}"
|
||||
$DC run --rm -T -e IP="$IP" "$SERVICE" python - <<'PY'
|
||||
import os
|
||||
from database import get_db
|
||||
ip = os.environ.get('IP', '').strip()
|
||||
db = get_db()
|
||||
if ip:
|
||||
cur = db.execute("DELETE FROM login_attempts WHERE ip = ? OR ip = ?",
|
||||
('login:' + ip, 'dyndns:' + ip))
|
||||
else:
|
||||
cur = db.execute("DELETE FROM login_attempts")
|
||||
db.commit()
|
||||
n = cur.rowcount
|
||||
db.close()
|
||||
print(f"{n} Sperr-Eintrag/Einträge entfernt" + (f" für {ip}." if ip else " (alle)."))
|
||||
PY
|
||||
;;
|
||||
|
||||
reset-password)
|
||||
RESET_USER="${2:-admin}"
|
||||
RESET_PW="${3:-}"
|
||||
$DC run --rm -T -e RESET_USER="$RESET_USER" -e RESET_PW="$RESET_PW" "$SERVICE" python - <<'PY'
|
||||
import os
|
||||
import secrets
|
||||
from database import get_db
|
||||
from werkzeug.security import generate_password_hash
|
||||
|
||||
user = os.environ.get('RESET_USER') or 'admin'
|
||||
pw = os.environ.get('RESET_PW') or ''
|
||||
generated = False
|
||||
if not pw:
|
||||
pw = secrets.token_urlsafe(12)
|
||||
generated = True
|
||||
|
||||
db = get_db()
|
||||
cur = db.execute("UPDATE admin_users SET password_hash = ? WHERE username = ?",
|
||||
(generate_password_hash(pw), user))
|
||||
db.commit()
|
||||
n = cur.rowcount
|
||||
db.close()
|
||||
|
||||
if n == 0:
|
||||
raise SystemExit(f"FEHLER: Admin-Benutzer '{user}' nicht gefunden.")
|
||||
print(f"Passwort für '{user}' gesetzt.")
|
||||
if generated:
|
||||
print(f"Neues Passwort: {pw}")
|
||||
PY
|
||||
;;
|
||||
|
||||
*)
|
||||
usage
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
Reference in New Issue
Block a user