Confidentialité. Cet audit a bien eu lieu. Pour des raisons évidentes de confidentialité, le nom du client, sa pile technique, ses volumes et les détails de la mission ont été modifiés, et ce que vous lisez en est une version romancée. Les défauts décrits, eux, sont ceux qu’on a trouvés.
Trois requêtes, trois réponses
# Sans jeton, l'API refuse proprement
$ curl -s https://api.transportys.fr/v2/shipments
{"error":"Authentification requise"}
# Avec un jeton invalide, elle refuse aussi
$ curl -s -H "Authorization: Bearer abc123" https://api.transportys.fr/v2/shipments
{"error":"Jeton invalide"}
# Avec un jeton malformé, qui fait lever une exception dans la bibliothèque
$ curl -s -H "Authorization: Bearer eyJhbGciOiJIUzI1NiJ9.@@@.@@@" https://api.transportys.fr/v2/shipments | jq '.total'
48219
Deux refus corrects, et une troisième requête qui renvoie 48 219 expéditions. Toutes les expéditions, de toutes les entreprises clientes de la plateforme.
Le client : Transportys, un éditeur français de logiciel pour le transport de marchandises. Le métier mérite deux phrases, parce qu’il n’a rien d’évident vu de l’extérieur. Quand une entreprise doit envoyer des palettes de son entrepôt vers un magasin, elle ne possède presque jamais les camions. Elle passe par des transporteurs, avec qui elle a négocié des tarifs à l’avance.
Le logiciel de Transportys est ce qui orchestre tout ça : il choisit le transporteur pour chaque envoi, applique le bon tarif, édite les documents de transport et suit la marchandise jusqu’à la livraison. Dans le métier, on appelle ça un TMS, pour Transport Management System.
Sa base contient donc les adresses de livraison, les plans de tournée, les contrats, et surtout les tarifs négociés entre chaque entreprise et chaque transporteur. Dans ce secteur, connaître la grille tarifaire d’un concurrent vaut bien plus cher qu’un fichier d’adresses.
Ce qu’on a exploité ici relève de la catégorie A10:2025 Mishandling of Exceptional Conditions, la dernière du Top 10 OWASP et l’une des deux nouveautés de l’édition 2025. Elle couvre les situations où un programme ne sait ni prévenir, ni détecter, ni réagir correctement à l’imprévu.
Autrement dit, la faille ne vit pas dans le chemin qui marche, ni dans le chemin d’erreur propre. Elle vit dans le chemin où c’est le contrôle de sécurité lui-même qui casse.
Le cas en bref
- Secteur : éditeur français d’un TMS en SaaS pour le transport et la logistique
- Catégorie OWASP : A10:2025 Mishandling of Exceptional Conditions
- Format : pentest en boîte grise, à partir d’un compte de démonstration
- Défaut 1 : le contrôle d’accès s’efface quand il rencontre une erreur, au lieu de refuser
- Défaut 2 : une mise à jour interrompue en cours de route laisse le système à moitié modifié
- Défaut 3 : deux messages d’erreur différents permettent d’énumérer les numéros d’expédition valides
- Donnée exposée : 48 219 expéditions, toutes entreprises clientes confondues, tarifs négociés inclus
- Correctif : décider explicitement, pour chaque contrôle, ce qui se passe quand il échoue
Fail open, fail closed
Prenez le portique d’accès d’un entrepôt. Coupure de courant. Il s’ouvre, ou il se ferme ?
La question n’a pas de bonne réponse universelle, et c’est tout l’intérêt.
Une porte coupe-feu doit s’ouvrir quand le courant saute, pour que les gens à l’intérieur puissent sortir. S’ouvrir quand ça casse, c’est le fail open.
Le portique d’entrée, lui, doit rester verrouillé, sinon il ne protège plus la marchandise et n’importe qui entre depuis l’extérieur. Rester fermé quand ça casse, c’est le fail closed.
Il n’y a aucune contradiction là-dedans : c’est la même coupure de courant, et deux directions opposées. On laisse sortir, on ne laisse pas entrer.
Le vrai défaut n’est donc pas d’avoir choisi le mauvais. C’est de n’avoir rien choisi du tout, et d’avoir laissé un bloc de code décider à votre place. Chez Transportys, personne n’a jamais pris la décision d’ouvrir l’API en cas de panne d’authentification. Elle s’est prise toute seule, un jour, dans un bloc catch.
Trois défauts, dans l’ordre où on les trouve
Défaut 1. Le contrôle d’accès s’efface quand il rencontre une erreur. C’est le défaut principal, celui de la démonstration en tête d’article, et il correspond à la CWE-636, Not Failing Securely.
Voilà à quoi ressemble le cœur du problème :
public function listShipments(Request $request): Response
{
try {
$user = $this->authService->verify($request->headers->get('Authorization'));
} catch (\Throwable $e) {
// le service d'authentification est instable, on ne bloque pas les expéditions
$this->logger->warning('auth unavailable', ['err' => $e->getMessage()]);
$user = null;
}
// l'exécution reprend ici, exactement comme si la validation avait réussi
return $this->json(
$this->shipments->forClient($user?->clientId)
);
}
Trois ingrédients se combinent, et pris séparément aucun ne poserait problème.
D’abord, le catch est trop large. \Throwable attrape absolument tout. Or la bibliothèque de jetons lève des exceptions de natures très différentes : une signature invalide veut dire « ce jeton est faux, refuse », tandis qu’un délai réseau dépassé veut dire « le service n’a pas répondu ». En les mettant dans le même sac, on répond « laisse passer » aux deux. S’ils n’avaient attrapé que l’erreur réseau, en laissant remonter l’erreur de signature, la faille n’existerait pas.
Ensuite, l’exception est avalée sans être relancée. Le catch journalise, affecte null, et l’exécution reprend tranquillement à la ligne suivante. Attraper une erreur sans agir dessus porte un nom dans la nomenclature, la CWE-390, Detection of Error Condition Without Action.
Enfin, ce null ne déclenche aucun refus, il se propage. Et il arrive jusqu’ici :
public function forClient(?int $clientId): array
{
$qb = $this->createQueryBuilder('s');
// filtre optionnel : sans identifiant client, la condition n'est jamais ajoutée
if ($clientId !== null) {
$qb->andWhere('s.clientId = :id')->setParameter('id', $clientId);
}
return $qb->getQuery()->getResult();
}
C’est le motif du filtre optionnel, qu’on a tous écrit. Sans identifiant client, la condition WHERE n’est jamais ajoutée, donc la requête renvoie la table entière. « Pas de client » a été traduit par « aucune restriction » au lieu de « aucun résultat ». Voilà les 48 219 lignes.
Défaut 2. Une mise à jour qui s’arrête au milieu laisse le système à moitié modifié. Changer le destinataire d’un envoi demande deux écritures qui se suivent : enregistrer le nouveau destinataire, puis recalculer qui a le droit de suivre la marchandise. Si la seconde échoue, la première reste en place. L’envoi part désormais chez quelqu’un d’autre, mais c’est toujours l’ancien destinataire qui peut le suivre, parfois pendant des mois.
En base de données, on appelle transaction un enchaînement d’écritures qui doivent toutes réussir, ou toutes être annulées ensemble. Ici il n’y en avait pas : les deux écritures étaient indépendantes, donc l’échec de la seconde laissait tranquillement la première derrière elle. C’est le scénario de corruption d’état que l’OWASP cite explicitement dans cette catégorie.
Défaut 3. Deux messages d’erreur différents suffisent à énumérer. Demander une expédition qui n’existe pas renvoie « expédition introuvable ». En demander une qui existe mais ne vous appartient pas renvoie « accès refusé ». La différence entre les deux réponses est une information en soi, et elle permet de cartographier les numéros valides sans jamais accéder à leur contenu. C’est la CWE-209, un message d’erreur qui en dit plus qu’il ne devrait.
Comment y remédier
- Décider explicitement du comportement en cas d’erreur, contrôle par contrôle. Fail closed par défaut pour tout ce qui protège une donnée, et fail open réservé aux cas où une vie humaine en dépend.
- Attraper l’exception précise, jamais la classe racine. Un défaut de signature et une panne réseau n’appellent pas la même réponse, donc ils ne doivent pas passer par le même bloc.
- Faire de l’absence d’utilisateur un refus explicite. Pas un état neutre qui continue son chemin.
- Bannir le filtre optionnel sur une donnée cloisonnée. Sans identifiant client, une requête doit renvoyer zéro ligne, jamais toutes.
- Rendre les transactions atomiques. Si le recalcul des droits échoue, la mise à jour qui le précède doit être annulée avec lui.
- Tester le chemin d’erreur du contrôle lui-même. Un test automatisé qui coupe le service d’authentification et vérifie que l’API refuse bien vaut tous les audits de code.
- Faire remonter ces erreurs en alerte, pas en
warning. Une ligne de journal que personne ne lit n’est pas une détection.
Et la règle qui résume tout : un contrôle de sécurité qui tombe doit faire échouer la requête, pas disparaître.
Ce que ça coûte
Cette fois, le sujet n’est pas d’abord la donnée personnelle, donc on sort du terrain habituel du RGPD. Un mot sur le texte qui prend le relais, parce qu’il reste mal connu.
NIS2 est une directive européenne adoptée le 14 décembre 2022, qui remplace un premier texte de 2016 jugé trop étroit et appliqué de façon très inégale selon les pays. Son objet n’est pas de protéger les données des personnes, mais de garantir que les services dont dépend l’économie continuent de fonctionner et résistent aux attaques.
C’est toute la différence avec le RGPD, et elle est nette. Le RGPD protège les individus et leurs données, et c’est la CNIL qui le fait appliquer en France. NIS2 protège la continuité des services essentiels, et c’est l’ANSSI qui en a la charge. Deux objets différents, deux régulateurs différents, et deux séries d’obligations qui s’additionnent au lieu de se remplacer. Une entreprise parfaitement en règle avec le RGPD peut très bien être en défaut sur NIS2.
Les États membres devaient transposer le texte avant le 17 octobre 2024. La France a près de deux ans de retard.
- Le transport fait partie des secteurs couverts. La directive vise plus de dix-huit secteurs et concernera des milliers d’entités en France, de la PME au grand groupe, réparties entre entités essentielles et entités importantes selon la taille.
- Les délais de notification sont serrés. Alerte précoce sous 24 heures, notification sous 72 heures, rapport final sous un mois.
- Les sanctions changent d’échelle. Jusqu’à 10 millions d’euros ou 2 % du chiffre d’affaires mondial pour une entité essentielle, 7 millions ou 1,4 % pour une entité importante.
- La transposition française se termine. La loi dite résilience, qui transpose d’un seul tenant NIS2, la directive REC et DORA, poursuit son parcours parlementaire. Les obligations ne sont donc pas encore exigibles en France, mais l’enregistrement auprès de l’ANSSI se fera via la plateforme MonEspaceNIS2, qui propose déjà un test en ligne pour savoir si votre entreprise entre dans le périmètre.
Et voilà l’argument qui relie tout le reste : on ne notifie pas sous 24 heures ce qu’on n’a jamais vu. Chez Transportys, chaque contournement du contrôle d’accès produisait bien une ligne de journal, en warning, noyée parmi des milliers d’autres, sans alerte, sans seuil, sans destinataire. Le système savait. Personne ne l’a su.
Reste le coût qui ne figure dans aucun texte. Pour un éditeur de TMS, la grille tarifaire d’une entreprise cliente est le secret le mieux gardé de la relation commerciale. Une fuite ne se solde pas par une amende, elle se solde par des contrats qui partent à la concurrence.
Pourquoi cette faille survit
Les tests couvrent deux chemins sur trois. On vérifie que ça marche avec un bon jeton, on vérifie que ça refuse avec un mauvais. Le troisième cas, celui où la vérification elle-même casse, n’apparaît dans aucun scénario de test parce qu’il n’apparaît dans aucun cahier des charges.
Le catch large est enseigné comme une bonne pratique. On apprend à ne pas laisser une exception remonter jusqu’à l’utilisateur. C’est un bon réflexe pour l’affichage, c’est un désastre pour une décision d’autorisation.
Ce bloc est né d’un vrai incident. Un jour, le service d’authentification est tombé, les expéditions ont été bloquées, et il a fallu une intervention en urgence pour rétablir le service. Le catch a été écrit le lendemain, pour de bonnes raisons, et personne n’y est jamais revenu.
L’erreur ne fait aucun bruit. Une faille de contrôle d’accès classique se voit dans les données. Celle-ci ne se manifeste que sous une condition qui n’arrive presque jamais en usage normal, et qu’un attaquant, lui, provoque exprès.
Comment savoir si vous êtes exposé
Un geste que votre équipe peut faire cet après-midi, sur votre propre dépôt :
grep -rn "catch" --include="*.php" src/ | grep -iE "auth|token|permission|acl"
Adaptez l’extension à votre langage. Pour chaque résultat, une seule question compte : qu’est-ce qui se passe juste après ce bloc ? Si l’exécution reprend son cours normal sans refus explicite, vous avez le même défaut que Transportys.
Côté référentiels, l’ASVS de l’OWASP traite la gestion des erreurs et l’exigence de refus par défaut, et le Web Security Testing Guide détaille les tests de gestion d’erreurs. C’est sur ces référentiels que s’appuie notre test d’intrusion web et API.
On avait déjà raconté une faille de contrôle d’accès chez une PropTech et une préproduction oubliée sur Internet, deux autres retours d’audit de la même série.
Conclusion
Chez Transportys, personne n’a décidé d’ouvrir l’API à qui saurait faire planter l’authentification. La décision s’est prise toute seule, dans un bloc catch écrit pour de bonnes raisons après un vrai incident.
C’est ce qui rend cette catégorie difficile à voir. Elle ne se trouve ni dans le chemin nominal, que tout le monde teste, ni dans le refus propre, que tout le monde teste aussi. Elle vit dans le troisième chemin, celui où le contrôle casse, et celui-là n’est écrit nulle part.
C’est d’ailleurs une bonne partie de notre travail : prendre une API, la parcourir point par point, et vérifier ce qu’elle répond dans tous les cas, y compris ceux que personne n’avait prévus.
Si vous opérez une application qui protège des données derrière un contrôle d’accès, la question n’est pas de savoir si ce contrôle fonctionne. Il fonctionne, sinon vous l’auriez vu. Elle est plus dérangeante : qu’est-ce qu’il fait le jour où il tombe ?
Si personne dans votre équipe ne peut répondre, c’est qu’un bloc catch a déjà répondu à sa place.