syncova-backup/packages/recovery/targetguard.go
Jerrit Fritzsche 610719c316
Some checks failed
CI / Backend (Go) (push) Failing after 3m7s
CI / Frontend (React/TypeScript) (push) Successful in 37s
CI / Sicherheitsprüfungen (push) Successful in 44s
Syncova Backups V1
Enterprise-Backup-, Recovery-, Verification-, Security- und
Monitoring-Plattform fuer Proxmox VE, Windows, Linux und Dateisysteme.

Der Leitsatz, der fast jede Entscheidung erklaert: Ein Backup gilt erst als
vertrauenswuerdig, wenn Integritaet geprueft und Wiederherstellbarkeit
nachgewiesen wurde. Deshalb steigt ein Wiederherstellungspunkt erst nach einem
tatsaechlich durchgefuehrten Restore-Test auf "recoverable", und Unbekanntes
geht in keine Bewertung als "gut" ein.

Umfang (Phasen 0-23):

- Repository Engine: inhaltsadressierte Bloecke, atomares Commit-Protokoll,
  Katalogaufbau allein aus den Manifesten — ohne Datenbank
- Backup Engine: inhaltsabhaengiges Chunking, Deduplizierung trotz
  Verschluesselung, zstd, AES-256-GCM, Streaming mit Gegendruck
- Agenten fuer Windows und Linux mit Auftragsabholung (Pull-Modell)
- Proxmox-Provider mit beiden Zugriffswegen auf die Sicherungsarchive
- Scheduler, Recovery Engine mit Pruefpunkt, Verification, Unveraenderlichkeit
- Weboberflaeche, Kennzahlen, Meldungen, Berichte, Security Center,
  Ransomware-Heuristik (meldet, handelt nie)
- Disaster Recovery, Haertung, Leistungsmessung, Chaos Testing
- Eingefrorene Vertraege fuer API, Migrationen, Backup-Format und Repository
- Auslieferungspaket fuer linux/amd64, linux/arm64 und windows/amd64

Nicht enthalten und als solches gekennzeichnet: Kapazitaetsprognose, Backup
Copy, Changed Block Tracking bei Proxmox, erweiterte Attribute und ACLs.

Gebaut, aber nie auf echter Hardware gefahren: der Windows-Dienst, die
systemd-Einheit und der verpflichtende Proxmox-Meilenstein — ob eine
wiederhergestellte VM startet, ist ungeprueft. Einzelheiten in CHANGELOG.md
und docs/release-candidate.md.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-17 09:10:54 +02:00

124 lines
4.1 KiB
Go

package recovery
import (
"fmt"
"path/filepath"
"strings"
)
// TargetGuard prüft, wohin eine Wiederherstellung schreiben darf
// (SYNCOVA_IMPLEMENTATION_PLAN.md §21, filesystem permissions).
//
// Der Angriff, gegen den diese Prüfung steht, braucht keine Lücke im Code:
// Jemand mit dem Recht, Backups zurückzuschreiben, gibt als Ziel `/etc` an. Der
// nächste Lauf schreibt dann eine Datei nach `/etc/cron.d/` oder eine
// `authorized_keys` in ein fremdes Benutzerverzeichnis — und aus einem
// Anwendungsrecht ist ein Systemzugang geworden.
//
// Die Prüfung ersetzt **nicht** die Rechte des Dienstbenutzers. Sie ist die
// zweite Linie: Ein Dienst, der als eigener, unprivilegierter Benutzer läuft,
// käme ohnehin nicht nach /etc. Nur verlässt sich niemand gern darauf, dass
// jede Installation das richtig gemacht hat.
type TargetGuard struct {
// allowedRoots sind die ausdrücklich erlaubten Wurzelverzeichnisse.
//
// Ist die Liste gesetzt, gilt ausschließlich sie: Ein Ziel muss unterhalb
// eines dieser Verzeichnisse liegen.
allowedRoots []string
}
// NewTargetGuard erzeugt die Prüfung.
func NewTargetGuard(allowedRoots []string) *TargetGuard {
cleanedRoots := make([]string, 0, len(allowedRoots))
for _, allowedRoot := range allowedRoots {
trimmedRoot := strings.TrimSpace(allowedRoot)
if trimmedRoot == "" {
continue
}
absoluteRoot, absoluteError := filepath.Abs(trimmedRoot)
if absoluteError != nil {
continue
}
cleanedRoots = append(cleanedRoots, filepath.Clean(absoluteRoot))
}
return &TargetGuard{allowedRoots: cleanedRoots}
}
// blockedSystemRoots sind die Verzeichnisse des laufenden Systems.
//
// Sie sind gesperrt, auch wenn keine Erlaubnisliste konfiguriert ist: Eine
// Wiederherstellung dorthin überschreibt das System, auf dem die Anlage selbst
// läuft. Wer ein fremdes /etc zurückschreiben will, entpackt es an eine andere
// Stelle und kopiert es von dort — ein Zwischenschritt, der einen Blick
// erzwingt.
var blockedSystemRoots = []string{
"/bin", "/boot", "/dev", "/etc", "/lib", "/lib32", "/lib64", "/libx32",
"/proc", "/root", "/sbin", "/sys", "/usr", "/var/lib", "/var/run",
"/System", "/Library", "/Applications", "/private/etc", "/private/var/db",
}
// Validate prüft ein Zielverzeichnis.
func (guard *TargetGuard) Validate(targetPath string) error {
absoluteTarget, absoluteError := filepath.Abs(strings.TrimSpace(targetPath))
if absoluteError != nil {
return fmt.Errorf("der zielpfad ist unlesbar: %w", absoluteError)
}
absoluteTarget = filepath.Clean(absoluteTarget)
// Das Wurzelverzeichnis selbst ist niemals ein Ziel.
if absoluteTarget == string(filepath.Separator) {
return fmt.Errorf("das wurzelverzeichnis ist als ziel nicht zulaessig")
}
// Eine gesetzte Erlaubnisliste ist abschliessend.
if len(guard.allowedRoots) > 0 {
for _, allowedRoot := range guard.allowedRoots {
if isWithinDirectory(absoluteTarget, allowedRoot) {
return nil
}
}
return fmt.Errorf("das ziel %q liegt ausserhalb der erlaubten verzeichnisse (%s)",
absoluteTarget, strings.Join(guard.allowedRoots, ", "))
}
for _, blockedRoot := range blockedSystemRoots {
if isWithinDirectory(absoluteTarget, blockedRoot) {
return fmt.Errorf("das ziel %q liegt in einem systemverzeichnis (%s). eine "+
"wiederherstellung dorthin ueberschreibt das laufende system. legen sie die "+
"daten an einer anderen stelle ab und uebernehmen sie sie von dort",
absoluteTarget, blockedRoot)
}
}
return nil
}
// isWithinDirectory meldet, ob ein Pfad in einem Verzeichnis liegt.
//
// Der Vergleich geht über die Pfadtrennung, nicht über strings.HasPrefix: Sonst
// gälte "/etchen" als innerhalb von "/etc", und "/var/library" als innerhalb
// von "/var/lib".
func isWithinDirectory(candidatePath string, directoryPath string) bool {
if candidatePath == directoryPath {
return true
}
relativePath, relativeError := filepath.Rel(directoryPath, candidatePath)
if relativeError != nil {
return false
}
if relativePath == "." {
return true
}
// Beginnt der relative Pfad mit "..", liegt das Ziel ausserhalb.
return !strings.HasPrefix(relativePath, "..")
}