From 5e69c29f16bc730c70329f6ccc3992b99c17d596 Mon Sep 17 00:00:00 2001 From: sysops Date: Tue, 17 Mar 2026 15:23:31 +0100 Subject: [PATCH] feat: Security Audit Auto-Fix Buttons + manuelle Dokumentation (docs/security-audit.md) --- docs/security-audit.md | 270 +++++++++++++++++++++++++++++++++++++++++ internal/api/server.go | 121 ++++++++++++++++++ src/app/admin/page.tsx | 140 +++++++++++++++------ src/lib/api.ts | 7 ++ 4 files changed, 498 insertions(+), 40 deletions(-) create mode 100644 docs/security-audit.md diff --git a/docs/security-audit.md b/docs/security-audit.md new file mode 100644 index 0000000..d0c871f --- /dev/null +++ b/docs/security-audit.md @@ -0,0 +1,270 @@ +# Security Audit — archivmail + +Dieses Dokument beschreibt alle Security-Checks die das System automatisch durchführt, +welche Werte als sicher gelten und wie Probleme manuell behoben werden können. + +--- + +## Übersicht der Checks + +| Check | OK-Kriterium | Auto-Fix verfügbar | +|------------------------|----------------------------------------------|--------------------| +| Firewall (nftables) | Input-Chain policy: drop | Ja | +| Port 3000 (Next.js) | Nicht in Whitelist | Nein (via Firewall)| +| Port 8080 (Go Backend) | Nicht in Whitelist | Nein (via Firewall)| +| HTTPS (TLS) | Port 443 in Firewall + Zertifikat vorhanden | Nein (manuell) | +| SSH Root-Login | `prohibit-password` oder `no` | Ja | +| SSH Passwort-Auth | `PasswordAuthentication no` | Ja | +| Fail2ban | Dienst aktiv | Ja | + +--- + +## 1. Firewall (nftables) + +### Was wird geprüft +Ob `nft list ruleset` eine Input-Chain mit `policy drop` zurückgibt. +Whitelist-Modus: Nur explizit erlaubte Ports sind erreichbar. + +### Erlaubte externe Ports +| Port | Dienst | +|------|-------------------| +| 22 | SSH | +| 80 | nginx (Web-UI) | +| 2525 | SMTP Einlieferung | + +### Manuell prüfen +```bash +ssh root@192.168.1.131 + +# Aktuelle Regeln anzeigen +nft list ruleset + +# Offene Ports prüfen +ss -tlnp +``` + +### Manuell beheben +```bash +# Konfiguration neu laden +nft -f /etc/nftables.conf + +# Beim Systemstart aktivieren +systemctl enable nftables +systemctl restart nftables + +# Prüfen ob Regeln aktiv sind +nft list ruleset | grep "policy drop" +``` + +### Konfigurationsdatei +`/etc/nftables.conf` — Änderungen hier sind persistent. + +--- + +## 2. Port 3000 / Port 8080 + +### Was wird geprüft +Ob diese Ports explizit in der Firewall-Whitelist stehen. +Beide Dienste sollen nur intern erreichbar sein (nginx proxied den Zugriff). + +### Warum gefährlich +- **Port 3000**: Next.js Frontend — keine eigene Authentifizierung auf diesem Port +- **Port 8080**: Go Backend REST API — direkte API-Zugriffe ohne nginx möglich + +### Manuell prüfen +```bash +# Lauscht Port 3000 auf allen Interfaces? +ss -tlnp | grep 3000 +# => Wenn "0.0.0.0:3000" erscheint, ist er intern offen (Firewall muss blockieren) + +# Ist Port von außen erreichbar? +nft list ruleset | grep "dport 3000" +# => Wenn keine Zeile erscheint: gut (policy drop blockiert ihn) +``` + +### Manuell beheben (falls Firewall fehlt) +```bash +# Firewall neu laden — blockiert automatisch 3000 und 8080 +nft -f /etc/nftables.conf +``` + +--- + +## 3. HTTPS (TLS) + +### Was wird geprüft +Ob Port 443 in der Firewall freigegeben ist. +Aktuell läuft archivmail nur über HTTP (Port 80) — Verbindungen sind unverschlüsselt. + +### Warum wichtig +Ohne TLS werden Passwörter und E-Mail-Inhalte im Klartext übertragen. + +### Manuell einrichten (Let's Encrypt / certbot) +```bash +ssh root@192.168.1.131 + +# certbot installieren +apt-get install -y certbot python3-certbot-nginx + +# Zertifikat ausstellen (Domain muss auf den Server zeigen) +certbot --nginx -d archivmail.example.com + +# Firewall: Port 443 freigeben +# In /etc/nftables.conf folgende Zeile in chain input ergänzen: +# tcp dport 443 accept +nft -f /etc/nftables.conf + +# Automatische Erneuerung prüfen +certbot renew --dry-run +``` + +### nginx nach certbot +certbot passt die nginx-Konfiguration automatisch an und fügt SSL + Redirect HTTP→HTTPS hinzu. + +--- + +## 4. SSH Root-Login + +### Was wird geprüft +Den Wert von `PermitRootLogin` in `/etc/ssh/sshd_config`. + +| Wert | Bewertung | +|--------------------|-----------| +| `no` | OK | +| `prohibit-password`| OK (Key-Auth für root erlaubt) | +| `yes` | Warnung | +| nicht gesetzt | Warnung (Default ist prohibit-password) | + +### Manuell prüfen +```bash +grep -i "PermitRootLogin" /etc/ssh/sshd_config +``` + +### Manuell beheben +```bash +# Datei bearbeiten +nano /etc/ssh/sshd_config + +# Zeile setzen oder ändern: +PermitRootLogin prohibit-password + +# SSH-Dienst neu starten +systemctl restart ssh + +# Prüfen +sshd -T | grep permitrootlogin +``` + +--- + +## 5. SSH Passwort-Authentifizierung + +### Was wird geprüft +Den Wert von `PasswordAuthentication` in `/etc/ssh/sshd_config`. +Empfehlung: Nur SSH-Key-Authentifizierung erlauben. + +### Manuell prüfen +```bash +grep -i "PasswordAuthentication" /etc/ssh/sshd_config +sshd -T | grep passwordauthentication +``` + +### Manuell beheben +```bash +# Sicherstellen dass ein SSH-Key hinterlegt ist, BEVOR Passwort-Auth deaktiviert wird +cat ~/.ssh/authorized_keys # muss mindestens einen Eintrag enthalten + +# Dann in /etc/ssh/sshd_config: +PasswordAuthentication no + +# SSH neu starten +systemctl restart ssh +``` + +> **Warnung:** Passwort-Auth nur deaktivieren wenn ein SSH-Key in `~/.ssh/authorized_keys` vorhanden ist. +> Sonst ist kein Login mehr möglich. + +--- + +## 6. Fail2ban + +### Was wird geprüft +Ob der `fail2ban`-Systemd-Dienst aktiv ist. + +### Was ist Fail2ban +Fail2ban überwacht Log-Dateien und blockiert IPs automatisch bei zu vielen fehlgeschlagenen Login-Versuchen (Brute-Force-Schutz). + +### Manuell prüfen +```bash +systemctl status fail2ban + +# Aktive Jails anzeigen +fail2ban-client status + +# SSH-Jail prüfen +fail2ban-client status sshd + +# Gesperrte IPs anzeigen +fail2ban-client status sshd | grep "Banned IP" +``` + +### Manuell installieren und konfigurieren +```bash +apt-get install -y fail2ban + +# Konfiguration erstellen +cat > /etc/fail2ban/jail.local << 'EOF' +[sshd] +enabled = true +maxretry = 5 +bantime = 3600 +findtime = 600 + +[nginx-http-auth] +enabled = true +EOF + +systemctl enable fail2ban +systemctl start fail2ban + +# Prüfen +fail2ban-client status +``` + +### Gesperrte IP manuell entsperren +```bash +fail2ban-client set sshd unbanip +``` + +--- + +## Kompletter manueller Audit (alle Checks auf einmal) + +```bash +ssh root@192.168.1.131 + +echo "=== Firewall ===" +nft list ruleset | grep -E "policy|dport" + +echo "=== Offene Ports ===" +ss -tlnp + +echo "=== SSH Config ===" +sshd -T | grep -E "permitrootlogin|passwordauthentication" + +echo "=== Fail2ban ===" +systemctl is-active fail2ban +fail2ban-client status 2>/dev/null || echo "nicht installiert" + +echo "=== nftables Dienst ===" +systemctl is-enabled nftables +``` + +--- + +## Weiterführende Ressourcen + +- [Debian SSH Hardening](https://wiki.debian.org/SSH) +- [nftables Dokumentation](https://wiki.nftables.org/) +- [Fail2ban Dokumentation](https://www.fail2ban.org/wiki/index.php/MANUAL_0_8) +- [Let's Encrypt / certbot](https://certbot.eff.org/) diff --git a/internal/api/server.go b/internal/api/server.go index 5ffb2d1..ed91678 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -106,6 +106,7 @@ func (s *Server) routes() { s.mux.HandleFunc("GET /api/admin/system/stats", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSystemStats))) s.mux.HandleFunc("GET /api/admin/security/audit", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityAudit))) + s.mux.HandleFunc("POST /api/admin/security/fix", s.authMiddleware(s.requireRole(userstore.RoleAdmin, s.handleSecurityFix))) // Export routes s.mux.HandleFunc("GET /api/export/pdf/{id}", s.authMiddleware(s.requireMailAccess(s.handleExportPDF))) @@ -1598,3 +1599,123 @@ func (s *Server) handleSecurityAudit(w http.ResponseWriter, r *http.Request) { "run_at": time.Now().UTC().Format(time.RFC3339), }) } + +// ── Security Fix ──────────────────────────────────────────────────────────── + +// allowedFixActions is a strict whitelist — only these actions may be executed. +var allowedFixActions = map[string]bool{ + "install_fail2ban": true, + "enable_firewall": true, + "fix_ssh_password_auth": true, + "fix_ssh_root_login": true, +} + +func (s *Server) handleSecurityFix(w http.ResponseWriter, r *http.Request) { + var body struct { + Action string `json:"action"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + if !allowedFixActions[body.Action] { + writeError(w, http.StatusBadRequest, "unknown action: "+body.Action) + return + } + + ctx := r.Context() + var msg string + var fixErr error + + switch body.Action { + + case "install_fail2ban": + // Install fail2ban if not present + if _, err := exec.LookPath("fail2ban-client"); err != nil { + out, err := exec.CommandContext(ctx, "apt-get", "install", "-y", "fail2ban").CombinedOutput() + if err != nil { + writeError(w, http.StatusInternalServerError, "apt-get install fail2ban: "+string(out)) + return + } + } + // Write a minimal jail.local if not already present + jailPath := "/etc/fail2ban/jail.local" + if _, err := os.Stat(jailPath); os.IsNotExist(err) { + jailConf := "[sshd]\nenabled = true\nmaxretry = 5\nbantime = 3600\nfindtime = 600\n" + if err := os.WriteFile(jailPath, []byte(jailConf), 0644); err != nil { + writeError(w, http.StatusInternalServerError, "could not write jail.local: "+err.Error()) + return + } + } + // Enable and start + exec.CommandContext(ctx, "systemctl", "enable", "fail2ban").Run() + out, err := exec.CommandContext(ctx, "systemctl", "restart", "fail2ban").CombinedOutput() + if err != nil { + fixErr = fmt.Errorf("systemctl restart fail2ban: %s", string(out)) + } else { + msg = "Fail2ban installiert, SSH-Jail aktiviert und Dienst gestartet." + } + + case "enable_firewall": + // Reload rules from /etc/nftables.conf and enable service + out, err := exec.CommandContext(ctx, "nft", "-f", "/etc/nftables.conf").CombinedOutput() + if err != nil { + writeError(w, http.StatusInternalServerError, "nft -f /etc/nftables.conf: "+string(out)) + return + } + exec.CommandContext(ctx, "systemctl", "enable", "nftables").Run() + msg = "nftables-Regeln neu geladen und Dienst aktiviert." + + case "fix_ssh_password_auth": + fixErr = sshConfigSet("PasswordAuthentication", "no") + if fixErr == nil { + out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput() + if err != nil { + fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out)) + } else { + msg = "PasswordAuthentication auf 'no' gesetzt, SSH neu gestartet." + } + } + + case "fix_ssh_root_login": + fixErr = sshConfigSet("PermitRootLogin", "prohibit-password") + if fixErr == nil { + out, err := exec.CommandContext(ctx, "systemctl", "restart", "ssh").CombinedOutput() + if err != nil { + fixErr = fmt.Errorf("systemctl restart ssh: %s", string(out)) + } else { + msg = "PermitRootLogin auf 'prohibit-password' gesetzt, SSH neu gestartet." + } + } + } + + if fixErr != nil { + writeError(w, http.StatusInternalServerError, fixErr.Error()) + return + } + writeJSON(w, http.StatusOK, map[string]string{"message": msg}) +} + +// sshConfigSet sets or replaces a directive in /etc/ssh/sshd_config. +// Commented-out lines are left untouched; the active directive is updated or appended. +func sshConfigSet(key, value string) error { + const path = "/etc/ssh/sshd_config" + data, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read sshd_config: %w", err) + } + lines := strings.Split(string(data), "\n") + keyLower := strings.ToLower(key) + found := false + for i, l := range lines { + tl := strings.ToLower(strings.TrimSpace(l)) + if strings.HasPrefix(tl, keyLower) && !strings.HasPrefix(strings.TrimSpace(l), "#") { + lines[i] = key + " " + value + found = true + } + } + if !found { + lines = append(lines, key+" "+value) + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), 0644) +} diff --git a/src/app/admin/page.tsx b/src/app/admin/page.tsx index 7fc58af..eea899e 100644 --- a/src/app/admin/page.tsx +++ b/src/app/admin/page.tsx @@ -18,6 +18,7 @@ import { uploadMailFiles, getUploadProgress, getSecurityAudit, + fixSecurityIssue, type User, type AuditEntry, type SMTPStatus, @@ -126,6 +127,8 @@ export default function AdminPage() { const [securityAudit, setSecurityAudit] = useState(null); const [securityLoading, setSecurityLoading] = useState(false); const [securityError, setSecurityError] = useState(""); + const [fixLoading, setFixLoading] = useState(null); + const [fixMessage, setFixMessage] = useState(""); // Upload state const [uploadDragging, setUploadDragging] = useState(false); @@ -352,6 +355,7 @@ export default function AdminPage() { async function runSecurityAudit() { setSecurityLoading(true); setSecurityError(""); + setFixMessage(""); try { const result = await getSecurityAudit(); setSecurityAudit(result); @@ -362,6 +366,22 @@ export default function AdminPage() { } } + async function runFix(action: string) { + setFixLoading(action); + setFixMessage(""); + setSecurityError(""); + try { + const res = await fixSecurityIssue(action); + setFixMessage(res.message); + // Re-run audit automatically to reflect new state + await runSecurityAudit(); + } catch (e: unknown) { + setSecurityError(e instanceof Error ? e.message : "Fix fehlgeschlagen."); + } finally { + setFixLoading(null); + } + } + const auditTotalPages = Math.ceil(auditTotal / AUDIT_PAGE_SIZE); return ( @@ -1215,7 +1235,7 @@ export default function AdminPage() {

)} - @@ -1226,6 +1246,12 @@ export default function AdminPage() { )} + {fixMessage && ( + + {fixMessage} + + )} + {!securityAudit && !securityLoading && !securityError && (
Klicke auf “Jetzt prüfen” um den Security-Audit zu starten. @@ -1240,47 +1266,81 @@ export default function AdminPage() {
)} - {securityAudit && !securityLoading && ( -
- {securityAudit.checks.map((check: SecurityCheck, i: number) => ( - - - -
-
- {check.name} - - {check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"} - -
-

{check.message}

-
-
-
- ))} + {securityAudit && !securityLoading && (() => { + // Map check names to fix actions + const fixActions: Record = { + "Fail2ban": { action: "install_fail2ban", label: "Installieren & aktivieren" }, + "Firewall (nftables)": { action: "enable_firewall", label: "Firewall aktivieren" }, + "SSH Passwort-Auth": { action: "fix_ssh_password_auth", label: "Deaktivieren" }, + "SSH Root-Login": { action: "fix_ssh_root_login", label: "Auf prohibit-password setzen" }, + }; - {/* Summary */} -
- {[ - { label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length }, - { label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length }, - { label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length }, - ].map((s) => ( -
-

{s.count}

-

{s.label}

-
- ))} + return ( +
+ {securityAudit.checks.map((check: SecurityCheck, i: number) => { + const fix = check.status !== "ok" ? fixActions[check.name] : undefined; + return ( + + + +
+
+ {check.name} + + {check.status === "ok" ? "OK" : check.status === "warning" ? "Warnung" : "Fehler"} + +
+

{check.message}

+
+ {fix && ( + + )} + {check.name === "HTTPS (TLS)" && check.status !== "ok" && ( + + + + )} +
+
+ ); + })} + + {/* Summary */} +
+ {[ + { label: "OK", color: "bg-green-50 text-green-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "ok").length }, + { label: "Warnungen", color: "bg-yellow-50 text-yellow-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "warning").length }, + { label: "Fehler", color: "bg-red-50 text-red-700", count: securityAudit.checks.filter((c: SecurityCheck) => c.status === "error").length }, + ].map((s) => ( +
+

{s.count}

+

{s.label}

+
+ ))} +
-
- )} + ); + })()} diff --git a/src/lib/api.ts b/src/lib/api.ts index 18e3bdd..cacb0b7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -520,3 +520,10 @@ export interface SecurityAuditResult { export async function getSecurityAudit(): Promise { return request("/api/admin/security/audit"); } + +export async function fixSecurityIssue(action: string): Promise<{ message: string }> { + return request<{ message: string }>("/api/admin/security/fix", { + method: "POST", + body: JSON.stringify({ action }), + }); +}