syncova-backup/packages/platform/netguard/netguard.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

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)
}