Server-Side Request Forgery : exploitation et correction de la faille SSRF
Le contexte
Depuis quelques semaines, je développe un addon pour Shopper, une solution e-commerce headless bâtie sur Laravel. Le but de l'addon : migrer une boutique existante vers Shopper depuis n'importe quelle plateforme e-commerce (Shopify, PrestaShop, Magento, WooCommerce, etc). Je l'ai conçu multi-source dès le départ, avec une couche générique de connecteurs où chaque plateforme fournit sa propre implémentation d'une même interface. En clair : un contrat, plusieurs drivers.
Le premier connecteur que j'ai livré, c'est WooCommerce. Sur le papier c'est trivial : le marchand renseigne l'URL de sa boutique, une clé et un secret d'API, et le connecteur interroge l'API REST de WooCommerce pour récupérer produits, catégories, variantes, images et clients, avant de les recréer côté Shopper.
Le client HTTP au cœur de ce driver ressemblait à ça :
final class WooCommerceClient{ public function __construct( private string $storeUrl, private string $consumerKey, private string $consumerSecret, ) {} public function testConnection(): Response { return Http::withBasicAuth($this->consumerKey, $this->consumerSecret) ->get("{$this->storeUrl}/wp-json/wc/v3/products", ['per_page' => 1]); }}
Côté UI, un composant Livewire qui balançait l'URL saisie par le marchand directement dans le constructeur du client :
public function testConnection(): void{ $client = new WooCommerceClient( storeUrl: $this->storeUrl, // saisi par l'utilisateur consumerKey: $this->consumerKey, consumerSecret: $this->consumerSecret, ); $client->testConnection();}
Rien de compliquer jusqu'ici. Ça tournait, les tests passaient au vert, j'étais content de moi.
Avant de publier une version, j'ai fait tourner une revue d'architecture et de sécurité sur tout l'addon par Claude Code. Dans les retours, une ligne a attiré mon attention :
SSRF : le serveur effectue une requête HTTP vers une URL entièrement contrôlée par l'utilisateur. Un attaquant peut viser des services internes.
Ma première réaction : « SSRF ? Tu veux dire CSRF, non ? »
Eh bien non. Et c'est tout l'objet de cet article.
CSRF vs SSRF : quelle différence ?
Les deux acronymes se ressemblent à une lettre près, mais ils attaquent des choses opposées.
| CSRF (Cross-Site Request Forgery) | SSRF (Server-Side Request Forgery) | |
|---|---|---|
| Qui émet la requête forgée ? | Le navigateur de la victime | Le serveur de l'application |
| Ce qui est abusé | La session et les cookies de la victime | La position réseau du serveur |
| Cible typique | Une action authentifiée (« change mon email ») | Un service interne injoignable depuis Internet |
| Parade emblématique | Le token CSRF (@csrf sous Laravel) |
La validation stricte de l'URL côté serveur |
Laravel me couvre contre la CSRF depuis toujours sans que j'aie à y penser : le middleware VerifyCsrfToken, la directive @csrf, le token vérifié à chaque POST. Intégré, transparent, on finit par oublier qu'il tourne.
La SSRF, aucun middleware ne la gère par défaut, et c'est logique : le framework ne peut pas deviner que cette requête sortante-là est hostile. C'est votre code métier qui décide d'aller chercher une URL fournie par l'utilisateur. Le garde-fou, c'est à vous de l'écrire.
C'est quoi une SSRF, concrètement
Partons de la définition posée par F5 dans son glossaire de la SSRF :
« Une faille de sécurité qui survient lorsqu'un attaquant manipule une application web ou une API pour qu'elle effectue des requêtes vers des ressources internes. »
Le mot qui compte : server-side. Normalement, quand un utilisateur veut atteindre une ressource, c'est son navigateur qui émet la requête, et son navigateur est bloqué dehors, devant votre pare-feu. Avec une SSRF, la requête part de votre serveur. Et votre serveur, lui, tourne à l'intérieur du réseau, avec un accès direct aux services internes.
Regardez l'infrastructure typique d'une application en production :
Internet --[ pare-feu ]--> App Laravel --+--> 169.254.169.254 (metadonnees cloud) +--> 10.0.0.5:6379 (Redis interne) +--> 10.0.0.8:5432 (PostgreSQL) +--> 10.0.0.9/admin (dashboards internes)
Le pare-feu bloque le trafic d'Internet vers le réseau interne. Mais votre application, elle, communique avec l'interne en permanence, c'est son rôle. Alors si un attaquant contrôle l'URL qu'elle va appeler, il se sert de votre serveur comme d'un proxy pour franchir ce pare-feu. F5 le formule bien :
« La requête est faite du point de vue du serveur, pas du navigateur de l'utilisateur, ce qui permet de contourner les protections offertes par les pare-feux et les VPN. »
En sécurité, on appelle ça le schéma du confused deputy, l'adjoint dupé : un composant de confiance, ici votre serveur, exécute une action pour le compte de quelqu'un qui n'y avait aucun droit.
Comment mon addon pouvait être exploité
Reprenons le champ « URL de la boutique ». Zéro validation. La valeur partait telle quelle dans une requête HTTP sortante émise par le serveur. Voilà ce qu'un attaquant pouvait en tirer.
Voler les identifiants cloud
Le scénario qui fait mal. Sur AWS, GCP ou Azure, chaque instance expose un service de métadonnées sur une IP fixe non routable depuis l'extérieur : 169.254.169.254. Sur AWS, cet endpoint sert les credentials IAM temporaires de l'instance :
http://169.254.169.254/latest/meta-data/iam/security-credentials/
Il suffisait qu'un attaquant colle ça dans mon champ « URL de la boutique » :
http://169.254.169.254/latest/meta-data/iam/security-credentials/
Mon serveur allait interroger cet endpoint depuis sa position privilégiée et me renvoyait potentiellement les clés d'accès AWS de l'instance. Avec ces clés, l'attaquant enchaîne : buckets S3, bases de données, tout ce que le rôle IAM autorise.
Rien de théorique. C'est le mécanisme exact du breach Capital One de 2019 : une SSRF a lu les credentials IMDS, qui ont ouvert l'accès à plus de 100 millions de dossiers clients. Le tout à cause d'une requête sortante non validée.
Scanner et atteindre les services internes
Même sans cloud, l'attaquant vise la machine elle-même ou le réseau local :
http://localhost:6379 -> parle a Redis en clairhttp://127.0.0.1:9200 -> Elasticsearch internehttp://10.0.0.9/admin -> un dashboard interne sans auth externehttp://192.168.1.1 -> l'interface du routeur
Chaque saisie devient une requête que mon serveur exécute de l'intérieur, sous le pare-feu censé isoler ces services.
Le piège du « je ne renvoie pas le corps de la réponse » : la SSRF aveugle
Le réflexe rassurant : « De toute façon je n'affiche pas le corps de la réponse, juste connexion réussie ou échec. » Je l'ai eu. Fausse sécurité.
F5 liste trois variantes, et deux se passent complètement de la réponse :
- SSRF standard. La réponse revient à l'attaquant. Le pire des cas.
- SSRF aveugle. Réponse jamais affichée, mais un message d'erreur qui change fuite l'information. « Échec » sur un port fermé contre « réponse inattendue » sur un port ouvert, et l'attaquant cartographie le réseau interne, service par service.
- SSRF aveugle temporelle. Même sans message exploitable, un hôte qui répond en 5 ms contre un timeout à 30 s trahit ce qui existe. Il lit le réseau au chronomètre.
Mon bouton « Tester la connexion », qui renvoyait juste réussi, échoué ou injoignable, suffisait déjà pour monter une SSRF aveugle. Le « ça ne renvoie rien de sensible » ne tenait plus.
Comment corriger : une validation en couches
Bonne nouvelle côté implémentation : tout passait par un seul point de passage. Toutes mes requêtes sortantes étaient construites dans la même méthode du client. Un seul endroit où brancher la validation. Voilà comment je l'ai montée, couche par couche.
Couche 1 : n'autoriser que HTTPS
Un attaquant ne se limite pas forcément à http://. Selon le client HTTP, des schémas comme file://, gopher://, dict:// ou ftp:// ouvrent d'autres abus : lecture de fichiers locaux, requêtes forgées vers d'autres protocoles. On ferme par liste blanche, ici HTTPS uniquement.
if (! str_starts_with(strtolower($url), 'https://')) { throw new MigrationException('L’URL de la boutique doit utiliser HTTPS.');}
Couche 2 : rejeter les IP privées et réservées
Le cœur de la défense contre les attaques directes. PHP a exactement l'outil qu'il faut, filter_var avec deux flags :
$host = strtolower(parse_url($url, PHP_URL_HOST) ?? ''); if (filter_var($host, FILTER_VALIDATE_IP)) { $isPublic = filter_var( $host, FILTER_VALIDATE_IP, FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE ); if (! $isPublic) { throw new MigrationException("Hôte non autorisé : {$host}."); }}
FILTER_FLAG_NO_PRIV_RANGE dégage les plages privées : 10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16. FILTER_FLAG_NO_RES_RANGE dégage les réservées, ce qui inclut le loopback 127.0.0.0/8 et, surtout, 169.254.0.0/16.
Ce second flag est la pièce maîtresse : 169.254.169.254 tombe dans le réservé, donc l'endpoint de métadonnées cloud est bloqué d'office. L'attaque la plus grave tombe avec une seule ligne.
Couche 3 : bloquer les noms d'hôtes internes
Une IP littérale n'est pas la seule voie. localhost et les suffixes DNS internes classiques doivent aussi être refusés :
if ($host === 'localhost' || str_ends_with($host, '.localhost') || str_ends_with($host, '.local') || str_ends_with($host, '.internal')) { // ex. metadata.google.internal throw new MigrationException("Hôte non autorisé : {$host}.");}
Couche 4 : ne pas suivre les redirections les yeux fermés
Le piège qui m'a le plus marqué, parce qu'il court-circuite tout ce qui précède. Un attaquant fournit une URL parfaitement propre :
https://ma-boutique-piege.com <- passe toute la validation
Sauf que le serveur derrière cette URL répond ceci :
HTTP/1.1 302 FoundLocation: http://169.254.169.254/latest/meta-data/...
Par défaut, la plupart des clients HTTP, dont Guzzle sous le capot de la façade Http de Laravel, suivent les redirections. Votre contrôle a validé l'URL de départ, puis Guzzle a suivi le 302 vers l'interne. Toute la validation initiale tombe à l'eau.
La parade : re-valider chaque saut de redirection et interdire le downgrade vers http.
Http::baseUrl($this->storeUrl) ->withOptions([ 'allow_redirects' => [ 'max' => 5, 'strict' => true, 'protocols' => ['https'], // aucun downgrade vers http 'on_redirect' => function ($request, $response, $uri): void { // On rejoue exactement la même validation sur l'URL de destination. $this->assertSafeUrl((string) $uri); }, ], ]) ->get('/wp-json/wc/v3/products');
En sortant la validation dans une méthode assertSafeUrl(), je la rejoue au constructeur et sur chaque saut, via le callback on_redirect de Guzzle. La boutique ne peut plus faire rebondir le serveur vers une cible interne.
Le résidu, dit franchement : DNS rebinding et épinglage d'IP
Soyons clairs, sinon ce serait malhonnête. Les couches au-dessus arrêtent toutes les attaques directes : IP interne en dur, localhost, redirection piégée. C'est de très loin le cas le plus courant, celui de l'exemple F5.
Reste un vecteur plus vicieux : le DNS rebinding. L'attaquant possède un domaine piege.com qui, au moment où vous le validez, résout vers une IP publique. Il passe donc la validation. Mais entre votre vérification et la connexion réelle, il bascule son enregistrement DNS vers 169.254.169.254. Vous avez validé un nom, mais le client se connecte à une IP résolue après coup. C'est un TOCTOU, Time-Of-Check to Time-Of-Use.
La seule parade complète : résoudre le DNS vous-même au moment de la connexion, valider l'IP obtenue, puis l'épingler pour que la connexion aille exactement là, via l'option CURLOPT_RESOLVE de curl. Plus lourd, et pertinent uniquement si la fonctionnalité est exposée à des utilisateurs peu fiables.
Chez moi, la migration est verrouillée derrière l'authentification et une permission dédiée (migrator.create) : pour l'atteindre, il faut déjà être un administrateur connecté. Le rapport coût sur bénéfice ne justifiait pas l'épinglage d'IP. Je l'ai donc documenté dans le code, avec le chemin de mise à niveau, pour le jour où le modèle de menace changera. C'est ça, à mon sens, la sécurité proportionnée : fermer ce qui est réellement atteignable, et documenter le reste au lieu de faire comme s'il n'existait pas.
Ne fermez pas que l'app : la défense en profondeur
La validation applicative est votre première ligne, pas la seule. F5 insiste sur des couches complémentaires, valables quelle que soit la stack.
- Segmentation réseau et proxy de sortie. Un pare-feu de sortie (egress) qui interdit à vos serveurs applicatifs de joindre
169.254.0.0/16et les plages privées. Même si une SSRF passe l'application, elle se heurte au mur réseau. - IMDSv2 sur AWS. La v2 du service de métadonnées exige un token de session récupéré par une requête
PUTet limite le nombre de sauts réseau. La majorité des SSRF, qui ne savent émettre qu'unGET, se retrouvent neutralisées. Activez-le. - WAF. Un pare-feu applicatif filtre les motifs d'URL malveillants connus.
- Moindre privilège. Si le rôle IAM de l'instance n'a accès à rien de sensible, voler ses identifiants ne rapporte pas grand-chose.
Une SSRF qui doit franchir l'application, puis le réseau, pour finalement tomber sur un rôle sans le moindre privilège, ne vaut plus rien. C'est tout l'intérêt d'empiler les couches au lieu de tout miser sur une seule.
La vraie leçon : est-ce que mon serveur va chercher une URL fournie par l'utilisateur ?
S'il faut retenir une question de cette revue, c'est celle-là. La SSRF ne touche pas que les connecteurs de migration. Dès que votre serveur émet une requête réseau vers une adresse, un hôte ou une URL qui vient, même indirectement, d'un utilisateur, vous avez une SSRF potentielle sur les bras.
Passez vos projets au crible avec cette grille. Les fonctionnalités concernées sont bien plus nombreuses qu'on ne croit :
- Les webhooks que l'utilisateur configure (« appelle cette URL quand tel événement se produit »).
- L'import d'image ou de fichier par URL (« colle le lien de ton avatar »).
- La prévisualisation de lien, comme Slack qui déplie une URL collée dans un message.
- Les générateurs de PDF ou de captures d'écran via navigateur headless. Ceux-là sont particulièrement dangereux : un navigateur suit les redirections et exécute du JavaScript.
- Les URL de métadonnées SSO ou SAML à récupérer.
- Les lecteurs RSS, agrégateurs et oEmbed.
- Tout proxy ou endpoint du type « va me chercher cette ressource ».
Pour chacune, la même check-list :
- Restreindre le schéma par liste blanche, HTTPS et éventuellement HTTP.
- Rejeter les IP privées et réservées avec
FILTER_FLAG_NO_PRIV_RANGE | FILTER_FLAG_NO_RES_RANGE. - Rejeter les noms d'hôtes internes comme
localhost,.localou.internal. - Re-valider les redirections et interdire le downgrade vers HTTP.
- Si la surface touche des utilisateurs non fiables, résoudre puis épingler l'IP au moment de la connexion.
- Poser une défense en profondeur réseau : proxy de sortie, IMDSv2, moindre privilège.
Le mieux reste de sortir cette validation dans un composant réutilisable, une SafeUrlValidator ou une règle de validation Laravel dédiée, et de la brancher partout où une URL utilisateur devient une requête sortante. Écrit une fois, réutilisé partout.
Épilogue
Je code depuis des années. La CSRF, l'injection SQL, le XSS, je gérais. La SSRF, elle, était un angle mort : un de ces risques qu'on ne connaît pas parce qu'on ne les a jamais croisés, et qu'on ne croise jamais parce qu'on ne les cherche pas.
Ce qui m'a le plus marqué, ce n'est pas la faille, c'est la manière dont je l'ai trouvée. Une revue de code a pointé une ligne que je relisais depuis des semaines sans rien y voir d'anormal. Pas parce qu'un outil serait « plus intelligent » que moi, mais parce qu'il n'avait pas mon angle mort. Il a mis un nom sur un risque que je ne savais pas nommer, et ce nom m'a permis d'aller lire, comprendre, puis corriger en connaissance de cause, au lieu de coller un correctif magique sans le comprendre.
C'est peut-être là que se joue la vraie valeur de ces outils : pas remplacer votre jugement, mais éclairer ce que votre expérience ne vous a pas encore appris à voir.
Si cet article vous a fait découvrir la SSRF comme cette revue me l'a fait découvrir, faites une chose. Ouvrez votre projet, cherchez le prochain endroit où votre serveur va chercher une URL qu'un utilisateur lui a donnée, et posez-vous la question.
Pour aller plus loin : F5, Server-Side Request Forgery, OWASP, Server-Side Request Forgery, PHP, les drapeaux de filter_var.