Ochrona przed SSRF
Gdy Twoja aplikacja pobiera URL podany przez użytkownika, atakujący może to wykorzystać, żeby dosięgnąć Twojej sieci wewnętrznej. Klasy UrlValidator i IPAddress pomagają Ci bronić się przed tymi atakami Server-Side Request Forgery (SSRF).
Czym jest SSRF?
Wyobraź sobie funkcję, w której użytkownik wpisuje URL, a Twój serwer go pobiera: awatar spod zdalnego adresu, cel webhooka, podgląd odnośnika. Wygląda niewinnie, ale pod adres sięga serwer, a nie przeglądarka użytkownika. A serwer widzi miejsca, których atakujący nie widzi: interfejs loopback, sieć prywatną, usługi chmurowe.
Atakujący podaje więc URL wskazujący do wewnątrz zamiast do publicznego internetu. Typowe cele to:
- metadane chmury pod
http://169.254.169.254/, z których mogą wyciec klucze dostępowe - wewnętrzne panele administracyjne i routery, jak
http://192.168.1.1/ - usługi bez uwierzytelniania, na przykład Redis pod
http://localhost:6379/
Ta klasa podatności jest tak powszechna, że plasuje się w OWASP Top 10. Obroną jest zwalidowanie URL przed jego pobraniem i odrzucenie wszystkiego, co rozwiązuje się do adresu niepublicznego.
UrlValidator
Nette\Http\UrlValidator sprawdza URL względem konfigurowalnej polityki: schematu, portu, hosta, userinfo i adresów IP, do których host się rozwiązuje. Podstawowe użycie to jedno wywołanie:
use Nette\Http\UrlValidator;
if (!(new UrlValidator)->allows($userUrl)) {
return; // niebezpieczny URL, nie pobieraj go
}
Domyślna polityka jest celowo surowa: przyjmuje wyłącznie https na porcie 443 wskazujące na publiczny adres
IP. Wszystko inne (loopback, zakresy prywatne, link-local wraz z metadanymi chmury, zakresy zarezerwowane) jest odrzucane, a
multicast odrzucany jest bezwarunkowo. To właściwy punkt wyjścia przy pobieraniu dowolnych URL-i podanych przez
użytkownika.
Konfiguracja polityki
Politykę kształtujesz przez konstruktor. Na przykład żeby dopuścić zwykłe http na dowolnym porcie
i sięgać po adresy prywatne (przydatne wewnątrz zaufanej sieci):
$validator = new UrlValidator(
schemes: ['http', 'https'],
ports: null, // dowolny port
allowPrivateIps: true,
);
Częstym wzorcem jest ograniczenie pobierania do ustalonego zbioru domen partnerskich za pomocą whitelisty hostów.
Przedrostek *. dopasowuje subdomeny dowolnej głębokości, ale nie samą domenę: jeśli tego potrzebujesz, wypisz
obie formy:
$validator = new UrlValidator(
hostAllowlist: ['example.com', '*.example.com'],
);
Pełny zestaw opcji konstruktora:
| Parametr | Domyślnie | Znaczenie |
|---|---|---|
schemes |
['https'] |
dozwolone schematy; [] odrzuca wszystko |
ports |
[443] |
dozwolone porty, null = dowolny; niejawny port ze schematu jest respektowany |
allowPrivateIps |
false |
dopuszcza zakresy prywatne (10/8, 172.16/12, 192.168/16, fc00::/7) |
allowLoopback |
false |
dopuszcza loopback (127.0.0.0/8, ::1) |
allowLinkLocal |
false |
dopuszcza link-local wraz z metadanymi chmury 169.254.169.254 |
allowReserved |
false |
dopuszcza zakresy zarezerwowane przez IANA |
allowUserinfo |
false |
dopuszcza user:pass@ w URL |
hostAllowlist |
null |
jeśli ustawione, host musi pasować do jednego ze wzorców; [] odrzuca wszystkie |
hostBlocklist |
null |
jeśli ustawione, host nie może pasować do żadnego wzorca |
Metody walidacyjne
Walidator oferuje trzy metody. allows() przeprowadza pełną kontrolę wraz z rozwiązaniem DNS: host jest
rozwiązywany i każdy adres A/AAAA musi przejść politykę IP:
(new UrlValidator)->allows($url); // bool
allowsWithoutDns() pomija rozwiązanie DNS i kontrole zakresów IP. Użyj jej jako szybkiego filtra wstępnego
albo wtedy, gdy walidacja DNS jest delegowana do warstwy pobierającej:
(new UrlValidator)->allowsWithoutDns($url); // bool
Obie metody przyjmują ciąg, obiekt UrlImmutable albo null (który zawsze
nie przechodzi).
Pokonanie DNS rebindingu
Między walidacją a pobraniem istnieje subtelny wyścig: atakujący może zwrócić bezpieczne IP, gdy walidujesz host, a
potem przełączyć DNS na wewnętrzne IP przy właściwym pobraniu. Żeby zamknąć tę lukę, getResolvedIPs()
zwraca zwalidowane adresy IP, a Ty przypinasz do nich połączenie, żeby pobrania nie dało się przekierować gdzie indziej:
$ips = (new UrlValidator)->getResolvedIPs($url);
if (!$ips) {
return; // niebezpieczny URL
}
$ch = curl_init($url);
$host = parse_url($url, PHP_URL_HOST);
curl_setopt($ch, CURLOPT_RESOLVE, ["$host:443:" . implode(',', $ips)]);
// ... wykonanie żądania
Metoda zwraca tablicę ciągów IP (najpierw rekordy A, potem AAAA), które przeszły pełną politykę, albo pustą tablicę przy jakimkolwiek niepowodzeniu. Dla literału IP w URL waliduje adres bezpośrednio i nie wykonuje zapytania DNS.
IPAddress
Nette\Http\IPAddress to niezmienny obiekt wartości do
pracy z adresami IPv4 i IPv6. UrlValidator używa go wewnętrznie, ale przydaje się też sam w sobie zawsze, gdy
klasyfikujesz adresy. Konstruktor rzuca Nette\InvalidArgumentException dla nieprawidłowego adresu:
use Nette\Http\IPAddress;
$ip = new IPAddress('169.254.169.254');
echo $ip; // '169.254.169.254'
Gdy nie chcesz wyjątku, użyj fabryki tryFrom() albo testu isValid():
$ip = IPAddress::tryFrom($input); // ?IPAddress
IPAddress::isValid($input); // bool
Klasyfikacja adresów
Predykaty mówią Ci, do której klasy należy adres. Kluczowy jest isPublic(): true tylko dla adresów publicznie
routowalnych, czyli dokładnie tego, czego chce ochrona przed SSRF:
$ip = new IPAddress('169.254.169.254');
$ip->isPublic(); // false
$ip->isLinkLocal(); // true (zakres metadanych chmury)
Pełny zestaw predykatów:
| Metoda | Testuje |
|---|---|
isPublic() |
publicznie routowalny (żaden z poniższych) |
isPrivate() |
zakresy prywatne RFC 1918 / 4193 |
isLoopback() |
127.0.0.0/8, ::1 |
isLinkLocal() |
169.254.0.0/16 (wraz z metadanymi chmury), fe80::/10 |
isMulticast() |
224.0.0.0/4, ff00::/8 |
isReserved() |
zarezerwowane przez IANA (dokumentacja, CGNAT, przyszłe użycie, …) |
Przynależność do zakresu
isInRange() testuje, czy adres mieści się w bloku CIDR. Możesz przekazać sieć z prefiksem albo goły adres
dla dokładnego dopasowania (niejawne /32 dla IPv4, /128 dla IPv6):
$ip = new IPAddress('192.168.1.50');
$ip->isInRange('192.168.0.0/16'); // true
$ip->isInRange('10.0.0.1'); // false (dokładne dopasowanie)
Zniekształcone wejście albo inna rodzina IP zwraca false.
IPv6 z mapowanym IPv4
Adresy zapisane jako IPv6 z mapowanym IPv4 (jak ::ffff:127.0.0.1) to klasyczny sposób na prześlizgnięcie się
obok naiwnych filtrów. IPAddress je normalizuje, więc predykaty zakresów przejrzą to przebranie:
$ip = new IPAddress('::ffff:127.0.0.1');
$ip->isLoopback(); // true
$ip->isIPv4Mapped(); // true
$ip->toIPv4(); // IPAddress('127.0.0.1')
Metody isIPv4() i isIPv6() raportują postać tekstową: adres mapowany jest IPv6, a nie IPv4.