diff --git a/README.md b/README.md index 2d25c40..a0a9c44 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/app/database.py b/app/database.py index 679de32..574a55f 100644 --- a/app/database.py +++ b/app/database.py @@ -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, diff --git a/app/main.py b/app/main.py index d10ade7..1e8c6c4 100644 --- a/app/main.py +++ b/app/main.py @@ -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') diff --git a/app/static/css/app.css b/app/static/css/app.css new file mode 100644 index 0000000..ae8312d --- /dev/null +++ b/app/static/css/app.css @@ -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; } diff --git a/app/static/css/login.css b/app/static/css/login.css new file mode 100644 index 0000000..273c401 --- /dev/null +++ b/app/static/css/login.css @@ -0,0 +1,2 @@ +body { background: #1e2a38; min-height: 100vh; display: flex; align-items: center; } +.login-card { width: 100%; max-width: 380px; } diff --git a/app/static/js/app.js b/app/static/js/app.js new file mode 100644 index 0000000..3f809f9 --- /dev/null +++ b/app/static/js/app.js @@ -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(); + } + } +}); diff --git a/app/templates/base.html b/app/templates/base.html index c8ae1fc..3dc9fbe 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -6,26 +6,7 @@ {% block title %}DynDNS Manager{% endblock %} - + @@ -48,7 +29,7 @@
-
+
{{ session.admin_username }} Abmelden @@ -70,6 +51,7 @@
+ {% block scripts %}{% endblock %} diff --git a/app/templates/login.html b/app/templates/login.html index db019c3..aff9433 100644 --- a/app/templates/login.html +++ b/app/templates/login.html @@ -5,10 +5,8 @@ Login — DynDNS Manager - + +
@@ -24,6 +22,7 @@ {% endfor %} {% endwith %}
+
@@ -37,6 +36,5 @@
- diff --git a/app/templates/settings.html b/app/templates/settings.html index ad9da9b..52c7442 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -12,6 +12,7 @@
Plesk-Server
+
Admin-Passwort ändern
+
diff --git a/app/templates/users.html b/app/templates/users.html index 310bc20..16040bc 100644 --- a/app/templates/users.html +++ b/app/templates/users.html @@ -31,9 +31,10 @@ {% if base_domain %}{{ s.subdomain }}.{{ base_domain }}{% else %}{{ s.subdomain }}{% endif %} {% if s.current_ip %}· {{ s.current_ip }}{% endif %} - + class="d-inline" data-confirm="Subdomain {{ s.subdomain }} löschen?"> + + {% else %} @@ -60,6 +61,7 @@
+ @@ -82,6 +84,7 @@
+