Pourquoi les actions serveur de Next.js ont-elles besoin d’autorisation à l’intérieur du corps de chaque fonction ?
Un cas d’appropriation de compte détaillé montre comment les actions serveur Next.js non authentifiées exposent des opérations privilégiées, et où le contrôle d’autorisation doit être placé pour y mettre fin.
Sources: Unauthenticated Next.js Server Actions Leading to Zero Click Account Takeover
Les actions serveur de Next.js permettent à un composant React d’appeler directement une fonction serveur, sans passer par une route API intermédiaire. Cette commodité élimine l’endroit où de nombreuses équipes ont l’habitude d’intégrer des vérifications d’autorisation : le gestionnaire de route. Lorsque cette vérification n’est pas intégrée au corps de la fonction avec la logique, celle-ci peut être exécutée par n’importe qui capable de trouver son identifiant. Le cas présenté ci-dessous illustre précisément cet échec sur un portail d’abonnements réel, où deux actions non protégées sont enchaînées pour permettre une prise de contrôle du compte sans aucune action de l’utilisateur, puis il présente les règles de défense qui auraient pu y remédier.
Comment les actions serveur sont réellement déclenchées
Sous App Router, une action serveur est une fonction serveur appelée directement depuis un composant. Le framework attribue à chaque action un ID hexadécimal de 40 caractères, inclut cet ID dans le bundle client et transforme l’appel en une requête POST contenant un en-tête Next-Action. Le détail crucial est que les clés de routage proviennent de cet en-tête et non du chemin URL, et le corps de la requête n’est rien d’autre que les arguments de la fonction transmis par position.
Ce design présente une conséquence facile à manquer. Il n’existe pas de fichier de route à envelopper dans du middleware, donc il n’y a pas de point de contrôle naturel où un mécanisme de sécurité au niveau du framework s’exécuterait avant votre code. Si la vérification d’autorisation n’est pas écrite à l’intérieur de la fonction elle-même, aucune vérification n’a lieu. Quiconque extrait les identifiants des actions depuis le paquet distribué et reconstitue la structure des arguments à partir des appels minifiés peut reproduire la requête sans aucun cookie de session.
La première action : un e-mail entrant, un enregistrement privilégié sortant
Au sein du portail en question, une requête envoyée sans cookies, sans en-tête d’autorisation et sans aucun autre élément permettant d’identifier l’expéditeur a atteint la première action. Le corps de la requête ne contenait qu’une adresse e-mail et une valeur booléenne.
POST /particuliers/integrale-web-mensuel-2116 HTTP/2
Host: subdomain.target.com
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/140.0.0.0 Safari/537.36
Next-Action: 42a24b711d12f695d798b371e38b584c551ebb6a
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
["victim@gmail.com",true]
La réponse a renvoyé l’intégralité des données de l compte correspondant à cette adresse e-mail.
{
"user_id": "auth0|000000000000000000000001",
"email": "researcher@example.com",
"identities": [
{ "provider": "auth0", "connection": "www-target-com" }
],
"app_metadata": {
"subscriber_number": "900000000",
"custom_user_id": "00000000–0000–4000–8000–000000000000"
}
}
La structure de ce chargement est le signe révélateur. Des champs tels que identities et app_metadata ne sont pas fournis par l’API d’authentification publique d’un fournisseur d’identités ; ils proviennent de son API de gestion, l’interface administrative accessible à l’aide d’un jeton machine-à-machine doté de droits tels que la lecture et la mise à jour des utilisateurs. Le fait de les obtenir via une requête non authentifiée indique qu’un jeton privilégié se trouvait sur le serveur, et qu’une fonction anonyme l’utilisait discrètement. En plus d’être une anomalie en soi, la réponse a également transmis deux valeurs nécessaires à l’action suivante : un ID d’utilisateur et un nom de connexion.
La deuxième action : modifier le mot de passe d’un utilisateur
La deuxième action acceptait un objet et une chaîne de caractères. L’objet contenait un mot de passe ainsi qu’une connexion ; la chaîne représentait l’ID d’utilisateur. Envoi effectué vers un compte de test, toujours sans authentification, cela ressemblait à ceci.
POST / HTTP/2
Host: subdomain.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"password":"<REDACTED>","connection":"www-target-com"},"auth0|000000000000000000000001"]
Ce requête ne prouvait rien : pas de cookie de session, aucune connaissance du mot de passe existant, pas de jeton de réinitialisation ni de confirmation par e-mail, et aucune demande de vérification supplémentaire. Le serveur a répondu par succès en renvoyant l’objet utilisateur mis à jour. La faille décisive réside dans la source de l’ID utilisateur : il s’agit du deuxième argument fourni par l’appelant, plutôt que d’une valeur lue dans une session ou de la propriété sujet d’un jeton signé. La fonction modifie donc le compte auquel on lui en donne l’instruction.
Même action, champ différent, chemin de récupération détourné
Puisque le premier argument est transmis directement à la fonction de mise à jour des utilisateurs du fournisseur d’identité, c’est le champ que vous y insérez qui est modifié. En remplaçant le mot de passe par une adresse e-mail, c’est l’adresse e-mail du compte qui est mise à jour.
POST / HTTP/2
Host: subscribe.target.com
Next-Action: 89abcdef0123456789abcdef0123456789abcdef
Content-Type: text/plain;charset=UTF-8
Accept: text/x-component
[{"email":"attacker@example.com","connection":"www-target-com"},"auth0|000000000000000000000001"]
Le résultat a été le même succès, avec toujours l’absence totale de vérification d’appartenance. Cette variante est pire qu’il n’y paraît au premier abord : c’est l’adresse d’un compte qui sert à envoyer les réinitialisations de mot de passe, donc une fois qu’elle appartient à un attaquant, le processus de récupération du véritable propriétaire ne mène plus jusqu’à lui. Comme le mot de passe est défini directement plutôt qu’une session étant empruntée, l’accès obtenu ne expire pas de lui-même.
Les leçons tirées de ce cas
Les actions serveur sont des points d’entrée backend déguisés en fonctions, et elles méritent la même méfiance. Intégrez une vérification d’autorisation dans chaque action qui accède à des données ou à un état privilégiés, et confirmez l’identité de l’appelant avant d’exécuter toute autre action. Déterminez l’identité de l’utilisateur en fonction de la session ou d’un jeton vérifié, jamais à partir d’un argument contrôlé par le client, afin qu’une requête ne puisse pas indiquer l’ID d’une victime pour forcer le serveur à obéir. Gardez les jetons des fournisseurs privilégiés éloignés des chemins de code accessibles sans authentification. Traitez les IDs d’action comme étant publics, car ils sont envoyés au navigateur ; l’obscurité n’est pas un mécanisme de contrôle d’accès. Toute fonction que le client peut nommer est une fonction qu’un attaquant peut appeler, et la seule chose qui les sépare de vos données est la vérification que vous avez implémentée dans le corps de l’action.