IDOR multi-tenant : pourquoi les tests ne la voient pas
Un gérant pouvait consulter les brouillons d'un autre établissement en changeant un chiffre dans l'URL. Le défaut n'était pas dans le code d'autorisation : il était dans son absence sur une seule route, et aucun test ne pouvait le voir.
Sur une application de gestion multi-établissements, chaque gérant administre son établissement et rien d’autre. La règle est évidente, elle était écrite, et elle était appliquée — presque partout.
Une route ne l’appliquait pas. Un gérant qui changeait un identifiant dans l’URL d’une page d’édition voyait les brouillons d’un autre établissement. Pas ses données de paiement, pas ses comptes : ses fiches non publiées. Suffisant pour connaître la carte et les prix d’un concurrent direct avant leur mise en ligne.
Aucune alerte ne s’est déclenchée. Aucun test n’a échoué. C’est ce silence qui mérite un article.
Pourquoi aucun test ne pouvait la voir
Les tests étaient écrits — et ils passaient. Ils passaient parce qu’ils testaient la mauvaise chose.
Un test d’application se rédige spontanément du point de vue d’un seul compte : je me connecte, je crée une fiche, je la modifie, je vérifie qu’elle est correcte. Tout ce parcours est légitime. Il ne franchit jamais de frontière, donc il ne peut pas détecter qu’une frontière manque.
Le second angle mort tient à qui écrit le test. La personne qui vient d’écrire la route la teste avec le modèle mental qu’elle avait en l’écrivant. Si ce modèle contenait « bien sûr que l’appelant ne demandera que ses propres fiches », le test le contient aussi.
Un test ne révèle une faille de cloisonnement que s’il existe un second compte. C’est la seule construction qui marche :
1. Compte A crée une ressource, note son identifiant
2. Compte B s'authentifie normalement
3. Compte B demande l'identifiant de A
4. Le test réussit si, et seulement si, la réponse est 404
Notez le 404, pas le 403. Un 403 confirme que la ressource existe — c’est déjà une fuite d’information. Un compte qui n’a pas le droit de voir une ressource ne doit pas pouvoir déduire son existence.
La cause racine
L’autorisation existait. Elle était vérifiée dans la couche d’affichage : le gabarit ne proposait à un gérant que les fiches de son établissement, et l’interface ne construisait jamais un lien vers autre chose.
C’est une protection de l’interface, pas de la donnée. Elle tient tant que l’appelant utilise l’interface. Elle ne tient pas une seconde quand quelqu’un tape l’URL à la main — et c’est précisément ce qu’on fait quand on cherche.
La couche d’accès aux données, elle, recevait un identifiant et le servait :
-- Ce que faisait la route : elle fait confiance à l'appelant
SELECT * FROM fiche WHERE id = :id;
Rien dans cette requête ne dit à qui la fiche appartient. La sécurité reposait entièrement sur le fait que personne n’appelle cette route avec un identifiant qui n’est pas le sien.
Le correctif, et pourquoi il ne suffisait pas
La correction immédiate tient en une ligne :
-- Le périmètre du compte fait partie de la question posée
SELECT * FROM fiche
WHERE id = :id
AND etablissement_id = :etablissement_du_compte;
Un identifiant appartenant à un autre établissement ne renvoie plus rien. Le 404 tombe naturellement, sans code supplémentaire, parce que la ressource n’existe pas dans le périmètre de celui qui demande.
Mais corriger cette route ne corrige pas le problème. Il restait une certitude désagréable : si une route avait été oubliée, d’autres pouvaient l’être, et rien n’empêchait la prochaine route écrite de l’être aussi. Une revue de code attrape ce genre d’oubli une fois sur deux, et seulement si le relecteur y pense ce jour-là.
Ce qui a réellement réglé le problème, c’est de rendre l’oubli impossible plutôt que détectable : une seule fonction d’accès par table, qui prend le contexte du compte en premier argument et qu’on ne peut pas appeler sans lui.
// Impossible à appeler sans périmètre : le paramètre est obligatoire
// et il vient du contexte de session, pas de la requête HTTP.
fiche.parIdentifiant(contexte, id)
La différence est structurelle. Dans la première version, écrire une route sûre demandait de se souvenir d’une règle. Dans la seconde, écrire une route non sûre demande un effort délibéré. C’est le seul type de règle qui survit à six mois de développement et à une reprise par quelqu’un d’autre.
Le point qui compte : le contexte vient de la session, jamais de la requête. Une route qui
accepte etablissement_id en paramètre depuis le client n’a rien corrigé du tout — elle a
juste déplacé la faille d’un champ à un autre.
Les deux autres failles trouvées au même endroit
Chercher la première a fait apparaître deux autres défauts, et ils viennent de la même habitude de pensée.
Un paramètre exposait des données inactives à des requêtes non authentifiées. Un filtre destiné à l’administration — afficher aussi ce qui est masqué au public — était accepté sur une route publique. Le raisonnement d’origine : « ce paramètre n’est utilisé que par le back-office ». Le même raisonnement que la première faille, sur un autre champ.
Le partage de session entre onglets provoquait une divergence d’identité. Deux comptes ouverts dans deux onglets du même navigateur finissaient par agir l’un pour l’autre, selon l’ordre des requêtes. Rare en usage réel, mais parfaitement reproductible — et sur une borne en libre-service, pas rare du tout.
Ces trois défauts partagent une racine : une hypothèse sur le comportement de l’appelant, tenue pour acquise au lieu d’être vérifiée. C’est la définition même de ce qu’une architecture de confiance zéro cherche à éliminer, bien avant qu’il soit question de réseau ou de chiffrement.
Ce que nous en retenons
Trois règles, appliquées depuis sur chaque projet.
Le périmètre est dans la requête, pas dans la vue. Un contrôle d’accès qui vit dans le
gabarit protège l’interface, pas la donnée. Si le filtre n’est pas dans la clause WHERE, il
n’existe pas.
Le contexte vient de la session, jamais du client. Tout ce qui arrive dans une requête HTTP est une proposition de l’appelant, pas un fait.
Un projet sans faille trouvée n’est pas un projet sûr — c’est un projet qu’on n’a pas regardé. Ces trois défauts ont été cherchés, trouvés, corrigés et documentés avant la mise en service. C’est le seul moment où ça ne coûte rien.
Nous préférons écrire cet article plutôt que de laisser croire qu’un logiciel se construit sans erreur. Ce qui distingue un projet sérieux d’un autre, ce n’est pas l’absence de failles : c’est ce qui est mis en place pour qu’elles soient trouvées par vous, et pas par quelqu’un d’autre.
Une question sur ce sujet, ou un problème comparable de votre côté ?Écrivez-nous.