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