SSRF-Schutz

Wenn Ihre Anwendung eine vom Benutzer angegebene URL herunterlädt, kann ein Angreifer das ausnutzen, um in Ihr internes Netzwerk zu gelangen. Die Klassen UrlValidator und IPAddress helfen Ihnen, sich gegen diese Angriffe vom Typ Server-Side Request Forgery (SSRF) zu schützen.

Installation und Anforderungen

Was ist SSRF?

Stellen Sie sich eine Funktion vor, bei der der Benutzer eine URL eingibt und Ihr Server sie herunterlädt – ein Avatar von einer entfernten Adresse, ein Webhook-Ziel, eine Linkvorschau. Das sieht harmlos aus, aber die Adresse ruft der Server auf, nicht der Browser des Benutzers. Und der Server sieht Orte, die der Angreifer nicht sieht: das Loopback-Interface, das private Netzwerk, Cloud-Dienste.

Ein Angreifer schickt deshalb eine URL, die nach innen statt ins öffentliche Internet zeigt. Typische Ziele sind:

  • Cloud-Metadaten unter http://169.254.169.254/, die Zugangsschlüssel preisgeben können
  • interne Adminoberflächen und Router wie http://192.168.1.1/
  • Dienste ohne Authentifizierung, etwa Redis unter http://localhost:6379/

Diese Klasse von Sicherheitslücken ist so verbreitet, dass sie zu den OWASP Top 10 zählt. Die Abwehr besteht darin, die URL zu validieren, bevor Sie sie abrufen, und alles abzulehnen, was auf eine nicht öffentliche Adresse zeigt.

UrlValidator

Nette\Http\UrlValidator prüft eine URL gegen eine konfigurierbare Policy: das Schema, den Port, den Host, die Userinfo und die IP-Adressen, auf die der Host auflöst. Die grundlegende Verwendung ist ein einziger Aufruf:

use Nette\Http\UrlValidator;

if (!(new UrlValidator)->allows($userUrl)) {
	return; // unsichere URL, nicht abrufen
}

Die Standard-Policy ist bewusst streng – sie akzeptiert nur https auf Port 443, das auf eine öffentliche IP-Adresse zeigt. Alles andere (Loopback, private Bereiche, Link-Local einschließlich Cloud-Metadaten, reservierte Bereiche) wird abgelehnt, und Multicast wird bedingungslos abgelehnt. Das ist der richtige Ausgangspunkt für das Abrufen beliebiger vom Benutzer angegebener URLs.

Die Policy konfigurieren

Die Policy formen Sie über den Konstruktor. Um zum Beispiel einfaches http auf beliebigen Ports zu erlauben und private Adressen zu erreichen (nützlich innerhalb eines vertrauenswürdigen Netzwerks):

$validator = new UrlValidator(
	schemes: ['http', 'https'],
	ports: null, // beliebiger Port
	allowPrivateIps: true,
);

Ein häufiges Muster ist, das Abrufen mit einer Host-Allowlist auf eine feste Menge von Partnerdomains zu beschränken. Das Präfix *. passt auf beliebig viele Subdomain-Ebenen, aber nicht auf die Apex-Domain – führen Sie beide Formen auf, wenn Sie sie brauchen:

$validator = new UrlValidator(
	hostAllowlist: ['example.com', '*.example.com'],
);

Die vollständige Menge der Konstruktoroptionen:

Parameter Standard Bedeutung
schemes ['https'] erlaubte Schemata; [] lehnt alles ab
ports [443] erlaubte Ports, null = beliebig; der implizite Port des Schemas wird berücksichtigt
allowPrivateIps false erlaubt private Bereiche (10/8, 172.16/12, 192.168/16, fc00::/7)
allowLoopback false erlaubt Loopback (127.0.0.0/8, ::1)
allowLinkLocal false erlaubt Link-Local inkl. Cloud-Metadaten 169.254.169.254
allowReserved false erlaubt von der IANA reservierte Bereiche
allowUserinfo false erlaubt user:pass@ in der URL
hostAllowlist null falls gesetzt, muss der Host auf ein Muster passen; [] lehnt alle ab
hostBlocklist null falls gesetzt, darf der Host auf kein Muster passen

Methoden zur Validierung

Der Validator bietet drei Methoden. allows() führt die vollständige Prüfung einschließlich der DNS-Auflösung durch – der Host wird aufgelöst, und jede A/AAAA-Adresse muss die IP-Policy bestehen:

(new UrlValidator)->allows($url); // bool

allowsWithoutDns() überspringt die DNS-Auflösung und die Prüfung der IP-Bereiche. Verwenden Sie es als schnellen Vorfilter oder wenn die DNS-Validierung an die Abrufschicht delegiert ist:

(new UrlValidator)->allowsWithoutDns($url); // bool

Beide Methoden nehmen einen String, ein Objekt UrlImmutable oder null entgegen (was immer fehlschlägt).

DNS-Rebinding aushebeln

Zwischen Validierung und Abruf gibt es eine feine Race Condition: Ein Angreifer kann bei der Validierung des Hosts eine sichere IP zurückgeben und das DNS dann für den eigentlichen Download auf eine interne IP umstellen. Um dieses Loch zu schließen, gibt getResolvedIPs() die validierten IP-Adressen zurück, und Sie binden die Verbindung an sie, sodass der Abruf nicht anderswohin umgelenkt werden kann:

$ips = (new UrlValidator)->getResolvedIPs($url);
if (!$ips) {
	return; // unsichere URL
}

$ch = curl_init($url);
$host = parse_url($url, PHP_URL_HOST);
curl_setopt($ch, CURLOPT_RESOLVE, ["$host:443:" . implode(',', $ips)]);
// ... den Request ausführen

Die Methode gibt ein Array von IP-Strings zurück (zuerst A-Records, dann AAAA), die die vollständige Policy bestanden haben, oder bei jedem Fehlschlag ein leeres Array. Für ein IP-Literal in der URL validiert sie die Adresse direkt und führt keine DNS-Abfrage durch.

IPAddress

Nette\Http\IPAddress ist ein unveränderliches Value Object für die Arbeit mit IPv4– und IPv6-Adressen. UrlValidator verwendet es intern, aber es ist auch für sich nützlich, wann immer Sie Adressen klassifizieren. Der Konstruktor wirft bei einer ungültigen Adresse eine Nette\InvalidArgumentException:

use Nette\Http\IPAddress;

$ip = new IPAddress('169.254.169.254');
echo $ip; // '169.254.169.254'

Wenn Sie keine Exception wollen, verwenden Sie die Factory tryFrom() oder die Prüfmethode isValid():

$ip = IPAddress::tryFrom($input); // ?IPAddress
IPAddress::isValid($input);       // bool

Klassifizierung von Adressen

Die Prädikate sagen Ihnen, zu welcher Klasse eine Adresse gehört. Das wichtigste ist isPublic() – true nur für öffentlich routbare Adressen, und genau das will ein SSRF-Schutz:

$ip = new IPAddress('169.254.169.254');
$ip->isPublic();    // false
$ip->isLinkLocal(); // true (Bereich der Cloud-Metadaten)

Die vollständige Menge der Prädikate:

Methode Prüft auf
isPublic() öffentlich routbar (keines der folgenden)
isPrivate() private Bereiche nach RFC 1918 / 4193
isLoopback() 127.0.0.0/8, ::1
isLinkLocal() 169.254.0.0/16 (inkl. Cloud-Metadaten), fe80::/10
isMulticast() 224.0.0.0/4, ff00::/8
isReserved() von der IANA reserviert (Dokumentation, CGNAT, künftige Verwendung, …)

Zugehörigkeit zu einem Bereich

isInRange() prüft, ob die Adresse in einen CIDR-Block fällt. Sie können ein Netz mit Präfix übergeben oder eine bloße Adresse für einen exakten Vergleich (implizit /32 bei IPv4, /128 bei IPv6):

$ip = new IPAddress('192.168.1.50');
$ip->isInRange('192.168.0.0/16'); // true
$ip->isInRange('10.0.0.1');       // false (exakter Vergleich)

Fehlerhafte Eingaben oder eine andere IP-Familie ergeben false.

IPv4-mapped IPv6

Adressen, die als IPv4-mapped IPv6 geschrieben sind (etwa ::ffff:127.0.0.1), sind ein klassischer Weg, an naiven Filtern vorbeizukommen. IPAddress normalisiert sie, sodass die Bereichsprädikate die Tarnung durchschauen:

$ip = new IPAddress('::ffff:127.0.0.1');
$ip->isLoopback();   // true
$ip->isIPv4Mapped(); // true
$ip->toIPv4();       // IPAddress('127.0.0.1')

Die Methoden isIPv4() und isIPv6() beziehen sich auf die textuelle Form: Eine gemappte Adresse ist IPv6, nicht IPv4.

Version: 4.x