SSRF-verdediging voor webhooks, linkvoorbeelden en URL-fetchers
Hoe SSRF webhooks, linkvoorbeelden en importers verandert in een pad naar cloud-metadata en interne services, plus de verdedigingen die standhouden.
· 7 min. leestijd · Lina Source LLC
Elke functie die een URL van een gebruiker aanneemt en die vanaf je server ophaalt, is een potentiële server-side request forgery (SSRF, CWE-918). Webhooks, linkvoorbeelden, avatar-via-URL, RSS- en agenda-imports, PDF-renderers en knoppen om "vanaf URL te importeren" hebben allemaal dezelfde vorm: je server doet een request naar een bestemming die iemand anders kiest.
Het probleem is waar je server staat. Hij kan dingen bereiken die de aanvaller niet kan: de cloud-metadataservice, interne adminpanelen, databases zonder wachtwoord op een privénetwerk en services op localhost. SSRF maakt van je server de proxy van de aanvaller naar dat netwerk. Het sluipt er vooral makkelijk in bij kleine teams, omdat de functie die het veroorzaakt onschuldig lijkt: een URL-veld in een instellingenpagina, een voorbeeldkaart in een chat, een helper die een profielfoto downloadt. Geen daarvan ziet eruit als netwerkbeveiligingscode, dus ze worden zelden als zodanig gereviewd.
Waar een aanvaller op mikt
- Cloud-metadata-endpoints, het bekendst 169.254.169.254 op AWS, GCP en Azure, die instancegegevens kunnen teruggeven en in sommige configuraties tijdelijke credentials voor de rol van de machine.
- Services die aan localhost zijn gebonden, zoals adminomgevingen, debugservers en metrics-endpoints die aannemen dat alleen lokale aanroepers ze bereiken.
- Interne services op privéranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) zonder authenticatie, omdat ze nooit van buitenaf bereikbaar hoorden te zijn.
- Poortscans en servicediscovery, waarbij responstijden of foutmeldingen het interne netwerk in kaart brengen.
- Niet-HTTP-protocollen, als de fetch-bibliotheek schema’s als file:, gopher: of ftp: ondersteunt.
Ook als de response niet bij de aanvaller terechtkomt, kan een blinde SSRF nog steeds statuswijzigende requests naar interne endpoints afvuren. Webhooks zijn vaak blind, en dat maakt ze niet veilig. Veel interne services accepteren eenvoudige GET-requests die de status wijzigen, zoals cache-purges of adminacties achter een URL, juist omdat ze ervan uitgaan dat niemand van buiten ze kan bereiken.
Waarom simpele controles falen
De eerste ingeving is de URL te parsen en hostnamen als localhost of strings die met 10. of 192.168. beginnen te weigeren. Dat faalt om verschillende redenen.
- Hostnamen worden omgezet naar IP’s. Een aanvaller registreert een domein waarvan het A-record naar 127.0.0.1 of 169.254.169.254 wijst, en een hostnaamcontrole ziet niets verdachts.
- IP-adressen hebben veel schrijfwijzen: decimaal (2130706433), hexadecimaal, korte vormen als 127.1 en IPv4-mapped IPv6 zoals ::ffff:127.0.0.1. Stringvergelijkingen missen ze.
- DNS-rebinding: het domein wijst naar een publiek IP op het moment dat je valideert en naar een privé-IP even later, wanneer de HTTP-client verbinding maakt. Valideren en verbinden zijn twee aparte lookups.
- Redirects: de URL die je valideerde geeft een 302 naar http://169.254.169.254/ terug, en de HTTP-client volgt die zonder het te vragen.
De rode draad is dat de validatie plaatsvindt op iets anders dan het adres waarmee de socket daadwerkelijk verbindt. De fix is het opgeloste IP te controleren op het moment van verbinden, bij elke verbinding, ook na redirects.
Verdedigingen, sterkste eerst
Werk met een allowlist van bestemmingen waar het kan
Hoeft de functie alleen met een bekende set hosts te praten, zoals een integratie met een handvol providers, zet die hostnamen dan op een allowlist en weiger al het andere. Dit is de sterkste maatregel en de eenvoudigste om over te redeneren. Vergelijk de geparste hostnaam exact, niet met startsWith- of endsWith-controles, want die accepteren lijkende hosts zoals api.example.com.attacker.net of evilexample.com. Alles hieronder is voor functies die wel willekeurige publieke URL’s moeten accepteren.
Beperk schema en poort
Accepteer alleen https: (en http: alleen als het echt moet). Weiger credentials in de URL en beperk de poorten tot 443 en 80, tenzij er een duidelijke noodzaak is. Parse met een standaard URL-parser, nooit met een regex; de WHATWG-parser normaliseert bovendien vreemde IP-schrijfwijzen, wat latere controles helpt.
Resolve eerst, controleer het IP bij het verbinden
Blokkeer loopback, privé, link-local, carrier-grade NAT, het niet-gespecificeerde adres en de IPv6 unique-local- en link-local-ranges. Cruciaal is dat je de controle uitvoert binnen de DNS-lookup die de HTTP-client gebruikt, zodat het gevalideerde adres ook het adres is waarmee verbinding wordt gemaakt. Dat dicht het gat van DNS-rebinding. De modules http en https van Node accepteren precies hiervoor een eigen lookup-functie.
De blokkeerlijst hieronder dekt de ranges die voor de meeste omgevingen van belang zijn. Voeg eventuele publieke ranges van je eigen infrastructuur toe, want een load balancer of interne API met een publiek IP kan nog steeds requests van binnen je netwerk vertrouwen. Weiger een hostnaam als een van de opgeloste adressen geblokkeerd is, niet alleen het eerste, want de client kan ze in willekeurige volgorde proberen.
// 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-mapped
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
export function isBlockedIp(address, family) {
return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}
// Valideert elk opgelost adres voordat de socket verbinding maakt
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);
});
}Eén detail wordt makkelijk over het hoofd gezien: bevat de URL een letterlijk IP-adres, dan verbindt Node rechtstreeks en roept het lookup helemaal niet aan. De requestfunctie moet letterlijke IP’s dus zelf controleren voordat ze het overdraagt.
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 volgt nooit redirects; een 3xx geldt als mislukking
res.resume();
resolve(res.statusCode);
}
);
req.on("timeout", () => req.destroy(new Error("Request timed out")));
req.on("error", reject);
req.end(JSON.stringify(payload));
});
}Gebruik je fetch in Node, dat op undici is gebouwd, dan geldt hetzelfde idee via een eigen dispatcher: maak een undici Agent met een connect.lookup-optie en geef die als dispatcher mee. Het principe verandert niet: de controle hoort waar de verbinding wordt gelegd. Loopt uitgaand verkeer in je omgeving via een HTTP-proxy, houd er dan rekening mee dat de lookup voor de doelhost dan op de proxy gebeurt, dus de proxy zelf moet dezelfde regels afdwingen.
Schakel redirects uit, of valideer elke stap opnieuw
Volg bij webhooks helemaal geen redirects; een ontvanger die doorstuurt is verkeerd geconfigureerd, en luid falen vertelt de klant dat hij zijn endpoint moet herstellen. Bij linkvoorbeelden en importers, waar redirects normaal zijn, volg je ze handmatig met een kleine limiet en haal je elke stap door dezelfde schema-, poort- en IP-controles. Zet met fetch redirect: "manual" en verwerk de Location-header zelf.
Beperk wat een geslaagd request kan doen
- Stel connect- en totale timeouts in en begrens de responsgrootte, zodat de fetcher niet gebruikt kan worden om verbindingen open te houden of grote bestanden binnen te halen.
- Geef geen ruwe responses of gedetailleerde foutmeldingen terug aan de gebruiker. Geef bij linkvoorbeelden alleen de geëxtraheerde titel, beschrijving en afbeeldings-URL terug.
- Strip je eigen authenticatieheaders en cookies; de fetcher mag nooit interne credentials naar door de gebruiker aangeleverde hosts sturen.
Route via een egress-proxy
De robuustste opzet haalt het beleid uit de applicatiecode. Laat uitgaande, door gebruikers aangestuurde fetches via een speciale egress-proxy lopen, of vanaf een geïsoleerde worker in een netwerksegment dat simpelweg geen route heeft naar interne services of het metadata-endpoint. Dan wordt een bug in de URL-validatie geen inbraak, omdat het netwerk zelf weigert. Opensource forward proxies kunnen bestemmingsregels voor je afdwingen, en een aparte worker isoleert bovendien trage of vijandige responses van je belangrijkste webproces.
Hard de metadataservice
Vereis op AWS IMDSv2, dat een sessietoken nodig heeft dat je met een PUT-request ophaalt, en houd de hop limit op 1 zodat containers er niet via de host bij kunnen. GCP en Azure vereisen een specifieke requestheader bij metadata-aanroepen. Dat legt de lat flink hoger voor eenvoudige, op GET gebaseerde SSRF, maar het is een tweede laag en geen vervanging voor het blokkeren van link-local-adressen. Geef de instance- of servicerol daarnaast alleen de rechten die de app nodig heeft, zodat credentials die via de metadataservice worden gestolen zo min mogelijk ontsluiten.
Je verdediging testen
- Probeer http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ en http://169.254.169.254/latest/meta-data/ en bevestig dat elk wordt geweigerd.
- Laat een hostnaam die je beheert naar 127.0.0.1 wijzen en bevestig dat de lookup-controle hem weigert. Geef hem daarna twee A-records, één publiek en één privé, en bevestig dat hij nog steeds wordt geweigerd.
- Serveer vanaf een publieke host een 302-redirect naar een privéadres en bevestig dat die niet wordt gevolgd.
- Probeer file:-, ftp:- en gopher:-URL’s, en URL’s met credentials of ongebruikelijke poorten.
Zoek bij codereview naar elke plek waar een request-URL uit invoer wordt opgebouwd: fetch, axios, got, requests, http.Get, page.goto-aanroepen van een headless browser en beeldverwerkingsbibliotheken die URL’s accepteren. CodeAuditAgent volgt die datastroom in openbare repository’s en geplakte codefragmenten en rapporteert SSRF als CWE-918 met de geciteerde aanroepplek, een exploitscenario en een patch.