jobsphp

Failles API REST : comment Julie a sécurisé son code PHP

Ingénierie Web. Failles API REST : comment Julie a sécurisé son code PHP

Une API REST peut être correctement authentifiée et rester vulnérable. Le jeton est signé, le mot de passe n’est jamais transmis dans les appels métier, les routes privées répondent bien avec un code…

Failles API REST: comment Julie a sécurisé son code PHP

Une API REST peut être correctement authentifiée et rester vulnérable. Le jeton est signé, le mot de passe n’est jamais transmis dans les appels métier, les routes privées répondent bien avec un code 401 quand le jeton manque — et pourtant un attaquant peut parfois fabriquer un jeton accepté par le serveur.

Avec JWT, les erreurs les plus dangereuses ne viennent pas toujours d’un défaut cryptographique. Elles viennent souvent d’une mauvaise décision d’implémentation: laisser le jeton choisir l’algorithme utilisé pour le vérifier, mélanger clé publique et secret HMAC, oublier de contrôler l’audience ou conserver un jeton d’accès pendant plusieurs heures.

Le cas de Julie est représentatif d’un problème fréquent sur les API PHP: le code d’authentification fonctionne dans le parcours nominal, mais ses hypothèses de sécurité n’ont jamais été écrites noir sur blanc. Pour sécuriser une API REST PHP, il faut donc regarder au-delà de la simple présence d’un JWT. La bibliothèque, l’algorithme, les clés, les claims et le cycle de vie du jeton forment un seul système.

Le piège de la faille alg: none et la manipulation des en-têtes

Un JWT est composé de trois segments séparés par des points: l’en-tête, le corps — ou payload — et la signature. Les deux premiers segments sont encodés en Base64URL. Cette représentation donne parfois une fausse impression de protection: le contenu est difficile à lire au premier regard, mais il n’est pas chiffré.

L’en-tête contient notamment le champ alg, qui indique l’algorithme employé pour la signature. On y trouve par exemple HS256, RS256 ou ES256. Le corps contient les claims: l’identité du sujet, l’émetteur, l’audience, les dates de validité et, éventuellement, des informations d’autorisation.

Le point important est le suivant: l’en-tête fait partie des données fournies par le client. Il ne peut donc pas être considéré comme une instruction de confiance.

Pourquoi none est dangereux

La spécification JWT prévoit un algorithme nommé none, qui correspond à un jeton sans signature. Cette possibilité peut avoir une utilité dans certains contextes très contrôlés, mais elle n’a aucune place dans une API publique qui s’appuie sur JWT pour authentifier ses utilisateurs.

Une implémentation vulnérable peut suivre une logique comparable à celle-ci:

1. lire l’en-tête du jeton;

2. récupérer la valeur de alg;

3. choisir la méthode de vérification à partir de cette valeur;

4. accepter le payload si l’algorithme indique qu’aucune signature n’est nécessaire.

Dans ce scénario, l’attaquant n’a pas besoin de casser une clé. Il modifie l’en-tête, retire la signature et conserve — ou remplace — les claims qui l’intéressent. Si le serveur accepte ensuite le jeton, la falsification est complète.

La faille ne tient pas au fait qu’un JWT soit décodable. Elle tient au fait que le serveur fasse confiance à une donnée non authentifiée pour décider de la manière dont cette même donnée doit être authentifiée.

Un jeton peut annoncer son algorithme, mais il ne doit jamais pouvoir l’imposer. La politique de vérification appartient au serveur.

La correction avec firebase/php-jwt 6.x

Un ancien exemple PHP circule encore régulièrement:

JWT::decode($token, $key, ['HS256'])

Cette signature correspond à une ancienne version de firebase/php-jwt. Dans la branche 6.x, la méthode JWT::decode() attend notamment un objet Key, qui associe explicitement la clé à l’algorithme autorisé.

Pour un secret HMAC, l’appel doit prendre une forme proche de celle-ci:

$decoded = JWT::decode($token, new Key($secretKey, 'HS256'));

Il faut importer la classe correspondante avec use Firebase\JWT\Key;. La bibliothèque ne reçoit donc pas une clé isolée accompagnée d’une liste d’algorithmes passée dans un troisième argument. Elle reçoit une clé typée par l’algorithme attendu.

La différence est importante, mais elle ne dispense pas de concevoir correctement la configuration:

  • l’algorithme est choisi par la configuration de l’application, pas par le JWT reçu;
  • une clé utilisée pour HS256 ne doit pas être réutilisée comme clé RSA;
  • le serveur ne doit pas accepter plusieurs familles d’algorithmes sans raison d’architecture;
  • la valeur none ne doit jamais faire partie des algorithmes autorisés pour une route protégée;
  • une erreur de décodage ou de validation doit entraîner le rejet du jeton, sans faire tomber la route dans un mode dégradé.

Dans une application Symfony, cette politique a intérêt à être centralisée dans un service d’authentification ou un composant dédié. Si chaque contrôleur décode le jeton à sa manière, les différences de configuration deviennent inévitables: une route utilise HS256, une autre accepte plusieurs algorithmes, une troisième vérifie la signature mais pas l’audience.

La sécurité ne doit pas dépendre de la mémoire du développeur qui ajoutera la prochaine route.

Confusion d’algorithmes: quand le serveur se trompe de clé

La confusion d’algorithmes est plus subtile que alg: none. Dans ce cas, une signature existe et peut même être mathématiquement correcte. Le problème est qu’elle a été produite avec le mauvais type de clé.

Le cas RSA contre HMAC

Prenons une architecture où un serveur d’authentification signe les jetons avec une clé privée RSA. Les API vérifient la signature avec la clé publique correspondante. L’algorithme déclaré est alors généralement RS256.

HMAC fonctionne autrement. Avec HS256, la même valeur secrète sert à signer et à vérifier. Il n’existe pas de couple clé privée/clé publique.

Si le serveur accepte indifféremment RS256 et HS256, puis transmet la même variable $key à la bibliothèque, une confusion peut apparaître:

  • l’application pense fournir une clé publique RSA pour vérifier une signature RS256;
  • l’attaquant modifie l’en-tête pour annoncer HS256;
  • la bibliothèque traite la clé publique comme un secret HMAC;
  • la signature forgée avec cette valeur publique peut alors être considérée comme valide.

La clé publique n’est pas censée être secrète. Elle peut être distribuée aux services qui doivent vérifier les jetons. Elle ne doit donc jamais devenir, par accident, le secret d’un algorithme symétrique.

Une famille de failles liée à la bibliothèque

La vulnérabilité CVE-2021-46743 a concerné des versions antérieures de firebase/php-jwt et la confusion entre algorithmes asymétriques et symétriques. La correction ne consiste pas uniquement à modifier un paramètre dans le contrôleur: il faut utiliser une version corrigée et empêcher l’application d’accepter une combinaison clé-algorithme incohérente.

La règle d’architecture est simple: une configuration de vérification doit décrire une seule famille cryptographique pour un usage donné.

Pour une API interne qui utilise HMAC, on conserve un secret fort et distinct pour chaque environnement. Pour une architecture avec plusieurs services, RSA ou une autre signature asymétrique peut être préférable: le service émetteur garde la clé privée et les consommateurs ne disposent que de la clé publique. Dans les deux cas, il faut déclarer l’algorithme attendu dans la configuration du vérificateur.

Ne pas mélanger les clés dans une variable générique

Une variable nommée $key ne dit rien sur sa nature. Est-ce un secret HMAC? Une clé publique RSA? Le contenu d’un certificat? Le chemin vers un fichier? Cette ambiguïté est pratique au début d’un projet, puis elle devient une source d’erreur.

Des noms plus explicites réduisent le risque:

  • $jwtHmacSecret pour un secret destiné à HS256;
  • $jwtPublicKey pour une clé publique RSA ou elliptique;
  • $jwtPrivateKey pour la clé utilisée uniquement lors de l’émission;
  • $issuer et $audience pour les valeurs de contexte attendues.

Dans Symfony, ces valeurs doivent venir de la configuration et des variables d’environnement, jamais d’une valeur codée en dur dans un contrôleur. Le secret ne doit pas être commité dans le dépôt, copié dans les journaux ou partagé entre développement, préproduction et production.

Mise à jour des bibliothèques: le rempart contre les CVE connues

Une bibliothèque JWT n’est pas un composant que l’on installe une fois pour toutes. Elle manipule de la cryptographie, des formats standardisés et des clés dont les types doivent rester cohérents. Une version ancienne peut continuer à « fonctionner » pendant longtemps tout en conservant une faiblesse connue.

Le premier réflexe consiste à regarder le verrouillage réel des dépendances, pas seulement le fichier composer.json. La version effectivement installée est celle résolue dans composer.lock.

Avec Composer, l’équipe peut notamment inspecter le paquet installé avec une commande comme composer show firebase/php-jwt ou composer show lcobucci/jwt. L’objectif n’est pas de recopier une version trouvée dans un article ancien, mais de vérifier:

  • la branche actuellement installée;
  • les avis de sécurité qui concernent cette branche;
  • les contraintes des autres paquets;
  • la présence éventuelle d’une mise à jour bloquée par une dépendance indirecte.

firebase/php-jwt et lcobucci/jwt ne se remplacent pas ligne à ligne

Ces deux bibliothèques sont utilisées dans des styles différents.

firebase/php-jwt propose une API relativement directe. Dans sa branche 6.x, le décodage s’appuie sur Firebase\JWT\Key: l’algorithme est attaché à la clé attendue. Un exemple minimal de vérification HMAC est donc construit autour de new Key($secret, 'HS256'), et non autour de l’ancienne liste passée en troisième argument.

lcobucci/jwt propose une approche davantage orientée configuration et contraintes. Le développeur construit une configuration de signature, définit les contraintes de validation, puis demande au validateur de les appliquer au jeton. Cette approche rend plus visibles les règles attendues, ce qui est utile dans une application Symfony conséquente.

Une mise à jour ne suffit toutefois pas si le code continue d’appeler une ancienne API ou si l’équipe mélange des exemples provenant de versions différentes. Toute migration doit être accompagnée d’un test qui vérifie réellement:

  • le rejet d’un jeton sans signature;
  • le rejet d’un algorithme qui n’est pas celui de la configuration;
  • le rejet d’une mauvaise clé;
  • le rejet d’un jeton expiré;
  • le rejet d’un émetteur ou d’une audience inattendus.

Les avis de sécurité ne remplacent pas la revue de configuration

Une CVE corrigée ne signifie pas que l’API est automatiquement sécurisée. La bibliothèque peut être à jour et le code d’application continuer à:

  • faire confiance au rôle fourni dans le payload;
  • oublier de vérifier aud;
  • accepter le jeton sur la mauvaise route;
  • conserver une clé faible dans une variable d’environnement;
  • inscrire des données personnelles dans un payload lisible;
  • traiter toute erreur comme une absence de jeton.

La mise à jour ferme une classe de défauts dans le composant. La revue de code doit fermer les erreurs de conception autour de ce composant.

Bonnes pratiques pour la gestion du cycle de vie des jetons

Un JWT est un jeton porteur: celui qui le possède peut tenter de l’utiliser. Le serveur ne connaît pas nécessairement la personne qui l’a copié depuis un navigateur, un proxy mal configuré ou un journal applicatif. La durée de validité devient donc une limite concrète à l’exploitation d’une fuite.

Séparer jeton d’accès et jeton de renouvellement

Le modèle le plus courant repose sur deux objets différents:

1. le jeton d’accès, envoyé aux routes API et valable peu de temps;

2. le jeton de renouvellement, utilisé uniquement pour obtenir un nouveau jeton d’accès.

Le jeton d’accès doit rester suffisamment court pour limiter la fenêtre d’utilisation après une compromission. Il ne doit pas être confondu avec une session longue durée. Le jeton de renouvellement, lui, nécessite une protection plus forte et une capacité de révocation côté serveur.

Il faut également éviter de donner au jeton de renouvellement les mêmes privilèges qu’au jeton d’accès. Il ne devrait pas être accepté par les contrôleurs métier. Une route dédiée doit le recevoir, vérifier son statut, puis émettre un nouveau jeton d’accès.

Le renouvellement peut être rendu plus sûr par rotation: à chaque utilisation, l’ancien jeton de renouvellement est invalidé et un nouveau est émis. Si un ancien jeton réapparaît, l’application peut considérer qu’une copie a été réutilisée et révoquer la famille de jetons associée.

La révocation implique un peu d’état

Un JWT autonome n’offre pas de révocation instantanée par lui-même. Tant que sa signature est correcte et que sa date d’expiration n’est pas dépassée, un vérificateur stateless peut le considérer comme recevable.

Pour invalider un jeton avant son expiration, il faut conserver au moins une information côté serveur. Plusieurs stratégies sont possibles:

  • enregistrer l’identifiant jti dans un cache avec une durée de vie correspondant à celle du jeton;
  • conserver la révocation dans une base de données;
  • associer les jetons à une version de session ou de compte, puis invalider cette version;
  • révoquer toute une famille de jetons après une suspicion de compromission.

Redis est souvent adapté aux entrées temporaires et aux vérifications fréquentes. Une base relationnelle peut convenir si le volume reste maîtrisé ou si la révocation doit être auditée durablement. Il n’existe pas de choix universel: la bonne solution dépend de la topologie, du niveau de traçabilité attendu et de la tolérance à une vérification supplémentaire.

La question à poser n’est pas « comment rendre JWT complètement stateless? », mais « quelles informations doivent rester contrôlables par le serveur? ».

Ne pas exposer de données sensibles dans le payload

Le payload JWT est encodé, pas chiffré. Un utilisateur ou un intermédiaire qui obtient le jeton peut en lire les claims sans connaître la clé de signature.

Il est donc préférable de limiter le contenu à ce qui est nécessaire au contrôle d’accès:

  • sub pour identifier le sujet;
  • iss pour l’émetteur;
  • aud pour l’API destinataire;
  • exp pour l’expiration;
  • iat pour la date d’émission;
  • nbf lorsque le jeton ne doit pas être actif immédiatement;
  • jti lorsque l’identification et la révocation individuelles sont utiles;
  • un scope réduit si l’autorisation peut réellement être décidée à partir de cette information.

Un mot de passe, même haché, n’a rien à faire dans un JWT. Les données personnelles, les informations de paiement et les secrets d’intégration doivent rester hors du payload. Quant aux rôles, ils doivent être interprétés avec prudence: un rôle placé dans le jeton représente l’état connu au moment de l’émission, pas nécessairement l’état actuel du compte.

Si un administrateur est rétrogradé, un jeton encore valide peut continuer à porter l’ancien rôle. Pour les permissions sensibles, l’application peut vérifier l’état courant du compte ou utiliser une version de session révoquée lors d’un changement de privilège.

Validation rigoureuse des claims: au-delà de la simple signature

La signature répond à une question précise: « le contenu a-t-il été modifié et provient-il d’une clé reconnue? ». Elle ne répond pas à ces autres questions:

  • le jeton a-t-il été émis par le bon service?
  • est-il destiné à cette API?
  • est-il encore valide dans le temps?
  • a-t-il été révoqué?
  • le sujet possède-t-il encore les droits demandés?

Un JWT signé peut donc être parfaitement authentique et néanmoins inacceptable pour la requête en cours.

Les claims temporels

exp doit être vérifié systématiquement. Un jeton expiré ne doit pas être accepté sous prétexte que sa signature est valide.

nbf indique le moment avant lequel le jeton ne doit pas être utilisé. iat permet de contrôler la cohérence de la date d’émission, notamment lorsqu’un jeton prétend avoir été créé dans le futur ou très longtemps avant la requête.

Les horloges des serveurs peuvent présenter un léger décalage. Il est raisonnable de prévoir une tolérance explicitement définie dans la bibliothèque ou dans la configuration, plutôt que de désactiver la validation temporelle. Cette tolérance doit rester limitée et surveillée. Une marge trop large transforme progressivement un contrôle de validité en simple indication.

Dans lcobucci/jwt, les contraintes temporelles et les contraintes d’émetteur ou d’audience peuvent être regroupées dans une configuration de validation. Avec une version récente de la bibliothèque, le principe consiste à parser le jeton, puis à demander au validateur d’appliquer les contraintes configurées. Il ne faut pas se contenter de vérifier que le parsing n’a pas déclenché d’exception.

iss, aud et jti

iss, ou issuer, identifie le service qui a émis le jeton. Une API doit connaître à l’avance les émetteurs qu’elle accepte. Vérifier uniquement la signature peut laisser passer un jeton émis par un autre composant qui partage par erreur la même clé.

aud, ou audience, précise le service auquel le jeton est destiné. Dans un écosystème composé d’une API publique, d’une API d’administration et de services internes, un jeton valide pour l’un ne devrait pas être accepté par tous les autres.

jti identifie le jeton lui-même. Il est particulièrement utile pour une liste de révocation, à condition que l’application vérifie réellement cette liste. Ajouter le claim sans le consulter ne produit aucune protection.

Une configuration lcobucci/jwt peut exprimer ces règles avec des contraintes telles que SignedWith, IssuedBy, PermittedFor et une contrainte de validité temporelle. Le nom exact des classes et les méthodes disponibles dépendent de la branche installée: il faut donc suivre la documentation de la version réellement verrouillée par Composer, et non copier un exemple prévu pour une autre API.

La signature ne décide pas des autorisations métier

Même après validation du JWT, le contrôleur doit encore appliquer les règles métier. Un sub identifie un utilisateur; il ne donne pas automatiquement accès à toutes les ressources de cet utilisateur. Un scope orders:read ne devrait pas autoriser la modification d’une commande. Un rôle admin ne devrait pas être interprété comme un passe-partout si certaines opérations exigent une permission supplémentaire ou une confirmation forte.

Dans Symfony, cette séparation peut être portée par les voters, les attributs d’autorisation ou un service métier dédié. Le composant qui vérifie le JWT établit l’identité et le contexte de confiance. Le code métier décide ensuite si cette identité peut effectuer l’action demandée.

C’est une distinction essentielle pour éviter une faille de contrôle d’accès indirecte: une authentification correcte ne garantit pas une autorisation correcte.

Un exemple de vérification avec firebase/php-jwt 6.x

Avec firebase/php-jwt 6.x, la vérification minimale d’un jeton HMAC repose sur une clé explicitement associée à HS256:

$decoded = JWT::decode($token, new Key($jwtHmacSecret, 'HS256'));

L’application doit ensuite contrôler les claims attendus. Selon la manière dont le jeton est construit et la version utilisée, certains contrôles peuvent être réalisés par la bibliothèque, tandis que les règles propres au service restent à appliquer dans le code d’application. Dans tous les cas, il faut vérifier l’émetteur, l’audience, l’expiration et les autres claims nécessaires au contexte.

Pour une signature RSA, la forme change: on fournit la clé publique et l’algorithme asymétrique attendu, par exemple new Key($jwtPublicKey, 'RS256'). Il ne faut pas accepter une valeur d’algorithme issue directement de l’en-tête.

L’exception levée par le décodage ne doit pas être transformée en réponse détaillée à destination du client. Le client peut recevoir une réponse d’authentification générique; la raison technique doit rester dans des journaux protégés, sans y inscrire le jeton complet.

Ce que Julie doit vérifier avant de considérer l’API comme sécurisée

Une revue utile ne consiste pas à chercher une seule chaîne de caractères dans le dépôt. Elle suit le parcours complet du jeton, de son émission à sa révocation:

1. Émission: les claims sont-ils construits par le serveur et non repris directement depuis une entrée utilisateur? L’émetteur, l’audience et les dates sont-ils définis par la configuration?

2. Signature: la clé est-elle suffisamment robuste, stockée hors du code et distincte selon les environnements? Le service émetteur est-il le seul à détenir la clé privée lorsqu’une signature asymétrique est utilisée?

3. Décodage: le code utilise-t-il l’API correspondant à la version installée de la bibliothèque? Avec firebase/php-jwt 6.x, l’algorithme est-il déclaré dans l’objet Key?

4. Algorithme: le serveur impose-t-il une combinaison clé-algorithme précise? Une valeur alg inattendue provoque-t-elle un rejet immédiat?

5. Claims: iss, aud, exp, nbf et iat sont-ils vérifiés selon le besoin? Le jti est-il consulté lorsqu’une révocation est annoncée?

6. Autorisation: le rôle ou le scope du jeton est-il suffisant pour l’action demandée, ou faut-il aussi vérifier l’état actuel de la ressource et du compte?

7. Cycle de vie: les jetons d’accès expirent-ils rapidement? Les jetons de renouvellement peuvent-ils être révoqués et, si nécessaire, soumis à une rotation?

8. Observabilité: les rejets sont-ils journalisés avec un motif exploitable, sans enregistrer le jeton lui-même ni des secrets?

9. Dépendances: les versions installées sont-elles suivies dans composer.lock et contrôlées par un processus régulier de mise à jour?

Cette revue doit aussi couvrir les tests négatifs. Il faut tester un jeton expiré, un jeton destiné à une autre audience, un émetteur inconnu, une signature altérée, un algorithme différent de celui attendu, une clé incorrecte et un jeton révoqué. Une API qui ne teste que le parcours de connexion ne teste pas vraiment sa sécurité.

La signature prouve l’origine du jeton. Elle ne prouve ni sa destination, ni sa fraîcheur, ni les droits actuels de son porteur.

La sécurité d’une API REST PHP ne se résume donc pas à choisir JWT plutôt qu’une session. JWT fait ce qu’on lui demande: signer des données et permettre leur vérification. Les failles apparaissent lorsqu’on lui demande de choisir lui-même son algorithme, lorsqu’on confond clé publique et secret, lorsqu’on oublie de vérifier le contexte ou lorsqu’on conserve trop longtemps un jeton devenu incontrôlable.

Pour Julie, la correction durable n’est pas une ligne ajoutée dans un contrôleur. C’est une chaîne de décisions cohérentes: une bibliothèque maintenue, une API de version actuelle, un algorithme imposé par le serveur, des clés correctement séparées, des claims validés, un accès de courte durée et un mécanisme de révocation adapté.

Le reste n’est pas du détail. C’est précisément dans ces raccords — entre la bibliothèque et sa version, entre la signature et l’autorisation, entre l’émission et la révocation — que se joue la différence entre une API simplement authentifiée et une API réellement sécurisée.

Questions fréquentes

Pourquoi l'algorithme 'none' est-il dangereux dans un jeton JWT ?
L'algorithme 'none' permet de créer un jeton sans signature. Si le serveur accepte cette valeur, un attaquant peut modifier le contenu du jeton sans avoir besoin de clé, rendant la falsification totale.
Comment éviter la confusion d'algorithmes entre RSA et HMAC ?
Il faut s'assurer que le serveur n'accepte qu'une seule famille cryptographique par usage et ne jamais utiliser une clé publique RSA comme secret pour un algorithme HMAC.
Quelle est la différence entre un jeton d'accès et un jeton de renouvellement ?
Le jeton d'accès est de courte durée et utilisé pour les appels métier, tandis que le jeton de renouvellement sert uniquement à obtenir un nouveau jeton d'accès et nécessite une protection renforcée.
Pourquoi faut-il vérifier l'audience (aud) et l'émetteur (iss) d'un JWT ?
La signature prouve l'origine, mais pas la destination. Vérifier ces claims permet de s'assurer que le jeton a été émis par le bon service et qu'il est bien destiné à l'API qui le reçoit.
Comment révoquer un jeton JWT avant son expiration ?
Comme le JWT est stateless, la révocation nécessite de conserver un état côté serveur, par exemple en enregistrant l'identifiant du jeton (jti) dans un cache comme Redis jusqu'à son expiration.