Security-Hardening (Pentest-Findings F-02 bis F-07)

- CSRF-Schutz: session-gebundenes Token in allen POST-Formularen, serverseitig
  per before_request geprueft; /nic/update ausgenommen (Basic-Auth-API)
- Brute-Force-Schutz: DB-gestuetzter Login-Lockout pro Client-IP
  (5 Fehlversuche -> 15 min), echte IP via ProxyFix/X-Forwarded-For
- SSRF: validate_plesk_url() erzwingt http(s) und blockt Link-Local/Metadata,
  Multicast und reservierte Ziele
- Session-Cookies: HttpOnly, SameSite=Lax, Secure (per Env abschaltbar)
- Security-Header: CSP, X-Frame-Options, X-Content-Type-Options, Referrer-Policy
- Generische Plesk-Fehlermeldungen (keine internen URLs im UI)
- CSS/JS nach static/ ausgelagert -> strikte CSP ohne 'unsafe-inline'
- login_attempts-Tabelle + README-Security-Abschnitt

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-06-06 14:45:27 +02:00
parent 9c631992af
commit c3070469c1
10 changed files with 233 additions and 37 deletions
+31 -5
View File
@@ -34,10 +34,14 @@ sie samt Tabellen erzeugt; ein Default-Admin (`admin` / `admin`) wird angelegt.
### Umgebungsvariablen (`docker-compose.yml`)
| Variable | Bedeutung | Default |
|--------------|------------------------------------------------------|----------------------|
| `DB_PATH` | Pfad zur SQLite-Datei **im Container** | `/data/dyndns.db` |
| `SECRET_KEY` | Flask-Session-Key — **unbedingt ändern** (`openssl rand -hex 32`) | zufällig pro Start |
| Variable | Bedeutung | Default |
|-------------------------|------------------------------------------------------|----------------------|
| `DB_PATH` | Pfad zur SQLite-Datei **im Container** | `/data/dyndns.db` |
| `SECRET_KEY` | Flask-Session-Key — **unbedingt ändern** (`openssl rand -hex 32`) | zufällig pro Start |
| `SESSION_COOKIE_SECURE` | Session-Cookie nur über HTTPS senden. Für lokale http-Tests auf `0` setzen. | `1` |
> **`SECRET_KEY` unbedingt setzen:** Bleibt er auf dem Default, wird bei jedem
> Neustart ein zufälliger Key erzeugt und alle Sessions werden ungültig.
> **Wichtig:** Das Volume mountet ein **Verzeichnis** (`./data:/data`), nicht die
> Datei direkt. Würde man die noch nicht existierende Datei mounten
@@ -149,6 +153,27 @@ nginx-Direktiven einbinden.
---
## Sicherheit
- **CSRF-Schutz:** Alle ändernden POST-Formulare tragen ein Session-gebundenes
Token (`csrf_token`), das serverseitig per `before_request` geprüft wird. Der
Router-Endpoint `/nic/update` ist ausgenommen (reine API mit Basic-Auth).
- **Brute-Force-Schutz:** Nach `LOGIN_MAX_FAILS` (5) Fehlversuchen pro Client-IP
wird der Login für `LOGIN_LOCK_MINUTES` (15) gesperrt. Die echte Client-IP wird
über `X-Forwarded-For` (nginx, via `ProxyFix`) ermittelt.
- **Session-Cookies:** `HttpOnly`, `SameSite=Lax` und (per Default) `Secure`.
- **Security-Header:** `Content-Security-Policy`, `X-Frame-Options: DENY`,
`X-Content-Type-Options: nosniff`, `Referrer-Policy: no-referrer`.
- **SSRF-Schutz:** Die admin-konfigurierte Plesk-URL muss `http(s)` sein; Ziele
in Link-Local-/Cloud-Metadata- (`169.254.0.0/16`), Multicast- und reservierten
Bereichen werden abgelehnt. Private-/Loopback-Adressen bleiben erlaubt, da
Plesk häufig intern oder am selben Host läuft.
- **Generische Fehlermeldungen:** Verbindungsfehler zu Plesk leaken keine
internen URLs mehr; Details landen nur im Server-Log.
> **Erste Maßnahme nach dem Setup:** Das Default-Login `admin` / `admin` unter
> *Einstellungen → Admin-Passwort ändern* sofort ersetzen.
## Architektur
```
@@ -157,7 +182,8 @@ app/
├── database.py SQLite-Schema, Migration, Settings-Helfer
├── plesk.py Plesk-REST-API: Verbindungstest + A-Record anlegen/aktualisieren
├── wsgi.py gunicorn-Einstieg (ruft init_db beim Start)
── templates/ Bootstrap-Oberfläche
── templates/ Bootstrap-Oberfläche
└── static/ ausgelagertes CSS/JS (ermöglicht strikte CSP ohne 'unsafe-inline')
```
### Datenmodell
+6
View File
@@ -51,6 +51,12 @@ def init_db():
FOREIGN KEY (dyndns_user_id) REFERENCES dyndns_users(id)
);
CREATE TABLE IF NOT EXISTS login_attempts (
ip TEXT PRIMARY KEY,
fails INTEGER NOT NULL DEFAULT 0,
locked_until TEXT
);
CREATE TABLE IF NOT EXISTS update_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dyndns_user_id INTEGER NOT NULL,
+147 -3
View File
@@ -1,11 +1,17 @@
import hmac
import ipaddress
import os
import re
import secrets
import socket
import sqlite3
from datetime import datetime
from datetime import datetime, timedelta
from functools import wraps
from urllib.parse import urlparse
from flask import (Flask, Response, flash, redirect, render_template,
from flask import (Flask, Response, abort, flash, redirect, render_template,
request, session, url_for)
from werkzeug.middleware.proxy_fix import ProxyFix
from werkzeug.security import check_password_hash, generate_password_hash
from database import get_db, get_setting, init_db, set_setting
@@ -14,6 +20,67 @@ from plesk import test_connection, update_dns_record
app = Flask(__name__)
app.secret_key = os.environ.get('SECRET_KEY', os.urandom(32).hex())
# Hinter dem nginx-Reverse-Proxy: X-Forwarded-* auswerten, damit
# request.remote_addr die echte Client-IP liefert (für Login-Lockout & myip).
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
# Session-Cookie härten. SESSION_COOKIE_SECURE für lokale http-Tests via
# Env abschaltbar (Default an, da Produktivbetrieb hinter HTTPS läuft).
app.config.update(
SESSION_COOKIE_HTTPONLY=True,
SESSION_COOKIE_SAMESITE='Lax',
SESSION_COOKIE_SECURE=os.environ.get('SESSION_COOKIE_SECURE', '1') == '1',
)
# Endpunkte ohne CSRF-Token-Prüfung (reine API/Basic-Auth, keine Cookies).
CSRF_EXEMPT = {'dyndns_update'}
# Login-Brute-Force-Schutz
LOGIN_MAX_FAILS = 5
LOGIN_LOCK_MINUTES = 15
# Security-Header. CSS/JS sind ausgelagert (static/), daher kein 'unsafe-inline'.
# Erlaubt sind nur die eigene Origin und das Bootstrap-CDN.
CSP = (
"default-src 'self'; "
"img-src 'self' data:; "
"style-src 'self' https://cdn.jsdelivr.net; "
"script-src 'self' https://cdn.jsdelivr.net; "
"font-src 'self' https://cdn.jsdelivr.net; "
"form-action 'self'; frame-ancestors 'none'; base-uri 'self'"
)
@app.after_request
def _security_headers(resp):
resp.headers['X-Frame-Options'] = 'DENY'
resp.headers['X-Content-Type-Options'] = 'nosniff'
resp.headers['Referrer-Policy'] = 'no-referrer'
resp.headers.setdefault('Content-Security-Policy', CSP)
return resp
# ---------------------------------------------------------------------------
# CSRF-Schutz (schlanker Session-Token, ohne externe Abhängigkeit)
# ---------------------------------------------------------------------------
@app.before_request
def _csrf_protect():
if request.endpoint in CSRF_EXEMPT:
return
if '_csrf_token' not in session:
session['_csrf_token'] = secrets.token_urlsafe(32)
if request.method in ('POST', 'PUT', 'PATCH', 'DELETE'):
token = session.get('_csrf_token', '')
sent = request.form.get('csrf_token', '')
if not token or not sent or not hmac.compare_digest(token, sent):
abort(400, 'CSRF-Token ungültig oder fehlt.')
@app.context_processor
def _inject_csrf():
return {'csrf_token': lambda: session.get('_csrf_token', '')}
# ---------------------------------------------------------------------------
# Helpers
@@ -28,6 +95,67 @@ def login_required(f):
return wrapped
# --- Login-Brute-Force-Schutz (DB-gestützt, pro Client-IP) -----------------
def _login_locked_for(ip):
"""Sekunden bis zur Entsperrung, oder 0 wenn nicht gesperrt."""
db = get_db()
row = db.execute('SELECT locked_until FROM login_attempts WHERE ip = ?', (ip,)).fetchone()
db.close()
if row and row['locked_until']:
try:
until = datetime.strptime(row['locked_until'], '%Y-%m-%d %H:%M:%S')
except ValueError:
return 0
remaining = (until - datetime.now()).total_seconds()
return int(remaining) if remaining > 0 else 0
return 0
def _record_login_fail(ip):
db = get_db()
row = db.execute('SELECT fails FROM login_attempts WHERE ip = ?', (ip,)).fetchone()
fails = (row['fails'] if row else 0) + 1
locked_until = None
if fails >= LOGIN_MAX_FAILS:
locked_until = (datetime.now() + timedelta(minutes=LOGIN_LOCK_MINUTES)
).strftime('%Y-%m-%d %H:%M:%S')
db.execute(
'INSERT INTO login_attempts (ip, fails, locked_until) VALUES (?, ?, ?) '
'ON CONFLICT(ip) DO UPDATE SET fails = excluded.fails, locked_until = excluded.locked_until',
(ip, fails, locked_until),
)
db.commit()
db.close()
def _reset_login_fails(ip):
db = get_db()
db.execute('DELETE FROM login_attempts WHERE ip = ?', (ip,))
db.commit()
db.close()
# --- SSRF-Schutz für die admin-konfigurierte Plesk-URL ---------------------
def validate_plesk_url(url):
"""(ok, fehlermeldung). Lässt nur http(s) zu und blockt Link-Local- /
Cloud-Metadata- (169.254.0.0/16), Multicast- und reservierte Ziele.
Private/Loopback bleiben erlaubt — Plesk läuft oft intern/am selben Host."""
p = urlparse(url)
if p.scheme not in ('http', 'https') or not p.hostname:
return False, 'Nur gültige http(s)-URLs erlaubt.'
try:
infos = socket.getaddrinfo(p.hostname, p.port or (443 if p.scheme == 'https' else 80))
except (socket.gaierror, UnicodeError):
return False, 'Hostname nicht auflösbar.'
for info in infos:
ip = ipaddress.ip_address(info[4][0])
if ip.is_link_local or ip.is_multicast or ip.is_reserved or ip.is_unspecified:
return False, 'Ziel-Adresse nicht erlaubt (Link-Local/Reserved).'
return True, ''
# ---------------------------------------------------------------------------
# Auth
# ---------------------------------------------------------------------------
@@ -40,6 +168,12 @@ def index():
@app.route('/login', methods=['GET', 'POST'])
def login():
if request.method == 'POST':
ip = request.remote_addr or 'unknown'
locked = _login_locked_for(ip)
if locked:
flash(f'Zu viele Fehlversuche. Bitte in {locked // 60 + 1} Minuten erneut versuchen.', 'danger')
return render_template('login.html')
username = request.form.get('username', '').strip()
password = request.form.get('password', '')
db = get_db()
@@ -48,9 +182,13 @@ def login():
).fetchone()
db.close()
if admin and check_password_hash(admin['password_hash'], password):
_reset_login_fails(ip)
session.clear()
session['admin_id'] = admin['id']
session['admin_username'] = admin['username']
return redirect(url_for('dashboard'))
_record_login_fail(ip)
flash('Benutzername oder Passwort falsch.', 'danger')
return render_template('login.html')
@@ -114,6 +252,11 @@ def settings_plesk():
plesk_base_domain = request.form.get('plesk_base_domain', '').strip()
plesk_verify_ssl = '1' if request.form.get('plesk_verify_ssl') else '0'
ok, err = validate_plesk_url(plesk_url)
if not ok:
flash(f'Plesk-URL abgelehnt: {err}', 'danger')
return redirect(url_for('settings'))
set_setting('plesk_url', plesk_url)
set_setting('plesk_api_key', plesk_api_key)
set_setting('plesk_base_domain', plesk_base_domain)
@@ -125,7 +268,8 @@ def settings_plesk():
ver = info.get('version', '?')
flash(f'Verbindung OK — Plesk {ver}', 'success')
except Exception as exc:
flash(f'Verbindungsfehler: {exc}', 'danger')
app.logger.warning('Plesk-Verbindungstest fehlgeschlagen: %s', exc)
flash('Verbindungsfehler — bitte URL, API-Schlüssel und Erreichbarkeit prüfen.', 'danger')
else:
flash('Plesk-Einstellungen gespeichert.', 'success')
+20
View File
@@ -0,0 +1,20 @@
:root { --sidebar-bg: #1e2a38; --sidebar-hover: #2d3f52; }
body { background: #f4f6f9; min-height: 100vh; }
#sidebar {
width: 220px; min-width: 220px; min-height: 100vh;
background: var(--sidebar-bg); color: #cdd6e0;
}
#sidebar .brand { color: #4fc3f7; font-weight: 700; font-size: 1.1rem; }
#sidebar .nav-link {
color: #b0bec5; border-radius: 6px; padding: .5rem .75rem;
margin-bottom: 2px; transition: background .15s;
}
#sidebar .nav-link:hover, #sidebar .nav-link.active {
background: var(--sidebar-hover); color: #fff;
}
#sidebar .nav-link i { width: 1.3em; }
#sidebar hr { border-color: var(--sidebar-hover); }
.main-content { flex: 1; padding: 2rem; min-width: 0; }
.card { border: none; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.badge-ip { font-family: monospace; font-size: .85em; }
.va-baseline { vertical-align: baseline; }
+2
View File
@@ -0,0 +1,2 @@
body { background: #1e2a38; min-height: 100vh; display: flex; align-items: center; }
.login-card { width: 100%; max-width: 380px; }
+10
View File
@@ -0,0 +1,10 @@
// Bestätigungsdialog für Formulare mit data-confirm — ersetzt inline onsubmit,
// damit die CSP ohne 'unsafe-inline' für script-src auskommt.
document.addEventListener('submit', function (e) {
var form = e.target;
if (form && form.dataset && form.dataset.confirm) {
if (!window.confirm(form.dataset.confirm)) {
e.preventDefault();
}
}
});
+3 -21
View File
@@ -6,26 +6,7 @@
<title>{% block title %}DynDNS Manager{% endblock %}</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<style>
:root { --sidebar-bg: #1e2a38; --sidebar-hover: #2d3f52; }
body { background: #f4f6f9; min-height: 100vh; }
#sidebar {
width: 220px; min-width: 220px; min-height: 100vh;
background: var(--sidebar-bg); color: #cdd6e0;
}
#sidebar .brand { color: #4fc3f7; font-weight: 700; font-size: 1.1rem; }
#sidebar .nav-link {
color: #b0bec5; border-radius: 6px; padding: .5rem .75rem;
margin-bottom: 2px; transition: background .15s;
}
#sidebar .nav-link:hover, #sidebar .nav-link.active {
background: var(--sidebar-hover); color: #fff;
}
#sidebar .nav-link i { width: 1.3em; }
.main-content { flex: 1; padding: 2rem; min-width: 0; }
.card { border: none; box-shadow: 0 1px 4px rgba(0,0,0,.08); }
.badge-ip { font-family: monospace; font-size: .85em; }
</style>
<link rel="stylesheet" href="{{ url_for('static', filename='css/app.css') }}">
</head>
<body class="d-flex">
@@ -48,7 +29,7 @@
</a>
</div>
<div class="mt-auto">
<hr style="border-color:#2d3f52">
<hr>
<small class="text-secondary d-block mb-2 px-2">{{ session.admin_username }}</small>
<a href="{{ url_for('logout') }}" class="nav-link text-danger">
<i class="bi bi-box-arrow-left"></i> Abmelden
@@ -70,6 +51,7 @@
</div>
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js"></script>
<script src="{{ url_for('static', filename='js/app.js') }}"></script>
{% block scripts %}{% endblock %}
</body>
</html>
+3 -5
View File
@@ -5,10 +5,8 @@
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Login — DynDNS Manager</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css">
<style>
body { background: #1e2a38; min-height: 100vh; display: flex; align-items: center; }
.login-card { width: 100%; max-width: 380px; }
</style>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
<link rel="stylesheet" href="{{ url_for('static', filename='css/login.css') }}">
</head>
<body class="justify-content-center">
<div class="login-card">
@@ -24,6 +22,7 @@
{% endfor %}
{% endwith %}
<form method="post">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label fw-semibold">Benutzername</label>
<input name="username" type="text" class="form-control" autofocus required>
@@ -37,6 +36,5 @@
</div>
</div>
</div>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
</body>
</html>
+2
View File
@@ -12,6 +12,7 @@
<div class="card-header fw-semibold">Plesk-Server</div>
<div class="card-body">
<form method="post" action="{{ url_for('settings_plesk') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Plesk URL</label>
<input name="plesk_url" type="url" class="form-control font-monospace"
@@ -57,6 +58,7 @@
<div class="card-header fw-semibold">Admin-Passwort ändern</div>
<div class="card-body">
<form method="post" action="{{ url_for('settings_password') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="mb-3">
<label class="form-label">Aktuelles Passwort</label>
<input name="current_password" type="password" class="form-control" required>
+9 -3
View File
@@ -31,9 +31,10 @@
{% if base_domain %}{{ s.subdomain }}.{{ base_domain }}{% else %}{{ s.subdomain }}{% endif %}
{% if s.current_ip %}<span class="text-muted">· {{ s.current_ip }}</span>{% endif %}
<form method="post" action="{{ url_for('subdomain_delete', subdomain_id=s.id) }}"
class="d-inline" onsubmit="return confirm('Subdomain {{ s.subdomain }} löschen?');">
<button type="submit" class="btn btn-link btn-sm p-0 ms-1 text-danger" title="Subdomain löschen"
style="vertical-align: baseline;"><i class="bi bi-x-lg"></i></button>
class="d-inline" data-confirm="Subdomain {{ s.subdomain }} löschen?">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-link btn-sm p-0 ms-1 text-danger va-baseline"
title="Subdomain löschen"><i class="bi bi-x-lg"></i></button>
</form>
</span>
{% else %}
@@ -60,6 +61,7 @@
<i class="bi bi-pencil"></i>
</button>
<form method="post" action="{{ url_for('user_toggle', user_id=u.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-outline-warning" title="Aktivieren/Deaktivieren">
<i class="bi bi-{% if u.active %}pause{% else %}play{% endif %}-fill"></i>
</button>
@@ -82,6 +84,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('subdomain_add', user_id=u.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<label class="form-label">Subdomain(s)</label>
<div class="input-group">
@@ -111,6 +114,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('user_edit', user_id=u.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">DynDNS-Benutzername</label>
@@ -148,6 +152,7 @@
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">Abbrechen</button>
<form method="post" action="{{ url_for('user_delete', user_id=u.id) }}" class="d-inline">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="btn btn-danger">Löschen</button>
</form>
</div>
@@ -174,6 +179,7 @@
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<form method="post" action="{{ url_for('user_add') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="modal-body">
<div class="mb-3">
<label class="form-label">DynDNS-Benutzername</label>