Si votre produit envoie des webhooks, vous avez une fonctionnalité que n’importe quel client peut programmer : il vous donne une URL, et vos serveurs y envoient une requête.
C’est exactement ce que cherche une attaque SSRF (Server-Side Request Forgery) : faire parler vos serveurs à des adresses que seuls eux peuvent joindre.
Ce qui intéresse l’attaquant :
- 169.254.169.254, le service de métadonnées de votre cloud (identifiants temporaires, configuration) ;
- vos services internes jamais exposés sur Internet : bases de données, moteurs de recherche, outils d’administration, files de messages ;
- et, souvent, la réponse elle-même : beaucoup de dashboards affichent le corps de la réponse dans l’historique des livraisons. L’attaquant n’a plus qu’à la lire.
La parade classique tient en une phrase : refuser les URL qui pointent vers une adresse privée. Sauf qu’il existe au moins quatre façons de la contourner.
Piège n°1 : ne vérifier qu’à l’enregistrement
L’URL est validée quand le client la saisit : hooks.client.com pointe vers une IP publique, tout va bien.
Le lendemain, il modifie l’enregistrement DNS de son domaine pour qu’il pointe vers 10.0.0.5. Vos workers envoient désormais chaque événement sur votre réseau interne, et personne ne revérifie.
La règle : vérifier à chaque envoi, pas seulement à l’enregistrement.
Piège n°2 : vérifier, puis se reconnecter (DNS rebinding)
Vous avez retenu la leçon : à chaque envoi, vous résolvez le nom, l’IP est publique, vous lancez la requête… et votre client HTTP résout le nom une deuxième fois pour se connecter.
Un serveur DNS malveillant, avec un TTL de 0, répond une IP publique à la première question et 127.0.0.1 à la seconde. Votre contrôle a vu la bonne adresse ; votre connexion part vers la mauvaise.
La règle : épingler la connexion sur l’IP que vous venez de vérifier. Avec curl, c’est CURLOPT_RESOLVE. Ailleurs, on se connecte directement à l’IP en gardant le bon Host et le bon SNI.
Vous avez retenu la leçon : à chaque envoi, vous résolvez le nom, l’IP est publique, vous lancez la requête… et votre client HTTP résout le nom une deuxième fois pour se connecter.
Un serveur DNS malveillant, avec un TTL de 0, répond une IP publique à la première question et 127.0.0.1 à la seconde. Votre contrôle a vu la bonne adresse ; votre connexion part vers la mauvaise.
La règle : épingler la connexion sur l’IP que vous venez de vérifier. Avec curl, c’est CURLOPT_RESOLVE. Ailleurs, on se connecte directement à l’IP en gardant le bon Host et le bon SNI.
En PHP / Laravel, l’idée tient en quelques lignes :
$ips = resolveAll($host); // A et AAAA, pas seulement IPv4
foreach ($ips as $ip) {
if (isInternal($ip)) {
return; // une seule IP interne suffit à refuser
}
}
// IPv6 entre crochets dans CURLOPT_RESOLVE
Http::withOptions(['curl' => [CURLOPT_RESOLVE => ["{$host}:443:" . implode(',', $ips)]]])
->withoutRedirecting()
->timeout(15)
->post($url, $payload);
Piège n°3 : une liste d’adresses « privées » incomplète
Bloquer 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16 et 127.0.0.0/8 ne suffit pas. Pensez aussi à :
- IPv6 : ::1, fc00::/7, fe80::/10. Et résolvez les enregistrements AAAA : une fonction qui ne lit que l’IPv4 laisse passer un nom dont seule l’adresse IPv6 est interne ;
- les IPv4 déguisées en IPv6 : ::ffff:10.0.0.1, ou 64:ff9b::a00:1, qu’une passerelle NAT64 traduit en… 10.0.0.1 ;
- l’espace partagé 100.64.0.0/10 (CGNAT), souvent utilisé en interne par les réseaux Kubernetes et les VPN ;
- 0.0.0.0, qui atteint la machine locale sous Linux ;
- les écritures exotiques : http://2130706433/ ou http://0x7f.1/ désignent 127.0.0.1. Comparez des adresses IP analysées, jamais des chaînes de caractères.
Un exemple concret : en PHP, filter_var() avec FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE laisse passer 100.64.0.1 et 64:ff9b::a00:1. Il faut ajouter ces plages à la main.
Piège n°4 : suivre les redirections
L’URL est publique et le contrôle passe. Puis le serveur répond 302 Location: http://169.254.169.254/…, et votre client HTTP suit poliment la redirection.
La règle : désactiver les redirections. Un 3xx est une livraison ratée, pas une invitation.
Bonus : ne servez pas la réponse sur un plateau
Afficher le corps de la réponse dans l’historique des livraisons transforme une SSRF « aveugle » en SSRF lisible. Tronquez la réponse, et n’affichez rien quand l’adresse a été bloquée.
La défense la plus solide reste le réseau
Les contrôles dans le code sont indispensables, mais le plus sûr est que vos workers de webhooks ne puissent tout simplement pas joindre votre réseau interne :
- faites sortir leurs requêtes par un proxy d’egress dédié (Smokescreen, de Stripe, est open source) ;
- interdisez-leur, au pare-feu, les plages privées et 169.254.169.254 ;
- sur AWS, rendez IMDSv2 obligatoire : le jeton s’obtient par un PUT avec un en-tête dédié, ce qu’un webhook envoyé en POST ne sait pas faire.
La checklist
- ✅ Valider l’URL à l’enregistrement et à chaque envoi
- ✅ Résoudre toutes les IP (A et AAAA) et refuser dès qu’une seule est interne
- ✅ Épingler la connexion sur les IP vérifiées
- ✅ Couvrir IPv6, IPv4-mapped, NAT64, CGNAT et 0.0.0.0
- ✅ Ne jamais suivre de redirection
- ✅ Des timeouts courts et une réponse tronquée
- ✅ Isoler les workers au niveau réseau : proxy d’egress, pare-feu, IMDSv2
- ✅ Journaliser chaque URL bloquée, et alerter
Chez Wajub, chaque webhook passe désormais par ces contrôles au moment de l’envoi, sur le dashboard comme sur l’API.
Si vous construisez une plateforme qui appelle des URL fournies par vos clients, faites l’exercice : laquelle de ces quatre portes est encore ouverte chez vous ?
