C’est fini pour NextJS… 13 NOUVELLES vulnérabilités
BBetter Stack
Computing/SoftwareBusiness NewsInternet Technology
Transcript
00:00:00C'est encore arrivé. C'est ma troisième vidéo sur les CVE des Server Components cette année et je
00:00:05ne pense même pas les avoir toutes couvertes. Cette fois, il y en a 13, oui 13 CVE, sur React
00:00:11et Next.js, dont 6 sont de sévérité élevée, incluant des dénis de service, des contournements
00:00:15de middleware, du XSS et plus encore. Peut-être que les Server Components étaient une erreur.
00:00:20Voici donc la note de sécurité de Next.js, corrigeant juste quelques problèmes "ordinaires"
00:00:28survenus ce mois-ci, et tout en bas, la résolution est évidemment de mettre à jour
00:00:32toutes vos versions de Next.js. Voici les versions impactées. Il est à noter
00:00:36que TanStack n'est pas impacté, ce qui me donne une autre raison d'utiliser
00:00:41TanStack, même si je ne suis pas objectif. Je ne vais pas toutes les passer en revue car
00:00:44nous y passerions du temps et je n'ai pas trouvé d'exploits fonctionnels pour chacune,
00:00:48mais je veux vous en montrer une par catégorie, en commençant par le contournement
00:00:52de middleware et de proxy. Celle que j'ai recréée concerne le Pages Router.
00:00:56On a un contournement dans le Pages Router avec i18n, une CVE avec un score de
00:01:027.5 sur 10. Voici un exemple d'application vulnérable : dans la config Next.js, j'ai
00:01:06activé i18n avec deux locales, anglais et français. J'ai aussi un fichier middleware,
00:01:12renommé "proxy" dans les versions récentes pour éviter la confusion que je vais
00:01:16vous montrer, mais en gros, le middleware doit nous permettre de modifier
00:01:19une requête entrante, que ce soit par redirection, réécriture ou ajout de headers.
00:01:24Ici, je l'utilise pour la page "/secret" : il vérifie s'il y a un cookie de session,
00:01:28donc si l'utilisateur est connecté. Sinon, il doit le rediriger vers la page de login
00:01:32pour que seuls les utilisateurs authentifiés voient ma page secrète.
00:01:37En bas, on a un "matcher" pour que le middleware appliqué à cette page secrète
00:01:41corresponde aussi aux variantes de langues car techniquement, avec deux locales,
00:01:45on a trois versions de cette URL. Dans la page secrète elle-même,
00:01:50j'ai des "server-side props" récupérées sur le serveur au moment du rendu.
00:01:54Théoriquement, grâce au middleware, seul un utilisateur connecté devrait voir
00:01:58ces valeurs utilisées sur la page : un email, un flag et un titre.
00:02:03Seul un utilisateur autorisé doit y accéder. Mettons cela à l'épreuve.
00:02:07D'abord, j'essaie d'accéder à la page secrète : je suis redirigé vers le login,
00:02:11donc le middleware fonctionne. Mais si on devenait des hackers experts ?
00:02:16On peut faire ça en inspectant l'élément, truc de hacker de fou,
00:02:20puis dans le script NEXT_DATA ici, on cherche notre ID de build,
00:02:24celui-ci dans mon cas, et on le copie.
00:02:28Ensuite, on tape une URL : _next/data/ l'ID de build
00:02:32puis la page ciblée .json. Une fois fait,
00:02:37on récupère les props qui auraient dû être protégées par le middleware,
00:02:40ici le flag, l'email, le titre et un message vous invitant à vous abonner
00:02:44pour plus d'actus dév, tutoriels et astuces. Faites-le donc, j'espère
00:02:48vous avoir impressionnés par mes talents de hacker. Pourquoi cela arrive-t-il ?
00:02:52C'est aussi simple à expliquer qu'à faire. Avec notre page secrète et ses props,
00:02:56les props server-side sont servies via une URL comme celle-ci, et le middleware
00:03:00devait protéger cette route. Le souci est qu'avec i18n activé, on avait
00:03:05deux autres URLs : les variantes anglaise et française. Les server-side props
00:03:09ont aussi ces variantes, et Next.js avait du code défectueux qui faisait que
00:03:13si i18n était activé, le cas de base n'était pas protégé par le matcher,
00:03:18contrairement aux versions anglaise et française. Le chemin "/secret" pur
00:03:22était exposé. On le voit vite si je change l'URL pour la version anglaise :
00:03:26je suis bien redirigé vers le login. Vulnérabilité incroyablement simple,
00:03:31mais pour être honnête, ces contournements de middleware semblent souvent pires
00:03:35qu'ils ne le sont. Ce n'est pas idéal, mais on ne devrait rien protéger uniquement
00:03:40avec du middleware de toute façon, et Next.js ne le recommande pas.
00:03:44Si vous aviez des données sensibles dans ces props sans aucune logique d'auth
00:03:48côté serveur, le tort est un peu partagé. Passons à plus grave : le déni
00:03:53de service. Il y en avait trois, mais je n'en ai recréé qu'un seul de façon fiable,
00:03:56celui lié aux Server Components. Cela impacte Next.js ainsi que tout ce qui
00:04:01utilise le package react-server-dom, soit Next.js et ses dérivés comme Vinxi.
00:04:05TanStack Start ne l'utilise pas, donc n'est pas vulnérable. C'est aussi
00:04:10une sévérité de 7.5 sur 10. Il suffit d'une application Next.js simple
00:04:14utilisant une Server Action, même très basique. Voici le site qui tourne.
00:04:18Quand je rafraîchis, c'est presque instantané.
00:04:22Pour avoir des chiffres, si j'envoie cette requête,
00:04:25elle se résout en 0,02 seconde. Mais si je lance mon exploit
00:04:29puis renvoie la requête, elle prend maintenant six secondes.
00:04:34Et ce n'est qu'avec un seul exploit, imaginez si je les enchaîne.
00:04:39Pour comprendre l'exploit, il faut connaître le protocole React Flight,
00:04:42le format utilisé pour sérialiser les composants et données entre serveur
00:04:46et client. Vous l'avez sûrement déjà vu : sur cette page, on a un formulaire
00:04:50avec une Server Action. Dans l'onglet réseau, quand je clique sur envoyer,
00:04:54le payload envoyé ressemble à du charabia,
00:04:58tout comme la réponse ici. En copiant ce payload,
00:05:02je peux expliquer ce qu'il se passe côté serveur.
00:05:05D'abord la désérialisation, qui commence sur le chunk 0 avec ce $k1.
00:05:10Ce $k1 est un pointeur indiquant des données de formulaire commençant
00:05:16par un underscore. Il va parcourir toutes les clés envoyées dans le payload
00:05:20en cherchant une chaîne qui commence par un seul underscore pour identifier
00:05:24la clé et sa valeur. Une fois fait, il reconnaît nom, email, message,
00:05:28et transforme ces données en l'objet que vous voyez ici. Simple.
00:05:32Le problème arrive quand on passe à l'échelle. Disons que j'ajoute
00:05:36un autre pointeur $k2 cherchant les clés avec deux underscores.
00:05:41Désormais, pour $k1, il parcourt les six clés pour trouver celles
00:05:44avec un underscore, et pour $k2, il fait la même chose
00:05:48en cherchant les deux underscores. On est à 12 comparaisons au total.
00:05:52Rien de grave, mais poussons à l'extrême. Si on ajoute 199 999 clés
00:05:56aléatoires au payload et qu'on modifie notre tableau pour aller de
00:06:03$k1 à $k1000, il devra chercher de un à mille underscores
00:06:07parmi nos 200 000 clés du payload.
00:06:12Cela représente 200 millions de comparaisons de chaînes.
00:06:17Comme vous l'imaginez, cela va bloquer le thread pendant plusieurs secondes.
00:06:21Voici le commit qui a corrigé le problème selon moi.
00:06:25C'est assez complexe, mais je vais essayer d'expliquer au mieux.
00:06:28En gros, ils utilisent maintenant un système de curseur pour les clés :
00:06:33ils chargent les 200 000 clés du payload dans une liste, et on commence
00:06:36par chercher la référence $k1 en descendant la liste avec un curseur
00:06:41qui ne peut pas revenir en arrière. Il vérifie $j1, ça ne correspond pas
00:06:45à l'underscore voulu, alors il passe à $j2, et ainsi de suite
00:06:50jusqu'au bout de la liste, $j199 999.
00:06:54Arrivé là, s'il n'y a pas de match pour $k1, il passe à $k2.
00:07:01Mais comme le curseur ne peut pas reculer, il atteint immédiatement
00:07:06la fin de la liste. Ce sera donc indéfini, et ainsi de suite jusqu'à $k1000.
00:07:09Au final, on n'a parcouru que les 200 000 clés une seule fois.
00:07:14Le correctif a réduit le nombre d'opérations de k*n (k références
00:07:18pour n clés) à n+k. On est passés de 200 millions à 201 000 opérations,
00:07:23car il faut quand même parcourir les clés et les références $k.
00:07:27Ce tweet de Prime résume bien la situation : créer son propre protocole
00:07:33avec sérialisation est extrêmement difficile, pas étonnant qu'il y ait des failles.
00:07:37À mon avis, ils devraient demander à Claude Mythos de relire le code
00:07:41de React et Next.js. Ensuite, on a la CVE la plus sévère du lot :
00:07:46du SSRF (Server-Side Request Forgery) dans les applications Next.js.
00:07:50Celle-ci est notée 8.6 sur 10, mais ne concerne pas les déploiements
00:07:54hébergés sur Vercel, seulement l'auto-hébergement ou d'autres fournisseurs.
00:07:59L'exploit est très simple. On lance notre serveur Next.js,
00:08:04même une application par défaut sans aucune modification.
00:08:09Imaginons aussi un serveur interne, accessible uniquement par le serveur
00:08:14Next.js et pas par le monde extérieur, sur notre cloud par exemple.
00:08:18On envoie alors une simple requête curl à notre application Next.js
00:08:23sur le port 3002, en indiquant que notre cible de requête
00:08:26est l'URL localhost du serveur interne auquel on veut accéder.
00:08:31En validant, regardez ce qui revient : c'est la page HTML d'un serveur
00:08:36Python avec la liste des fichiers, et sur le serveur Python lui-même,
00:08:40on voit une requête entrante provenant de l'application Next.js.
00:08:45Pour mieux visualiser : dans notre périmètre de déploiement, on a notre
00:08:49serveur Next.js et des services internes (Redis, base de données, etc.).
00:08:53Ces services ne doivent pas être publics : une requête curl externe
00:08:57échouerait car ils sont derrière un pare-feu, accessibles seulement par Next.js.
00:09:02Ce qu'on a fait, c'est envoyer une requête à Next.js en lui disant :
00:09:06"Hé, peux-tu envoyer une requête pour moi à ce service interne ?"
00:09:10On a ainsi contourné le pare-feu en passant par Next.js.
00:09:15Nous avons obtenu l'information en contournant le pare-feu via Next.js
00:09:19qui, lui, a accès au service interne. La cause racine est assez simple :
00:09:23dans la requête curl, on envoie un header "upgrade websocket".
00:09:28Avec ces headers, Next.js atteint ce morceau de code qui résout les routes
00:09:32sur notre URL, mais l'URL analysée ici est en fait la cible de la requête
00:09:37envoyée dans notre curl, donc le serveur interne, et non l'application
00:09:40Next.js. Cette URL passe ensuite un test pour vérifier si elle a un protocole.
00:09:45Comme on utilise HTTP, c'est oui, et Next.js proxie donc la requête.
00:09:49Le correctif ajoute deux gardes à la fonction resolveRoutes : un booléen
00:09:55"finished" et un code de statut. resolveRoutes traite l'URL pour voir
00:09:58si c'est une requête proxy légitime basée sur les rewrites ou middlewares.
00:10:02Sinon, "finished" passe à faux. En bas, on vérifie : si c'est vrai,
00:10:06on continue, sinon le proxy ne s'exécute pas. Pour notre curl,
00:10:11"finished" sera bien à faux. Si jamais c'était à vrai, le second check
00:10:15est le code de statut. Si c'est une requête HTTP (200, 404...), ce n'est pas
00:10:20une requête proxy WebSocket valide, donc elle est ignorée. J'ai vraiment
00:10:24essayé de creuser ces sujets. Dites-moi si vous êtes encore là en
00:10:27mettant un commentaire au hasard, genre "bar" ou autre, et abonnez-vous.
00:10:32Il reste deux catégories plus rapides. D'abord l'empoisonnement de cache,
00:10:36avec ce problème modéré dans les React Server Components, noté 5.4 sur 10.
00:10:41Pour le recréer, j'ai une application Next.js et un faux CDN pour simuler
00:10:45un environnement réel. En visitant le site via le CDN et en naviguant
00:10:49dans les produits, la première fois est un "cache miss", puis un "cache hit".
00:10:53On le voit dans les logs : d'abord un miss sur /products avec query string,
00:10:58puis des hits. Ensuite, j'ai vidé le cache pour simuler une expiration,
00:11:01puis j'envoie cette requête curl. Sur l'application, si je clique
00:11:04sur voir les produits, je reçois plein de charabia : l'attaque a réussi.
00:11:09Il semble que si on envoie un curl avec le header react-server-components,
00:11:14l'URL renvoie des données de composant au lieu de l'HTML. Next.js vérifie
00:11:18alors s'il doit stocker cela comme du Server Component ou de l'HTML.
00:11:23Théoriquement, un utilisateur demandant la page HTML ne devrait jamais
00:11:27recevoir de données RSC. Mais avec notre query string à la fin de la requête
00:11:31curl, le test (qui vérifie si l'URL finit par .rsc) échouait.
00:11:35Next.js pensait donc que c'était de l'HTML et le stockait comme tel.
00:11:39Résultat : l'utilisateur suivant recevait les données RSC brutes.
00:11:44Le correctif est très simple : ils ignorent désormais les query strings
00:11:47lors du check de l'extension .rsc. Enfin, la dernière catégorie :
00:11:52le Cross-Site Scripting (XSS). En voici une à 6.1 sur 10 dans les scripts
00:11:58"before interactive" avec une entrée non fiable. En gros, dans Next.js,
00:12:02si un tag script a la stratégie "beforeInteractive" et un attribut
00:12:07provenant d'un contenu externe (comme des search params), on peut faire du XSS.
00:12:11En faisant cliquer l'utilisateur sur un lien piégé avec du contenu injecté,
00:12:16il pourrait voir ceci : un faux formulaire de connexion demandant de se
00:12:20reconnecter, alors que c'est une injection via le paramètre de recherche.
00:12:24L'attaquant peut exécuter du JavaScript sur la machine de la victime,
00:12:29par exemple pour voler des cookies de session et accéder à vos comptes.
00:12:33C'est un exemple classique d'échappement incorrect. On le voit mieux
00:12:37sur cette version simplifiée : je ferme la balise script pour en ouvrir
00:12:41une nouvelle avec mon propre code. L'alerte "pwned" s'affiche.
00:12:46Cela fonctionne avec un tag script Next.js en "beforeInteractive" recevant
00:12:50des données de query parameters. Next.js transforme ce tag en utilisant
00:12:55"dangerouslySetInnerHTML" — le nom est déjà un indice — et JSON.stringify.
00:12:59Il faut savoir que JSON.stringify n'échappe pas les caractères HTML
00:13:03comme les chevrons fermants. Donc nos données injectées se retrouvent
00:13:08directement dans la page, permettant de fermer prématurément le script
00:13:12légitime pour lancer le nôtre. Une petite astuce à la fin permet
00:13:16d'effacer les traces visuelles pour que l'utilisateur ne se doute de rien.
00:13:19Voilà pour ces 13 CVE de la semaine sur Next.js et React.
00:13:22Honnêtement, je ne sais plus quoi en penser. Ça m'attriste car il y a
00:13:26deux ans, je ne jurais que par Next.js, mais les obstacles s'accumulent.
00:13:31On dirait qu'ils ont précipité certaines choses avant de devoir les corriger.
00:13:34Désormais, je suis passé à TanStack et Astro pour les sites de contenu.
00:13:39C'est plus simple. J'aime aussi beaucoup ce que fait Cloudflare
00:13:43récemment, j'y migre mes projets petit à petit. Il m'en reste 20
00:13:47sur Vercel à mettre à jour. Et vous, croyez-vous aux Server Components
00:13:51ou est-ce un échec ? Dites-le-moi en commentaire, abonnez-vous,
00:13:55et à la prochaine.