Difesa dall'SSRF per webhook, anteprime dei link e fetcher di URL
Come l'SSRF trasforma webhook, anteprime e importatori in una via verso i metadati cloud e i servizi interni, e le difese che reggono, con esempio Node.js.
· 7 min di lettura · Lina Source LLC
Qualsiasi funzionalità che riceve un URL da un utente e lo recupera dal tuo server è una potenziale server-side request forgery (SSRF, CWE-918). Webhook, anteprime dei link, avatar da URL, importazioni di RSS e calendari, renderer di PDF e pulsanti «importa da URL» condividono tutti la stessa forma: il tuo server effettua una richiesta verso una destinazione scelta da qualcun altro.
Il problema è dove si trova il tuo server. Può raggiungere cose che l'attaccante non può raggiungere: il servizio di metadati cloud, pannelli di amministrazione interni, database senza password su una rete privata e servizi in ascolto su localhost. L'SSRF trasforma il tuo server nel proxy dell'attaccante verso quella rete. È particolarmente facile da introdurre nei team piccoli, perché la funzionalità che lo provoca sembra innocua: un campo URL in una pagina di impostazioni, una card di anteprima in una chat, un helper che scarica un'immagine del profilo. Nessuno di questi sembra codice di sicurezza di rete, quindi raramente viene revisionato come tale.
Che cosa cerca un attaccante
- Endpoint di metadati cloud, il più noto dei quali è 169.254.169.254 su AWS, GCP e Azure, che possono restituire dettagli dell'istanza e, in alcune configurazioni, credenziali temporanee per il ruolo della macchina.
- Servizi in ascolto su localhost, come interfacce di amministrazione, server di debug ed endpoint di metriche che danno per scontato di ricevere solo chiamate locali.
- Servizi interni su intervalli privati (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) privi di autenticazione perché non erano pensati per essere raggiungibili dall'esterno.
- Scansione di porte e scoperta dei servizi, usando i tempi di risposta o i messaggi di errore per mappare la rete interna.
- Protocolli diversi da HTTP, se la libreria di fetch supporta schemi come file:, gopher: o ftp:.
Anche quando la risposta non viene restituita all'attaccante, un SSRF blind può comunque innescare richieste che modificano lo stato di endpoint interni. I webhook sono spesso blind, e questo non li rende sicuri. Molti servizi interni accettano semplici richieste GET che modificano lo stato, come svuotamenti della cache o azioni amministrative dietro un URL, proprio perché danno per scontato che nessuno dall'esterno possa raggiungerli.
Perché i controlli semplici falliscono
Il primo istinto è analizzare l'URL e rifiutare hostname come localhost o stringhe che iniziano con 10. o 192.168. Questo approccio fallisce per diversi motivi.
- Gli hostname si risolvono in IP. Un attaccante registra un dominio il cui record A punta a 127.0.0.1 o 169.254.169.254, e un controllo sull'hostname non nota nulla di strano.
- Gli indirizzi IP hanno molte grafie: decimale (2130706433), esadecimale, forme abbreviate come 127.1 e IPv6 con IPv4 mappato come ::ffff:127.0.0.1. Il confronto tra stringhe se li lascia sfuggire.
- DNS rebinding: il dominio si risolve in un IP pubblico quando lo validi e in un IP privato un istante dopo, quando il client HTTP si connette. Validazione e connessione sono due risoluzioni distinte.
- Redirect: l'URL che hai validato restituisce un 302 verso http://169.254.169.254/, e il client HTTP lo segue senza chiederti nulla.
Il filo conduttore è che la validazione avviene su qualcosa di diverso dall'indirizzo a cui il socket si connette davvero. La correzione è verificare l'IP risolto al momento della connessione, per ogni connessione, anche dopo i redirect.
Difese, dalla più forte alla più debole
Usa una allowlist di destinazioni quando puoi
Se la funzionalità deve parlare solo con un insieme noto di host, come un'integrazione con una manciata di provider, metti quegli hostname in allowlist e rifiuta tutto il resto. È il controllo più forte e il più semplice da ragionare. Confronta l'hostname analizzato in modo esatto, non con controlli startsWith o endsWith, che accettano host simili come api.example.com.attacker.net o evilexample.com. Tutto ciò che segue riguarda le funzionalità che devono accettare URL pubblici arbitrari.
Limita schema e porta
Accetta solo https: (e http: solo se proprio devi). Rifiuta le credenziali nell'URL e limita le porte a 443 e 80 a meno che non ci sia un'esigenza chiara. Analizza l'URL con un parser standard, mai con una regex; il parser WHATWG normalizza anche le grafie IP anomale, il che aiuta i controlli successivi.
Risolvi, poi verifica l'IP al momento della connessione
Blocca gli intervalli di loopback, privati, link-local, carrier-grade NAT, non specificati e gli intervalli IPv6 unique-local e link-local. Soprattutto, applica il controllo dentro la risoluzione DNS usata dal client HTTP, così l'indirizzo validato è l'indirizzo a cui ci si connette. Questo chiude la falla del DNS rebinding. I moduli http e https di Node accettano una funzione lookup personalizzata proprio per questo.
La lista di blocco qui sotto copre gli intervalli che contano per la maggior parte dei deployment. Aggiungi gli eventuali intervalli pubblici che appartengono alla tua infrastruttura, dato che un load balancer o un'API interna con un IP pubblico possono comunque fidarsi delle richieste provenienti dalla tua rete. Rifiuta un hostname se anche solo uno dei suoi indirizzi risolti è bloccato, non solo il primo, perché il client può provarli in qualsiasi ordine.
// 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 mappato
blocked.addSubnet("fc00::", 7, "ipv6");
blocked.addSubnet("fe80::", 10, "ipv6");
export function isBlockedIp(address, family) {
return blocked.check(address, family === 6 ? "ipv6" : "ipv4");
}
// Valida ogni indirizzo risolto prima che il socket si connetta
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);
});
}Un dettaglio è facile da farsi sfuggire: quando l'URL contiene un IP letterale, Node si connette direttamente senza chiamare affatto lookup. Quindi la funzione che effettua la richiesta deve verificare da sé gli IP letterali prima di passare il controllo.
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 non segue mai i redirect; un 3xx è trattato come errore
res.resume();
resolve(res.statusCode);
}
);
req.on("timeout", () => req.destroy(new Error("Request timed out")));
req.on("error", reject);
req.end(JSON.stringify(payload));
});
}Se in Node usi fetch, che è costruito su undici, la stessa idea si applica tramite un dispatcher personalizzato: crea un Agent di undici con l'opzione connect.lookup e passalo come dispatcher. Il principio non cambia: il controllo vive dove viene stabilita la connessione. Se il tuo ambiente instrada il traffico in uscita attraverso un proxy HTTP, tieni presente che la risoluzione per l'host di destinazione avviene allora sul proxy, quindi deve essere il proxy stesso ad applicare le stesse regole.
Disattiva i redirect, oppure rivalida ogni hop
Per i webhook, non seguire affatto i redirect; un ricevitore che fa redirect è mal configurato, e fallire in modo esplicito dice al cliente di sistemare il suo endpoint. Per anteprime dei link e importatori, dove i redirect sono normali, seguili manualmente con un limite basso e fai passare ogni hop dagli stessi controlli su schema, porta e IP. Con fetch, imposta redirect: «manual» e gestisci tu stesso l'header Location.
Limita ciò che una richiesta riuscita può fare
- Imposta timeout di connessione e totali, e limita la dimensione della risposta, così il fetcher non può essere usato per tenere aperte connessioni o scaricare file di grandi dimensioni.
- Non restituire all'utente risposte grezze o messaggi di errore dettagliati. Per le anteprime dei link, restituisci solo il titolo, la descrizione e l'URL dell'immagine estratti.
- Rimuovi i tuoi header di autenticazione e i cookie; il fetcher non deve mai inviare credenziali interne a host forniti dagli utenti.
Instrada tutto attraverso un proxy di uscita
La configurazione più robusta sposta la policy fuori dal codice applicativo. Fai passare le richieste in uscita guidate dagli utenti attraverso un proxy di uscita dedicato, oppure eseguile da un worker isolato in un segmento di rete che semplicemente non ha alcuna rotta verso i servizi interni o l'endpoint dei metadati. Allora un bug nella validazione degli URL non diventa una violazione, perché è la rete stessa a rifiutare. I forward proxy open source possono applicare per te le regole sulle destinazioni, e un worker separato isola anche le risposte lente o ostili dal tuo processo web principale.
Rafforza il servizio di metadati
Su AWS, richiedi IMDSv2, che necessita di un token di sessione ottenuto con una richiesta PUT, e mantieni l'hop limit a 1 così i container non possono raggiungerlo attraverso l'host. GCP e Azure richiedono un header specifico nelle chiamate ai metadati. Queste misure alzano parecchio l'asticella per gli SSRF semplici basati su GET, ma sono un secondo livello, non un sostituto del blocco degli indirizzi link-local. Assegna inoltre al ruolo dell'istanza o del servizio solo i permessi di cui l'applicazione ha bisogno, così le credenziali rubate tramite il servizio di metadati sbloccano il meno possibile.
Testare le tue difese
- Prova http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ e http://169.254.169.254/latest/meta-data/ e verifica che ciascuno venga rifiutato.
- Fai puntare a 127.0.0.1 un hostname che controlli e verifica che il controllo sulla risoluzione lo rifiuti. Poi dagli due record A, uno pubblico e uno privato, e verifica che venga comunque rifiutato.
- Fai servire da un host pubblico un redirect 302 verso un indirizzo privato e verifica che non venga seguito.
- Prova URL file:, ftp: e gopher:, e URL con credenziali o porte insolite.
In code review, cerca ogni punto in cui l'URL di una richiesta viene costruito a partire dall'input: fetch, axios, got, requests, http.Get, chiamate page.goto di browser headless e librerie di elaborazione immagini che accettano URL. CodeAuditAgent traccia quel flusso di dati nei repository pubblici e negli snippet incollati e segnala l'SSRF come CWE-918 con il punto di chiamata citato, uno scenario di exploit e una patch.