Retour aux articles
Laravel

Server-Side Request Forgery : exploitation et correction de la faille SSRF

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 clair
http://127.0.0.1:9200 -> Elasticsearch interne
http://10.0.0.9/admin -> un dashboard interne sans auth externe
http://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 Found
Location: 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.

  1. Segmentation réseau et proxy de sortie. Un pare-feu de sortie (egress) qui interdit à vos serveurs applicatifs de joindre 169.254.0.0/16 et les plages privées. Même si une SSRF passe l'application, elle se heurte au mur réseau.
  2. IMDSv2 sur AWS. La v2 du service de métadonnées exige un token de session récupéré par une requête PUT et limite le nombre de sauts réseau. La majorité des SSRF, qui ne savent émettre qu'un GET, se retrouvent neutralisées. Activez-le.
  3. WAF. Un pare-feu applicatif filtre les motifs d'URL malveillants connus.
  4. 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, .local ou .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.