Podgląd linku pobiera 169.254.169.254 i oddaje poświadczenia roli. Walidacja adresu tego nie zatrzyma
Wygenerowany endpoint pobiera adres podany przez użytkownika. Dlaczego warunek na hostname tego nie zamyka i jaka kolejność sprawdzeń działa.
Klient chciał w panelu podglądu linku: wkleja adres, a CMS dociąga tytuł i miniaturę. Funkcja działa poprawnie od pierwszego dnia. Dwa tygodnie po wdrożeniu ktoś wkleja w to pole http://169.254.169.254/latest/meta-data/iam/security-credentials/ i w odpowiedzi podglądu dostaje nazwę roli serwera, a kolejnym żądaniem jej tymczasowe poświadczenia.
Endpoint zrobił dokładnie to, o co go poproszono: pobrał zasób z adresu podanego przez użytkownika. Brakującego warunku nie było w zamówieniu, nie ma go w kodzie i nie zobaczy go nikt, kto testuje tę funkcję na adresach prawdziwych stron.
Handler, który pobiera wszystko, o co się go poprosi
Polecenie „dodaj endpoint, który pobiera stronę z podanego adresu i zwraca tytuł oraz obrazek Open Graph” daje zwykle taki wynik:
// app/api/podglad/route.ts - wersja z generatora
export async function POST(req: Request) {
const { url } = await req.json();
if (!url || typeof url !== 'string') {
return Response.json({ error: 'brak adresu' }, { status: 400 });
}
const res = await fetch(url); // caly ruch wychodzi z serwera
const html = await res.text();
return Response.json(wyciagnijOpenGraph(html));
}
Kod ma obsługę braku parametru i sprawdzenie typu. Nie ma w nim żadnego stwierdzenia o tym, gdzie temu żądaniu wolno się udać — a wychodzi ono z serwera aplikacji, czyli spod adresu IP, który ma dostęp do sieci wewnętrznej, do bazy po prywatnym adresie i do usługi metadanych dostawcy chmury.
Ta klasa błędu nazywa się Server-Side Request Forgery. W edycji OWASP Top 10 z 2025 roku nie ma już osobnej kategorii na nią — CWE-918 jest zmapowany do kategorii A01, Broken Access Control, razem z 39 innymi identyfikatorami. To przesunięcie ma praktyczny sens: żądanie wychodzące z serwera omija całą kontrolę dostępu, którą ustawiono na wejściu, bo przychodzi z zaufanego miejsca.
Sprawdzenie adresu nie jest sprawdzeniem celu
Pierwsza poprawka, która zwykle pojawia się po zgłoszeniu, wygląda sensownie i nie wystarcza:
const adres = new URL(url); // rzuci wyjatek na smieciach
if (!['http:', 'https:'].includes(adres.protocol)) {
return Response.json({ error: 'zly protokol' }, { status: 400 });
}
if (adres.hostname === 'localhost' || adres.hostname === '127.0.0.1') {
return Response.json({ error: 'adres lokalny' }, { status: 400 });
}
Konstruktor URL odpowiada na pytanie, czy łańcuch jest poprawnym adresem. Nie odpowiada na pytanie, dokąd to żądanie dojdzie. Między nazwą w adresie a gniazdem, które otwiera klient HTTP, stoi rozwiązywanie nazw — i to ono decyduje o celu.
| Co wpisuje użytkownik | Co widzi walidacja nazwy | Gdzie faktycznie idzie żądanie |
|---|---|---|
http://2852039166/ |
Nazwa hosta, nie 127.0.0.1 |
Pętla lokalna, zapis dziesiętny adresu |
http://[::ffff:169.254.169.254]/ |
Adres IPv6, poza listą zakazanych | Usługa metadanych, przez adres IPv4 w zapisie IPv6 |
http://wlasna-domena.example/ |
Zwykła domena publiczna | Tam, gdzie wskazuje rekord A, czyli na przykład na 10.0.0.5 |
http://zaufana.example/przekieruj |
Host z listy dozwolonych | Pod adres z nagłówka Location, po przekierowaniu |
Trzeci i czwarty wiersz są ważniejsze od pierwszych dwóch, bo nie da się ich obejść dokładaniem wzorców do walidacji nazwy. Domena, której właścicielem jest osoba wysyłająca żądanie, może w rekordzie A mieć dowolny adres, także prywatny. Przewodnik OWASP mówi o tym wprost: „Domain allowlisting alone does not prevent DNS rebinding”, a druga, niesprawdzona kwerenda DNS między walidacją a połączeniem omija wcześniejsze testy. To klasyczny wyścig między momentem sprawdzenia a momentem użycia.
Czarna lista jako pierwszy wybór, czyli praca, która nigdy się nie kończy
Rozbudowywanie listy zakazanych zakresów jest zajęciem bez końca, a ta sama strona OWASP formułuje zasadę w jednym zdaniu: „Deny-lists are bypass-prone. Prefer allow-lists.” Czarna lista zostaje jako minimum tam, gdzie celem może być dowolny adres publiczny, i wtedy trzeba w niej mieć co najmniej 169.254.169.254, nazwy metadata.google.internal oraz metadata.amazonaws.com, pętlę lokalną, zakresy prywatne z RFC 1918 i zakres link-local.
Przewodnik rozdziela dwa przypadki, a rozdział ten przesądza o konstrukcji kodu:
- Cele znane z góry („Application can send request only to identified and trusted applications”) — wtedy obowiązuje biała lista nazw lub adresów, a żądanie składa się od nowa z ustalonego schematu, portu i ścieżki, zamiast przyjmować cały adres od użytkownika.
- Cel dowolny („Application can send requests to ANY external IP address or domain name”) — biała lista nie istnieje, więc trzeba przed połączeniem sprawdzić, że rozwiązany adres jest publiczny, i odciąć wszystko, co nim nie jest.
Podgląd linku w CMS-ie to drugi przypadek. Import obrazka z adresu podanego przez klienta, webhook do systemu, który klient wskazuje sam, i generowanie PDF-u ze wskazanej strony — też. To cztery różne pozycje w wycenie i jeden endpoint pod spodem. Przy webhooku i przy integracji wraca to samo pytanie o zakres, które pada przy automatach po stronie firmy: granice uprawnień integracji sięgającej do systemów wyznacza konfiguracja po stronie serwera, nie to, co poda wywołujący.
Kolejność, która ten endpoint domyka
Mechanika sprowadza się do przesunięcia decyzji z nazwy na adres: rozwiąż nazwę samodzielnie, sprawdź otrzymany adres, połącz się po tym adresie i nie pozwól klientowi HTTP pójść nigdzie dalej.
import { lookup } from 'node:dns/promises';
import { isIP } from 'node:net';
const ZAKAZANE = [
/^127\./, /^10\./, /^192\.168\./, /^169\.254\./, // petla, RFC1918, link-local
/^172\.(1[6-9]|2\d|3[01])\./, /^0\./, /^100\.(6[4-9]|[7-9]\d|1[01]\d|12[0-7])\./,
];
async function rozwiazIPubliczny(hostname: string): Promise<string> {
const { address } = await lookup(hostname); // jedna kwerenda, jej wynik uzywamy
if (isIP(address) !== 4) throw new Error('tylko IPv4 w tym endpoincie');
if (ZAKAZANE.some((w) => w.test(address))) throw new Error('adres nie jest publiczny');
return address;
}
const adres = new URL(url);
if (!['http:', 'https:'].includes(adres.protocol)) throw new Error('zly protokol');
if (!['', '80', '443'].includes(adres.port)) throw new Error('nietypowy port');
const ip = await rozwiazIPubliczny(adres.hostname);
const odp = await fetch(`${adres.protocol}//${ip}${adres.pathname}`, {
redirect: 'manual', // Location sprawdzamy sami
headers: { Host: adres.host }, // nazwa zostaje dla vhosta i SNI
signal: AbortSignal.timeout(5000),
});
if (odp.status >= 300 && odp.status < 400) throw new Error('przekierowanie odrzucone');
Cztery decyzje w tym fragmencie są nieoczywiste i każda ma swoje uzasadnienie w zaleceniach OWASP. Połączenie idzie po sprawdzonym adresie, a nie po nazwie, żeby druga kwerenda nie mogła zwrócić czegoś innego niż pierwsza — przy zachowaniu pierwotnej nazwy w nagłówku Host, w SNI i w weryfikacji certyfikatu. Przekierowania są wyłączone, bo podążanie za nimi omija walidację wejścia; jeśli funkcja ma je obsługiwać, każdy kolejny adres przechodzi tę samą ścieżkę od początku. Port jest ograniczony, bo inaczej ten endpoint służy do skanowania portów po stronie serwera. Limit czasu odcina cel, który odpowiada wolno, żeby nie trzymać połączeń.
Ta sama reguła dotyczy ponowień i połączeń zapasowych — jeśli biblioteka HTTP sama próbuje drugiego adresu z rekordu DNS, sprawdzenie trzeba przenieść niżej, do warstwy gniazda.
Druga warstwa: usługa metadanych i wyjście z sieci
Kod, który sam sprawdza adresy, jest pierwszą warstwą. Druga leży w infrastrukturze i działa także wtedy, gdy ktoś doda kolejny endpoint bez tych sprawdzeń.
W AWS tą warstwą jest wymuszenie IMDSv2. Dokumentacja opisuje, czym różni się od poprzednika: sesję zaczyna żądanie PUT po token, który trzeba potem dołączać w nagłówku do każdego GET, a żądania bez prawidłowego tokenu dostają 401. Dla SSRF liczą się dwa szczegóły z tej samej strony: żądania PUT są odrzucane, jeśli zawierają nagłówek X-Forwarded-For, a domyślny limit przeskoków w odpowiedzi na PUT wynosi 1. Pierwszy odcina atak przez serwer proxy, drugi — przez kontener, który sięgałby po token spoza instancji. Endpoint podglądu linku wysyła GET bez tokenu, więc po wyłączeniu IMDSv1 dostaje 401 zamiast poświadczeń.
Dla pozostałych dostawców i dla sieci wewnętrznej rolę tej warstwy pełni segregacja ruchu wychodzącego: reguły zapory ograniczające aplikację do tras, których realnie potrzebuje, albo osobne proxy wyjściowe dla całego ruchu inicjowanego treścią od użytkownika. OWASP nazywa to zablokowaniem nieuprawnionych wywołań na poziomie sieci i wskazuje jako zalecane uzupełnienie, nie zamiennik kontroli w kodzie.
Zestaw adresów do przejechania przed oddaniem
Ręczne sprawdzenie na adresie prawdziwej strony nie mówi nic. Wartość ma lista przypadków z oczekiwanym wynikiem, przepuszczona przez działający endpoint:
| Adres w żądaniu | Co sprawdza | Oczekiwany wynik |
|---|---|---|
http://169.254.169.254/latest/meta-data/ |
Usługa metadanych wprost | 400 z komunikatem o adresie niepublicznym |
http://127.0.0.1:3000/api/admin |
Pętla lokalna i własne API | 400, bez względu na port |
http://2852039166/ |
Zapis dziesiętny adresu | 400 po rozwiązaniu nazwy |
http://10.0.0.5:5432/ |
Prywatny zakres i port usługi | 400 na porcie, zanim dojdzie do połączenia |
Domena testowa z rekordem A na 127.0.0.1 |
Przepisanie DNS | 400, bo sprawdzany jest rozwiązany adres |
Adres publiczny zwracający 302 na 169.254.169.254 |
Przekierowanie | Odrzucenie na Location, bez drugiego żądania |
| Adres publiczny odpowiadający po 30 s | Limit czasu | Przerwanie po ustawionym limicie |
| Poprawny adres strony klienta | Ścieżka pozytywna | 200 i dane Open Graph |
Te osiem przypadków zapisuje się raz jako test integracyjny i zostaje w repozytorium. Domena testowa z rekordem A wskazującym na pętlę lokalną jest jedynym elementem wymagającym przygotowania — bez niej nie da się sprawdzić najważniejszego wariantu, bo wszystkie pozostałe obejścia dotyczą zapisu adresu, a ten dotyczy samego rozwiązywania nazw.
Co sprawdzić w przeglądzie kodu i co wpisać do dokumentacji
- Wyszukaj w repozytorium wszystkie wywołania klienta HTTP, których argument nie jest literałem ani wartością ze zmiennych środowiskowych. Każde takie miejsce to kandydat.
- Przy każdym ustal, czy zbiór celów jest znany z góry. Jeśli tak, przyjmuj identyfikator albo samą nazwę hosta i składaj adres w kodzie, zamiast przyjmować cały adres.
- Sprawdź, czy klient HTTP podąża za przekierowaniami. W
fetchdomyślne zachowanie to podążanie, więc brakredirect: 'manual'oznacza, że walidacja dotyczy tylko pierwszego żądania. - Sprawdź, czy walidacja działa na nazwie, czy na rozwiązanym adresie. Warunek na
hostnamenie chroni przed domeną wskazującą na adres prywatny. - Sprawdź, czy ten sam endpoint nie obsługuje przy okazji schematów innych niż
httpihttps. Biblioteki potrafią obsłużyćfile:albogopher:, a wtedy funkcja czyta pliki z dysku serwera. - Sprawdź, co trafia do odpowiedzi. Zwracanie surowej treści i kodu odpowiedzi z celu zamienia endpoint w narzędzie rozpoznania, nawet jeśli nie da się z niego wyciągnąć danych bezpośrednio.
- W dokumentacji przekazania zapisz, przez którą funkcję przechodzi każdy ruch wychodzący inicjowany treścią od użytkownika. Bez tego zdania następny endpoint pobierający adres powstanie obok tej funkcji, nie przez nią.
Decyzja do podjęcia nie dotyczy tego, czy dopisać warunki w tym jednym handlerze — to kilkadziesiąt linii. Dotyczy tego, czy ruch wychodzący inicjowany przez użytkownika przechodzi przez jedną funkcję w projekcie, czy przez tyle implementacji, ile jest takich funkcji w zamówieniu. Przy podglądzie linku, imporcie obrazka, webhooku i generowaniu PDF-u to już cztery miejsca, z których wystarczy jedno pominięte, żeby pozostałe trzy nie miały znaczenia. Jedna funkcja i jedna lista przypadków testowych kosztują dzień, a dają odpowiedź na pytanie, które przy odbiorze zadaje się raz: czy ten serwer może pobrać coś, czego nie powinien zobaczyć nikt poza nim.