- Sécurité
- SSRF
- Node.js
Se défendre contre la SSRF : webhooks, aperçus de liens et récupération d’URL
Comment la SSRF transforme webhooks, aperçus de liens et imports en accès aux métadonnées cloud et services internes, et les défenses qui tiennent (exemple Node.js).
· 7 min de lecture · Lina Source LLC
Toute fonctionnalité qui reçoit une URL d’un utilisateur et la récupère depuis votre serveur est une falsification de requête côté serveur potentielle (SSRF, CWE-918). Webhooks, aperçus de liens, avatar depuis une URL, imports RSS et de calendriers, moteurs de rendu PDF et boutons « importer depuis une URL » partagent tous la même structure : votre serveur envoie une requête vers une destination choisie par quelqu’un d’autre.
Le problème tient à la position de votre serveur. Il peut atteindre ce que l’attaquant ne peut pas atteindre : le service de métadonnées du cloud, des panneaux d’administration internes, des bases de données sans mot de passe sur un réseau privé et des services sur localhost. La SSRF fait de votre serveur le proxy de l’attaquant vers ce réseau. Elle est particulièrement facile à introduire dans les petites équipes, car la fonctionnalité qui la provoque paraît anodine : un champ URL dans une page de paramètres, une carte d’aperçu dans un chat, un helper qui télécharge une photo de profil. Rien de tout cela ne ressemble à du code de sécurité réseau, et c’est rarement relu comme tel.
Ce que vise un attaquant
- Les endpoints de métadonnées du cloud, le plus célèbre étant 169.254.169.254 sur AWS, GCP et Azure, qui peuvent renvoyer des informations sur l’instance et, dans certaines configurations, des identifiants temporaires pour le rôle de la machine.
- Les services liés à localhost, comme les interfaces d’administration, les serveurs de débogage et les endpoints de métriques qui supposent que seuls des appelants locaux les contactent.
- Les services internes sur des plages privées (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16) dépourvus d’authentification parce qu’ils n’ont jamais été censés être accessibles de l’extérieur.
- Le scan de ports et la découverte de services, en exploitant les temps de réponse ou les messages d’erreur pour cartographier le réseau interne.
- Les protocoles autres que HTTP, si la bibliothèque de requêtes prend en charge des schémas comme file:, gopher: ou ftp:.
Même lorsque la réponse n’est pas renvoyée à l’attaquant, une SSRF à l’aveugle peut déclencher des requêtes qui modifient l’état d’endpoints internes. Les webhooks sont souvent à l’aveugle, et cela ne les rend pas sûrs pour autant. De nombreux services internes acceptent de simples requêtes GET qui modifient l’état, comme des purges de cache ou des actions d’administration derrière une URL, précisément parce qu’ils supposent que personne à l’extérieur ne peut les atteindre.
Pourquoi les vérifications simples échouent
Le premier réflexe est d’analyser l’URL et de rejeter les noms d’hôte comme localhost ou les chaînes qui commencent par 10. ou 192.168. Cela échoue pour plusieurs raisons.
- Les noms d’hôte se résolvent en adresses IP. Un attaquant enregistre un domaine dont l’enregistrement A pointe vers 127.0.0.1 ou 169.254.169.254, et une vérification du nom d’hôte n’y voit rien d’anormal.
- Les adresses IP s’écrivent de multiples façons : en décimal (2130706433), en hexadécimal, sous des formes courtes comme 127.1, et en IPv6 mappée IPv4 comme ::ffff:127.0.0.1. La comparaison de chaînes les laisse passer.
- DNS rebinding : le domaine se résout en IP publique au moment de la validation, puis en IP privée un instant plus tard, lorsque le client HTTP se connecte. Valider et se connecter sont deux résolutions distinctes.
- Redirections : l’URL que vous avez validée renvoie un 302 vers http://169.254.169.254/, et le client HTTP le suit sans vous demander votre avis.
Le point commun : la validation porte sur autre chose que l’adresse à laquelle le socket se connecte réellement. La solution consiste à vérifier l’IP résolue au moment de la connexion, pour chaque connexion, y compris après les redirections.
Les défenses, de la plus forte à la plus faible
Utiliser une liste d’autorisation de destinations quand c’est possible
Si la fonctionnalité n’a besoin de communiquer qu’avec un ensemble connu d’hôtes, par exemple une intégration avec quelques fournisseurs, placez ces noms d’hôte dans une liste d’autorisation et rejetez tout le reste. C’est le contrôle le plus fort et le plus simple à raisonner. Comparez le nom d’hôte analysé de manière exacte, et non avec des vérifications startsWith ou endsWith, qui acceptent des hôtes trompeurs comme api.example.com.attacker.net ou evilexample.com. Tout ce qui suit concerne les fonctionnalités qui doivent accepter des URL publiques arbitraires.
Restreindre le schéma et le port
N’acceptez que https: (et http: seulement si c’est indispensable). Rejetez les identifiants dans l’URL et limitez les ports à 443 et 80, sauf besoin clairement établi. Analysez l’URL avec un parseur standard, jamais avec une expression régulière ; le parseur WHATWG normalise en outre les écritures d’IP inhabituelles, ce qui facilite les vérifications suivantes.
Résoudre, puis vérifier l’IP au moment de la connexion
Bloquez les plages de loopback, privées, link-local, de NAT de niveau opérateur (CGNAT), non spécifiées, ainsi que les plages IPv6 unique-local et link-local. Surtout, appliquez la vérification à l’intérieur de la résolution DNS utilisée par le client HTTP, afin que l’adresse validée soit bien celle à laquelle on se connecte. Cela comble la faille du DNS rebinding. Les modules http et https de Node acceptent une fonction lookup personnalisée précisément pour cela.
La liste de blocage ci-dessous couvre les plages qui comptent pour la plupart des déploiements. Ajoutez-y les plages publiques qui appartiennent à votre propre infrastructure, car un load balancer ou une API interne dotés d’une IP publique peuvent tout de même faire confiance aux requêtes provenant de votre réseau. Rejetez un nom d’hôte si l’une quelconque de ses adresses résolues est bloquée, pas seulement la première, car le client peut les essayer dans n’importe quel ordre.
// 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");
}
// Validates every resolved address before the socket connects
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 détail passe facilement inaperçu : lorsque l’URL contient une IP littérale, Node se connecte directement sans appeler lookup. La fonction de requête doit donc vérifier elle-même les IP littérales avant de passer la main.
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 never follows redirects; a 3xx is treated as a failure
res.resume();
resolve(res.statusCode);
}
);
req.on("timeout", () => req.destroy(new Error("Request timed out")));
req.on("error", reject);
req.end(JSON.stringify(payload));
});
}Si vous utilisez fetch dans Node, qui repose sur undici, la même idée s’applique via un dispatcher personnalisé : créez un Agent undici avec une option connect.lookup et passez-le comme dispatcher. Le principe ne change pas : la vérification se fait là où la connexion est établie. Si votre environnement fait passer le trafic sortant par un proxy HTTP, notez que la résolution de l’hôte cible a alors lieu sur le proxy : c’est donc le proxy lui-même qui doit appliquer les mêmes règles.
Désactiver les redirections, ou revalider chaque saut
Pour les webhooks, ne suivez aucune redirection : un récepteur qui redirige est mal configuré, et un échec franc indique au client qu’il doit corriger son endpoint. Pour les aperçus de liens et les imports, où les redirections sont normales, suivez-les manuellement avec une limite basse et soumettez chaque saut aux mêmes vérifications de schéma, de port et d’IP. Avec fetch, définissez redirect: "manual" et traitez vous-même l’en-tête Location.
Limiter ce qu’une requête réussie peut faire
- Définissez des délais de connexion et des délais globaux, et plafonnez la taille des réponses, pour que le composant de récupération ne puisse pas servir à maintenir des connexions ouvertes ou à télécharger de gros fichiers.
- Ne renvoyez pas à l’utilisateur les réponses brutes ni des messages d’erreur détaillés. Pour les aperçus de liens, ne renvoyez que le titre, la description et l’URL de l’image extraits.
- Retirez vos propres en-têtes d’authentification et vos cookies : le composant de récupération ne doit jamais envoyer d’identifiants internes à des hôtes fournis par l’utilisateur.
Passer par un proxy de sortie
La configuration la plus robuste sort la politique du code applicatif. Faites passer les requêtes sortantes déclenchées par les utilisateurs par un proxy de sortie dédié, ou exécutez-les depuis un worker isolé dans un segment réseau qui n’a tout simplement aucune route vers les services internes ni vers l’endpoint de métadonnées. Un bug dans la validation d’URL ne se transforme alors pas en compromission, car le réseau lui-même refuse. Des forward proxies open source peuvent appliquer des règles de destination à votre place, et un worker séparé isole aussi les réponses lentes ou hostiles de votre processus web principal.
Durcir le service de métadonnées
Sur AWS, exigez IMDSv2, qui nécessite un jeton de session obtenu par une requête PUT, et laissez la limite de sauts à 1 pour que les conteneurs ne puissent pas l’atteindre via l’hôte. GCP et Azure exigent un en-tête de requête spécifique pour les appels de métadonnées. Ces mesures relèvent considérablement la barre pour les SSRF simples basées sur GET, mais elles constituent une seconde couche, pas un substitut au blocage des adresses link-local. Accordez aussi au rôle de l’instance ou du service uniquement les permissions dont l’application a besoin, afin que des identifiants volés via le service de métadonnées ouvrent le moins de portes possible.
Tester vos défenses
- Essayez http://127.0.0.1/, http://[::1]/, http://2130706433/, http://127.1/ et http://169.254.169.254/latest/meta-data/ et vérifiez que chacune est rejetée.
- Faites pointer un nom d’hôte que vous contrôlez vers 127.0.0.1 et vérifiez que la vérification dans lookup le rejette. Donnez-lui ensuite deux enregistrements A, l’un public et l’autre privé, et vérifiez qu’il est toujours rejeté.
- Servez depuis un hôte public une redirection 302 vers une adresse privée et vérifiez qu’elle n’est pas suivie.
- Essayez des URL file:, ftp: et gopher:, ainsi que des URL contenant des identifiants ou des ports inhabituels.
En revue de code, recherchez chaque endroit où une URL de requête est construite à partir d’une entrée : fetch, axios, got, requests, http.Get, les appels page.goto des navigateurs headless et les bibliothèques de traitement d’images qui acceptent des URL. CodeAuditAgent trace ce flux de données dans les dépôts publics et les extraits collés, et signale les SSRF sous CWE-918 avec l’appel cité, un scénario d’exploitation et un correctif.