Obrona przed SSRF w webhookach, podglądach linków i pobieraniu adresów URL
Jak SSRF zamienia webhooki, podglądy linków i importery w drogę do metadanych chmury i usług wewnętrznych oraz jakie obrony działają, z przykładem w Node.js.
· 7 min czytania · Lina Source LLC
Każda funkcja, która przyjmuje adres URL od użytkownika i pobiera go z Twojego serwera, to potencjalne fałszowanie żądań po stronie serwera (SSRF, CWE-918). Webhooki, podglądy linków, awatar z adresu URL, importy RSS i kalendarzy, generatory PDF oraz przyciski „importuj z adresu URL” mają ten sam kształt: Twój serwer wykonuje żądanie do celu wybranego przez kogoś innego.
Problemem jest to, gdzie stoi Twój serwer. Może sięgnąć tam, gdzie atakujący nie może: do usługi metadanych chmury, wewnętrznych paneli administracyjnych, baz danych bez haseł w sieci prywatnej i usług na localhoście. SSRF zamienia Twój serwer w proxy atakującego do tej sieci. Szczególnie łatwo wprowadzić tę podatność w małych zespołach, bo funkcja, która ją powoduje, wygląda niewinnie: pole z adresem URL na stronie ustawień, karta podglądu na czacie, funkcja pomocnicza pobierająca zdjęcie profilowe. Żadna z nich nie wygląda na kod dotyczący bezpieczeństwa sieci, więc rzadko są tak przeglądane.
Do czego dąży atakujący
- Endpointy metadanych chmury, najsłynniej 169.254.169.254 w AWS, GCP i Azure, które mogą zwrócić informacje o instancji, a w niektórych konfiguracjach tymczasowe poświadczenia roli tej maszyny.
- Usługi nasłuchujące na localhoście, takie jak interfejsy administracyjne, serwery debugowania i endpointy metryk, które zakładają wyłącznie lokalnych wywołujących.
- Usługi wewnętrzne w zakresach prywatnych (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), które nie mają uwierzytelniania, bo nigdy nie miały być osiągalne z zewnątrz.
- Skanowanie portów i wykrywanie usług, z wykorzystaniem czasów odpowiedzi lub komunikatów błędów do mapowania sieci wewnętrznej.
- Protokoły inne niż HTTP, jeśli biblioteka pobierająca obsługuje schematy takie jak file:, gopher: czy ftp:.
Nawet gdy odpowiedź nie wraca do atakującego, ślepy SSRF wciąż może wyzwalać żądania zmieniające stan wewnętrznych endpointów. Webhooki często są ślepe i to wcale nie czyni ich bezpiecznymi. Wiele usług wewnętrznych przyjmuje proste żądania GET, które zmieniają stan, takie jak czyszczenie pamięci podręcznej czy akcje administracyjne ukryte pod adresem URL, właśnie dlatego, że zakładają, iż nikt z zewnątrz ich nie osiągnie.
Dlaczego proste sprawdzenia zawodzą
Pierwszy odruch to sparsować adres URL i odrzucić nazwy hostów takie jak localhost albo łańcuchy zaczynające się od 10. czy 192.168. To zawodzi z kilku powodów.
- Nazwy hostów rozwiązują się do adresów IP. Atakujący rejestruje domenę, której rekord A wskazuje na 127.0.0.1 albo 169.254.169.254, a sprawdzenie nazwy hosta nie widzi nic złego.
- Adresy IP mają wiele zapisów: dziesiętny (2130706433), szesnastkowy, skrócony jak 127.1 oraz IPv6 z mapowanym IPv4, jak ::ffff:127.0.0.1. Dopasowywanie łańcuchów je przeoczy.
- Rebinding DNS: domena rozwiązuje się do publicznego adresu IP, gdy ją walidujesz, i do prywatnego chwilę później, gdy klient HTTP nawiązuje połączenie. Walidacja i połączenie to dwa osobne odpytania.
- Przekierowania: adres URL, który zwalidowałeś, zwraca 302 na http://169.254.169.254/, a klient HTTP podąża za nim, nie pytając Cię o zdanie.
Wspólnym mianownikiem jest to, że walidacja dotyczy czegoś innego niż adres, z którym faktycznie łączy się gniazdo. Poprawka polega na sprawdzaniu rozwiązanego adresu IP w chwili nawiązywania połączenia, dla każdego połączenia, także po przekierowaniach.
Obrony, od najmocniejszej
Gdy możesz, stosuj listę dozwolonych celów
Jeśli funkcja musi rozmawiać tylko ze znanym zbiorem hostów, jak integracja z kilkoma dostawcami, umieść te nazwy hostów na liście dozwolonych i odrzucaj całą resztę. To najmocniejszy mechanizm i najłatwiejszy do zrozumienia. Porównuj sparsowaną nazwę hosta dokładnie, a nie sprawdzeniami startsWith czy endsWith, które przepuszczają łudząco podobne hosty, takie jak api.example.com.attacker.net czy evilexample.com. Wszystko poniżej dotyczy funkcji, które muszą przyjmować dowolne publiczne adresy URL.
Ogranicz schemat i port
Przyjmuj tylko https: (i http: wyłącznie jeśli musisz). Odrzucaj poświadczenia w adresie URL i ogranicz porty do 443 i 80, o ile nie ma wyraźnej potrzeby. Parsuj standardowym parserem URL, nigdy wyrażeniem regularnym; parser WHATWG normalizuje też dziwne zapisy adresów IP, co pomaga przy późniejszych sprawdzeniach.
Rozwiąż nazwę, a potem sprawdź IP w chwili połączenia
Blokuj zakresy pętli zwrotnej, prywatne, link-local, carrier-grade NAT, nieokreślone oraz zakresy IPv6 unique-local i link-local. Co kluczowe, wykonuj to sprawdzenie wewnątrz odpytania DNS, z którego korzysta klient HTTP, żeby walidowany adres był tym samym adresem, z którym następuje połączenie. To zamyka lukę rebindingu DNS. Moduły http i https w Node przyjmują własną funkcję lookup właśnie do tego.
Poniższa lista blokad obejmuje zakresy istotne dla większości wdrożeń. Dodaj wszystkie zakresy publiczne należące do Twojej własnej infrastruktury, bo load balancer czy wewnętrzne API z publicznym adresem IP wciąż może ufać żądaniom z wnętrza Twojej sieci. Odrzucaj nazwę hosta, jeśli którykolwiek z jej rozwiązanych adresów jest zablokowany, a nie tylko pierwszy z nich, bo klient może próbować ich w dowolnej kolejności.
// safe-lookup.js
import dns from "node:dns";
import net from "node:net";
const blocked = new net.BlockList();
blocked.addSubnet("0.0.0.0", 8, "ipv4");
blocked.addSubnet("10.0.0.0", 8, "ipv4");
blocked.addSubnet("100.64.0.0", 10, "ipv4");
blocked.addSubnet("127.0.0.0", 8, "ipv4");
blocked.addSubnet("169.254.0.0", 16, "ipv4");
blocked.addSubnet("172.16.0.0", 12, "ipv4");
blocked.addSubnet("192.168.0.0", 16, "ipv4");
blocked.addAddress("::", "ipv6");
blocked.addAddress("::1", "ipv6");
blocked.addSubnet("::ffff:0:0", 96, "ipv6"); // IPv4 mapowane na IPv6
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
export function isBlockedIp(address, family) {
return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}
// Waliduje każdy rozwiązany adres, zanim gniazdo nawiąże połączenie
export function safeLookup(hostname, options, callback) {
dns.lookup(hostname, { ...options, all: true }, (err, addresses) => {
if (err) return callback(err);
const denied =
addresses.length === 0 ||
addresses.some((a) => isBlockedIp(a.address, a.family));
if (denied) {
return callback(new Error("Destination not allowed: " + hostname));
}
if (options.all) return callback(null, addresses);
callback(null, addresses[0].address, addresses[0].family);
});
}Jeden szczegół łatwo przeoczyć: gdy adres URL zawiera literał IP, Node łączy się bezpośrednio, w ogóle nie wywołując lookup. Dlatego funkcja wysyłająca żądanie musi sama sprawdzić literały IP, zanim przekaże sterowanie dalej.
import https from "node:https";
import net from "node:net";
import { isBlockedIp, safeLookup } from "./safe-lookup.js";
export function postWebhook(rawUrl, payload) {
const url = new URL(rawUrl);
if (url.protocol !== "https:") throw new Error("Only https is allowed");
if (url.port && url.port !== "443") throw new Error("Port not allowed");
if (url.username || url.password) throw new Error("Credentials not allowed");
const host = url.hostname.replace(/^\[|\]$/g, "");
const family = net.isIP(host);
if (family !== 0 && isBlockedIp(host, family)) {
throw new Error("Destination not allowed");
}
return new Promise((resolve, reject) => {
const req = https.request(
url,
{
method: "POST",
lookup: safeLookup,
timeout: 5000,
headers: { "content-type": "application/json" },
},
(res) => {
// node:https nigdy nie podąża za przekierowaniami; 3xx traktujemy jako błąd
res.resume();
resolve(res.statusCode);
}
);
req.on("timeout", () => req.destroy(new Error("Request timed out")));
req.on("error", reject);
req.end(JSON.stringify(payload));
});
}Jeśli w Node używasz fetch, które opiera się na undici, ta sama idea działa przez własny dispatcher: utwórz Agenta undici z opcją connect.lookup i przekaż go jako dispatcher. Zasada się nie zmienia: sprawdzenie żyje tam, gdzie nawiązywane jest połączenie. Jeśli Twoje środowisko kieruje ruch wychodzący przez proxy HTTP, pamiętaj, że odpytanie DNS dla hosta docelowego odbywa się wtedy na proxy, więc samo proxy musi egzekwować te same reguły.
Wyłącz przekierowania albo waliduj każdy skok
W webhookach w ogóle nie podążaj za przekierowaniami; odbiorca, który przekierowuje, jest źle skonfigurowany, a głośna porażka mówi klientowi, żeby naprawił swój endpoint. W podglądach linków i importerach, gdzie przekierowania są normalne, podążaj za nimi ręcznie z niewielkim limitem i przepuszczaj każdy skok przez te same sprawdzenia schematu, portu i adresu IP. W fetch ustaw redirect: "manual" i sam obsłuż nagłówek Location.
Ogranicz, co może zdziałać udane żądanie
- Ustaw limity czasu połączenia i całkowitego czasu oraz ogranicz rozmiar odpowiedzi, żeby pobierania nie dało się użyć do trzymania otwartych połączeń ani ściągania dużych plików.
- Nie zwracaj użytkownikowi surowych odpowiedzi ani szczegółowych komunikatów błędów. W podglądach linków zwracaj tylko wyodrębniony tytuł, opis i adres URL obrazu.
- Usuwaj własne nagłówki uwierzytelniania i ciasteczka; pobieranie nigdy nie powinno wysyłać wewnętrznych poświadczeń do hostów podanych przez użytkownika.
Kieruj ruch przez proxy wyjściowe
Najbardziej odporne rozwiązanie przenosi politykę poza kod aplikacji. Prowadź wychodzące pobrania inicjowane przez użytkowników przez dedykowane proxy wyjściowe albo z odizolowanego workera w segmencie sieci, który po prostu nie ma trasy do usług wewnętrznych ani do endpointu metadanych. Wtedy błąd w walidacji adresu URL nie zamienia się w naruszenie, bo odmawia sama sieć. Proxy przekazujące typu open source potrafią egzekwować reguły celów za Ciebie, a osobny worker dodatkowo izoluje powolne lub wrogie odpowiedzi od Twojego głównego procesu webowego.
Utwardź usługę metadanych
W AWS wymagaj IMDSv2, które potrzebuje tokena sesji uzyskanego żądaniem PUT, i utrzymuj limit skoków na 1, żeby kontenery nie mogły dosięgnąć usługi przez hosta. GCP i Azure wymagają przy wywołaniach metadanych określonego nagłówka żądania. Te środki znacznie podnoszą poprzeczkę przy prostym SSRF opartym na GET, ale są drugą warstwą, a nie zamiennikiem blokowania adresów link-local. Nadaj też roli instancji lub usługi tylko te uprawnienia, których aplikacja potrzebuje, żeby poświadczenia wykradzione przez usługę metadanych odblokowały jak najmniej.
Testowanie swoich zabezpieczeń
- Spróbuj http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ oraz http://169.254.169.254/latest/meta-data/ i potwierdź, że każdy z nich jest odrzucany.
- Skieruj kontrolowaną przez siebie nazwę hosta na 127.0.0.1 i potwierdź, że sprawdzenie w lookup ją odrzuca. Potem nadaj jej dwa rekordy A, jeden publiczny i jeden prywatny, i potwierdź, że nadal jest odrzucana.
- Podaj z publicznego hosta przekierowanie 302 na adres prywatny i potwierdź, że nie jest ono śledzone.
- Spróbuj adresów file:, ftp: i gopher: oraz adresów URL z poświadczeniami lub nietypowymi portami.
Podczas przeglądu kodu szukaj każdego miejsca, w którym adres URL żądania powstaje z danych wejściowych: fetch, axios, got, requests, http.Get, wywołań page.goto w przeglądarce headless oraz bibliotek przetwarzania obrazów przyjmujących adresy URL. CodeAuditAgent śledzi ten przepływ danych w publicznych repozytoriach i wklejonych fragmentach kodu oraz zgłasza SSRF jako CWE-918 z zacytowanym miejscem wywołania, scenariuszem ataku i poprawką.