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>
218 lines
7.1 KiB
Go
218 lines
7.1 KiB
Go
// Package netguard wehrt Anfragen an interne Adressen ab
|
|
// (SYNCOVA_IMPLEMENTATION_PLAN.md §21, SSRF).
|
|
//
|
|
// Der Angriff, gegen den dieses Paket steht, geht so: Jemand mit dem Recht,
|
|
// einen Benachrichtigungsweg einzurichten, trägt als Webhook-Adresse
|
|
// `http://169.254.169.254/latest/meta-data/` ein. Bei jeder Meldung ruft der
|
|
// Server diese Adresse ab — und der Metadatendienst der Cloud antwortet mit
|
|
// Zugangsdaten. Dasselbe Muster erreicht die eigene Datenbank auf
|
|
// `127.0.0.1:5432`, jeden internen Dienst und jeden Rechner im privaten Netz.
|
|
//
|
|
// Die Prüfung sitzt an **zwei** Stellen, und das ist kein Übereifer:
|
|
//
|
|
// Beim Anlegen des Kanals, damit der Betreiber die Meldung sofort sieht — und
|
|
// erneut unmittelbar vor dem Verbindungsaufbau, weil ein Name zwischen beiden
|
|
// Zeitpunkten auf eine andere Adresse zeigen kann. Ein DNS-Eintrag, der beim
|
|
// Prüfen auf eine öffentliche und beim Zustellen auf eine interne Adresse
|
|
// verweist, ist der übliche Weg um eine einmalige Prüfung herum.
|
|
package netguard
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"net/netip"
|
|
"net/url"
|
|
"strings"
|
|
)
|
|
|
|
// ErrBlockedAddress meldet eine gesperrte Zieladresse.
|
|
type ErrBlockedAddress struct {
|
|
// Address ist die gesperrte Adresse.
|
|
Address string
|
|
// Reason erklaert die Sperre.
|
|
Reason string
|
|
}
|
|
|
|
// Error liefert den Fehlertext.
|
|
func (blockedError *ErrBlockedAddress) Error() string {
|
|
return fmt.Sprintf("die zieladresse %s ist gesperrt: %s", blockedError.Address, blockedError.Reason)
|
|
}
|
|
|
|
// blockedRange beschreibt einen gesperrten Adressbereich.
|
|
type blockedRange struct {
|
|
// prefix ist der Adressbereich.
|
|
prefix netip.Prefix
|
|
// reason erklaert, warum er gesperrt ist.
|
|
reason string
|
|
}
|
|
|
|
// blockedRanges sind die Bereiche, die nie erreichbar sein dürfen.
|
|
//
|
|
// Die Liste ist bewusst breit: Was ein Betreiber wirklich braucht, ist ein
|
|
// öffentlicher Endpunkt. Ein interner Dienst als Ziel einer Benachrichtigung
|
|
// ist der seltene Sonderfall — und er lässt sich ausdrücklich erlauben.
|
|
var blockedRanges = buildBlockedRanges()
|
|
|
|
// buildBlockedRanges baut die Sperrliste.
|
|
func buildBlockedRanges() []blockedRange {
|
|
rangeDefinitions := []struct {
|
|
cidr string
|
|
reason string
|
|
}{
|
|
{"127.0.0.0/8", "Rückschleife — erreicht Dienste auf dem Server selbst"},
|
|
{"::1/128", "Rückschleife"},
|
|
{"169.254.0.0/16", "Verbindungslokal — hier antwortet der Metadatendienst der Cloud"},
|
|
{"fe80::/10", "verbindungslokal"},
|
|
{"10.0.0.0/8", "privates Netz"},
|
|
{"172.16.0.0/12", "privates Netz"},
|
|
{"192.168.0.0/16", "privates Netz"},
|
|
{"fc00::/7", "privates Netz"},
|
|
{"0.0.0.0/8", "unbestimmte Adresse"},
|
|
{"::/128", "unbestimmte Adresse"},
|
|
{"100.64.0.0/10", "Betreibernetz (CGNAT)"},
|
|
{"192.0.0.0/24", "für Protokollzwecke reserviert"},
|
|
{"192.0.2.0/24", "Dokumentationsbereich"},
|
|
{"198.18.0.0/15", "Messbereich"},
|
|
{"198.51.100.0/24", "Dokumentationsbereich"},
|
|
{"203.0.113.0/24", "Dokumentationsbereich"},
|
|
{"224.0.0.0/4", "Multicast"},
|
|
{"ff00::/8", "Multicast"},
|
|
{"240.0.0.0/4", "reserviert"},
|
|
}
|
|
|
|
ranges := make([]blockedRange, 0, len(rangeDefinitions))
|
|
|
|
for _, definition := range rangeDefinitions {
|
|
parsedPrefix, parseError := netip.ParsePrefix(definition.cidr)
|
|
if parseError != nil {
|
|
// Eine feste Liste im Quelltext: Ein Fehler hier ist ein Tippfehler
|
|
// des Entwicklers und fällt beim ersten Testlauf auf.
|
|
panic("netguard: unlesbarer adressbereich " + definition.cidr)
|
|
}
|
|
|
|
ranges = append(ranges, blockedRange{prefix: parsedPrefix, reason: definition.reason})
|
|
}
|
|
|
|
return ranges
|
|
}
|
|
|
|
// Guard prüft Zieladressen gegen die Sperrliste.
|
|
type Guard struct {
|
|
// allowInternalTargets hebt die Sperre auf.
|
|
//
|
|
// Für den Betrieb hinter einem Reverse Proxy oder mit einem internen
|
|
// Meldedienst. Der Schalter heißt, was er tut, und steht in der
|
|
// Konfiguration — nicht im Code.
|
|
allowInternalTargets bool
|
|
// resolveHost löst einen Namen auf; austauschbar für den Test.
|
|
resolveHost func(context.Context, string) ([]netip.Addr, error)
|
|
}
|
|
|
|
// NewGuard erzeugt eine Prüfung.
|
|
func NewGuard(allowInternalTargets bool) *Guard {
|
|
return &Guard{
|
|
allowInternalTargets: allowInternalTargets,
|
|
resolveHost: resolveHostAddresses,
|
|
}
|
|
}
|
|
|
|
// resolveHostAddresses löst einen Namen in Adressen auf.
|
|
func resolveHostAddresses(resolveContext context.Context, hostName string) ([]netip.Addr, error) {
|
|
resolvedAddresses, lookupError := net.DefaultResolver.LookupNetIP(resolveContext, "ip", hostName)
|
|
if lookupError != nil {
|
|
return nil, lookupError
|
|
}
|
|
|
|
return resolvedAddresses, nil
|
|
}
|
|
|
|
// CheckURL prüft die Zieladresse einer URL.
|
|
//
|
|
// Geprüft wird **jede** aufgelöste Adresse, nicht nur die erste: Ein Name kann
|
|
// auf mehrere Adressen zeigen, und der Verbindungsaufbau nimmt nicht
|
|
// zwangsläufig dieselbe, die eine Prüfung angesehen hat.
|
|
func (guard *Guard) CheckURL(checkContext context.Context, rawURL string) error {
|
|
if guard.allowInternalTargets {
|
|
return nil
|
|
}
|
|
|
|
parsedURL, parseError := url.Parse(rawURL)
|
|
if parseError != nil {
|
|
return fmt.Errorf("die zieladresse ist unlesbar: %w", parseError)
|
|
}
|
|
|
|
hostName := parsedURL.Hostname()
|
|
if hostName == "" {
|
|
return &ErrBlockedAddress{Address: rawURL, Reason: "die Adresse nennt keinen Rechner"}
|
|
}
|
|
|
|
return guard.CheckHost(checkContext, hostName)
|
|
}
|
|
|
|
// CheckHost prüft einen Rechnernamen oder eine Adresse.
|
|
func (guard *Guard) CheckHost(checkContext context.Context, hostName string) error {
|
|
if guard.allowInternalTargets {
|
|
return nil
|
|
}
|
|
|
|
// Ein besonders einfacher Fall, der ohne Auflösung auffällt.
|
|
if strings.EqualFold(hostName, "localhost") {
|
|
return &ErrBlockedAddress{
|
|
Address: hostName,
|
|
Reason: "Rückschleife — erreicht Dienste auf dem Server selbst",
|
|
}
|
|
}
|
|
|
|
// Eine unmittelbar angegebene Adresse braucht keine Auflösung.
|
|
if literalAddress, parseError := netip.ParseAddr(hostName); parseError == nil {
|
|
return guard.checkAddress(literalAddress)
|
|
}
|
|
|
|
resolvedAddresses, resolveError := guard.resolveHost(checkContext, hostName)
|
|
if resolveError != nil {
|
|
return fmt.Errorf("die zieladresse %q liess sich nicht aufloesen: %w", hostName, resolveError)
|
|
}
|
|
|
|
if len(resolvedAddresses) == 0 {
|
|
return &ErrBlockedAddress{Address: hostName, Reason: "der Name löst auf keine Adresse auf"}
|
|
}
|
|
|
|
for _, resolvedAddress := range resolvedAddresses {
|
|
if addressError := guard.checkAddress(resolvedAddress); addressError != nil {
|
|
return addressError
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// checkAddress prüft eine einzelne Adresse gegen die Sperrliste.
|
|
func (guard *Guard) checkAddress(candidateAddress netip.Addr) error {
|
|
// Eine in IPv6 eingebettete IPv4-Adresse (::ffff:127.0.0.1) muss als das
|
|
// geprüft werden, was sie ist. Ohne diesen Schritt führt sie an jeder
|
|
// IPv4-Sperre vorbei.
|
|
if candidateAddress.Is4In6() {
|
|
candidateAddress = candidateAddress.Unmap()
|
|
}
|
|
|
|
for _, blocked := range blockedRanges {
|
|
if blocked.prefix.Contains(candidateAddress) {
|
|
return &ErrBlockedAddress{
|
|
Address: candidateAddress.String(),
|
|
Reason: blocked.reason,
|
|
}
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// IsBlocked meldet, ob ein Fehler eine gesperrte Adresse betrifft.
|
|
func IsBlocked(occurredError error) bool {
|
|
var blockedError *ErrBlockedAddress
|
|
|
|
return occurredError != nil && errors.As(occurredError, &blockedError)
|
|
}
|