BOLA, faille n°1 des API : le correctif tient en une ligne, le test A-B le prouve

Une application de facturation expose une API : des adresses web, ou URL, que d'autres programmes appellent pour lire ou modifier des factures.

Bob est connecté avec son compte. Il demande sa facture à l'URL GET /invoices/7f3b, où GET veut dire « lire ». L'API répond avec sa facture et le code de réponse 200, qui signifie « OK ».

Bob change un caractère dans l'URL et demande GET /invoices/7f3a. L'API répond encore 200. Cette fois, la facture est celle d'Alice.

Cette faille s'appelle BOLA, pour Broken Object Level Authorization : l'autorisation est cassée au niveau de chaque objet, ici chaque facture. L'OWASP, une fondation qui classe les failles de sécurité du web, la met en tête de son classement 2023 des failles d'API.

Le correctif tient en une ligne : la condition qui vérifie que la facture appartient à l'utilisateur connecté. Une seule ligne, c'est aussi très facile à oublier.

Pour vérifier qu'elle est là, on écrit un test : un petit programme qui rejoue une scène tout seul. Il affiche vert si tout va bien, rouge sinon. Un livre sur la sécurité des API, Defending APIs de Colin Domoney (Packt, 2024), décrit un tel test sans jamais l'écrire. Voici ce correctif et ce test, en Go puis en Symfony.

BOLA (ou IDOR) : une faille qui tient dans une URL

Le mécanisme est toujours le même. L'URL porte l'identifiant de la facture, ici 7f3a. Le serveur lit cet identifiant et va chercher la facture dans la base de données, là où l'application range ses factures. Il ne se demande jamais à qui elle appartient.

La confusion vient de deux mots proches :

  • l'authentification dit qui tu es. Bob envoie avec chaque demande un jeton, une sorte de badge reçu à la connexion. L'API sait donc que c'est Bob ;
  • l'autorisation dit ce que tu as le droit de toucher. C'est elle qui manque ici.

Une API qui vérifie la première et oublie la seconde laisse Bob lire toute facture dont il connaît l'identifiant. Tu croiseras aussi le nom IDOR pour cette même faille.

Le réflexe courant consiste à rendre les identifiants impossibles à deviner, avec de longs identifiants tirés au hasard. L'OWASP le conseille, en plus d'une vraie vérification. Le livre de Colin Domoney juge ce genre de camouflage insuffisant :

« None of these are sufficient to prevent BOLA and will merely serve to delay an attacker. »

Aucune de ces mesures ne suffit, elles retardent seulement l'attaquant. Un identifiant peut se retrouver ailleurs : dans un lien partagé, une capture d'écran. Le jour où Bob le connaît, seule la vérification du propriétaire l'empêche de lire la facture.

Pourquoi un scanner à un seul compte ne la voit pas : le test A-B

Un scanner de vulnérabilités est un outil qui bombarde une API de demandes et signale les réponses suspectes. Un scanner qui n'utilise qu'un seul compte envoie GET /invoices/7f3a et reçoit 200. Le scanner est ravi : il ne sait pas que la facture est à Alice. Pour voir la faille, il faut deux comptes et se souvenir de qui a créé quoi.

Defending APIs le reconnaît sans détour :

« Unfortunately, most current test frameworks are not designed to perform the multistage A-B testing required to identify a broken object-level vulnerability. »

La plupart des outils de test ne sont pas conçus pour enchaîner les demandes de deux utilisateurs différents. Or c'est cet enchaînement qui révèle la faille. Le livre l'appelle le test A-B :

« First, create a resource as user A. Then, attempt to access the resource as user B. If you can, then you have an authorization issue. »

L'utilisateur A, ici Alice, crée une facture. L'utilisateur B, ici Bob, essaie de la lire. S'il y arrive, l'autorisation est cassée. La variante A-B-A va plus loin : B essaie de supprimer la facture, puis A la redemande pour vérifier qu'elle est intacte.

Dans ce test, Bob doit recevoir 404, le code de réponse « introuvable ». Bob ne doit même pas savoir que la facture existe. Un « interdit » lui apprendrait qu'il a visé juste.

Entre la création et la demande de Bob, le livre prévoit de vérifier que A accède bien à sa facture. Cette vérification paraît inutile, elle ne l'est pas. Le test ne regarde que la réponse faite à Bob. Une API cassée, qui répond 404 à tout le monde, donnerait aussi 404 à Bob. Le test serait vert pour une mauvaise raison. Alice redemande donc sa facture et doit recevoir 200.

Le schéma ci-dessous reprend ces étapes. Il emploie deux mots de plus : POST veut dire « créer », DELETE veut dire « supprimer ».

Test A-B puis A-B-A : Alice crée une facture et la redemande, Bob la demande et doit recevoir 404, Bob tente de la supprimer, Alice la redemande et la retrouve intacte. A 1. Alice crée une facture POST /invoices → 201, id 7f3a A 2. Alice redemande sa facture GET /invoices/7f3a → 200 B 3. Bob demande la même facture GET /invoices/7f3a 404 : test vert 200 : BOLA, rouge Variante A-B-A B 4. Bob tente de la supprimer DELETE /invoices/7f3a → 404 A 5. Alice redemande sa facture GET /invoices/7f3a → 200, intacte
Schéma fait pour l'article : le test A-B (étapes 1 à 3), puis la variante A-B-A (étapes 4 et 5).

Le 201 de l'étape 1 est le code de réponse « créé ». Le livre conseille d'écrire ces tests soi-même. Il ne donne aucun exemple de code : les sections Go et Symfony plus bas comblent ce manque.

L'exemple du livre protège la suppression, pas la facture

Le propre exemple d'autorisation du livre montre l'erreur la plus courante. L'auteur y confie les décisions à un moteur de règles, Casbin. C'est un outil qui lit des règles écrites dans un fichier et répond « oui » ou « non ».

L'exemple du livre manipule des « items », qui jouent ici le rôle de nos factures. Ses règles disent qu'alice peut supprimer les items et que bob peut seulement les lire.

La règle porte sur /items/* : elle répond pour tous les items d'un coup. Le moteur répond donc à la question « cet utilisateur a-t-il le droit de supprimer des factures ? ». Il ne pose jamais la vraie question de BOLA : « a-t-il le droit de supprimer cette facture-là ? ».

La ligne qui supprime le montre :

items_dao.delete_item(item_id)

Cette ligne ne reçoit que l'identifiant de l'item, jamais son propriétaire. Rien ne vérifie donc à qui il appartient.

À ma lecture, dès que deux utilisateurs ont le droit de supprimer, chacun peut effacer les factures de l'autre. Le test A-B-A le montrerait au premier essai.

Quand le moteur refuse, l'exemple répond avec le code de réponse 401. Il veut dire « je ne sais pas qui tu es, connecte-toi ». Or le serveur sait très bien qui est bob. Le 401 du livre est donc faux, tout simplement. Il envoie chercher l'erreur du côté de la connexion, alors que c'est le droit qui manque.

404 plutôt que 403

Le code de réponse 403 veut dire « je sais qui tu es, et c'est non ». Pour une facture d'un autre, 403 et 404 ont chacun un argument :

  • 404 cache l'existence de la facture. Bob ne distingue pas « elle n'existe pas » de « elle est à quelqu'un d'autre ». Il ne peut donc pas essayer des identifiants un par un pour dresser la liste de ceux qui existent ;
  • 403 aide le développeur qui cherche la cause d'un bug : il voit tout de suite que l'autorisation a refusé.

Ma recommandation : 404 pour toute donnée qui appartient à un utilisateur, comme une facture ou un compte. Le développeur peut retrouver la cause du refus dans les journaux du serveur, les fichiers où il note chaque demande. Une liste d'identifiants valides, elle, ne se rattrape pas une fois que Bob l'a dressée. Les deux tests, Go et Symfony, exigent donc 404.

La RFC 9110, le texte qui définit HTTP, prévoit ce choix : un serveur qui veut cacher l'existence d'une ressource interdite peut répondre 404. Limite : si Bob peut lire la facture mais pas la supprimer, il sait déjà qu'elle existe. Le refus de suppression est alors un 403.

En Go : filtrer par propriétaire, puis le prouver

Le correctif : le propriétaire dans la recherche

Le principe : ne jamais aller chercher une facture par son seul identifiant. La recherche porte aussi le propriétaire.

Les bases de données se parlent en SQL. La condition s'y écrit WHERE id = $1 AND owner_id = $2. Elle se lit « la facture qui a cet identifiant ET ce propriétaire ».

L'exemple garde les factures en mémoire, sans vraie base. La condition est la même :

// FindForOwner équivaut à :
//   SELECT id, owner_id, amount FROM invoices WHERE id = $1 AND owner_id = $2
func (s *Store) FindForOwner(id, ownerID string) (Invoice, error) {
    s.mu.RLock()
    defer s.mu.RUnlock()
    inv, ok := s.byID[id]
    if !ok || inv.OwnerID != ownerID { // la ligne du correctif
        return Invoice{}, ErrNotFound
    }
    return inv, nil
}

La ligne du correctif se lit ainsi. Si la facture n'existe pas, OU si elle appartient à quelqu'un d'autre, on répond « introuvable ». Les deux cas donnent exactement la même réponse. Une fonction jumelle, DeleteForOwner, porte la même condition pour la suppression.

Le code qui répond à une URL s'appelle une route.

Avant chaque route passe un middleware : un filtre qui lit le jeton et retrouve l'utilisateur connecté. La route reçoit cet utilisateur du middleware, jamais d'une valeur que Bob a tapée dans l'URL. Sinon, Bob n'aurait qu'à écrire « je suis Alice ».

mux.HandleFunc("GET /invoices/{id}", func(w http.ResponseWriter, r *http.Request) {
    inv, err := store.FindForOwner(r.PathValue("id"), currentUser(r))
    if errors.Is(err, ErrNotFound) {
        http.NotFound(w, r) // 404 : ne dit pas si la facture existe
        return
    }
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(inv)
})

Mettre le propriétaire dans la recherche vaut mieux que vérifier après coup. Vérifier après coup, c'est charger la facture, puis comparer son propriétaire à l'utilisateur connecté. Si quelqu'un oublie la comparaison dans une nouvelle route, la facture est envoyée à Bob. Avec FindForOwner, la fonction exige un propriétaire : un appel sans propriétaire ne compile même pas, Go refuse de construire le programme. Le code n'offre d'ailleurs aucune autre façon de chercher une facture. L'ordre des middlewares, quand il y en a plusieurs, est discuté dans mon article sur les middlewares Go en production.

Le test A-B avec httptest

Le test démarre l'API complète, middleware compris, grâce au paquet httptest de Go. Il envoie trois demandes, celles du schéma :

  1. Alice crée une facture et récupère son identifiant ;
  2. Alice redemande sa facture et doit recevoir 200 ;
  3. Bob demande la même facture et doit recevoir 404. S'il reçoit 200, le test échoue en nommant la faille.

Deux petites fonctions d'aide, non montrées, raccourcissent le code. createAsAlice fait l'étape 1. call envoie une demande avec le jeton d'Alice (token-alice) ou de Bob (token-bob) :

func TestBOLA_AB_Read(t *testing.T) {
    srv := newAPI()
    defer srv.Close()
    id := createAsAlice(t, srv) // A : POST /invoices, attend 201

    // Alice redemande sa facture. Sans cette étape, une API qui répond
    // 404 à tout le monde passerait le test pour de mauvaises raisons.
    if got := call(t, srv, "GET", "/invoices/"+id, "token-alice", "").StatusCode; got != http.StatusOK {
        t.Fatalf("A redemande : statut %d, attendu 200", got)
    }

    // B : Bob demande la facture d'Alice.
    got := call(t, srv, "GET", "/invoices/"+id, "token-bob", "").StatusCode
    if got == http.StatusOK {
        t.Fatalf("BOLA : Bob lit la facture d'Alice (200)")
    }
    if got != http.StatusNotFound {
        t.Fatalf("B lit : statut %d, attendu 404", got)
    }
}

Un second test joue la variante A-B-A : Bob tente une suppression, Alice redemande sa facture et doit toujours recevoir 200. Avec le correctif, les deux tests sont verts.

Un test de sécurité qui reste vert quand on casse le code ne prouve rien. Pour le vérifier, on casse le code exprès : on désactive la condition sur le propriétaire, dans FindForOwner et dans sa jumelle. Puis on relance les tests. Le résultat suivant est donc voulu :

--- FAIL: TestBOLA_AB_Read (0.00s)
    main_test.go:60: BOLA : Bob lit la facture d'Alice (200)
--- FAIL: TestBOLA_ABA_Delete (0.00s)
    main_test.go:74: B supprime : statut 204, attendu 404
FAIL

Les deux tests passent au rouge. Le premier nomme la faille. Dans le second, le 204 est le code de réponse « supprimé » : Bob a bel et bien effacé la facture d'Alice. On remet la condition, les tests redeviennent verts. Ce va-et-vient a été fait sur le code de l'article.

En Symfony : un Voter et un test A-B

Symfony est un framework PHP, une boîte à outils pour construire des applications web. Il range la vérification du propriétaire dans un Voter.

Un Voter répond oui ou non à une question : « cet utilisateur peut-il lire cette facture ? ». Dans le code ci-dessous, tout se joue sur la ligne return finale :

final class InvoiceVoter extends Voter
{
    public const VIEW = 'INVOICE_VIEW';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return $attribute === self::VIEW && $subject instanceof Invoice;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token, ?Vote $vote = null): bool
    {
        $user = $token->getUser();
        if (!$user instanceof User) {
            return false; // pas connecté : refus par défaut
        }

        /** @var Invoice $subject */
        return $subject->getOwner()?->getId() === $user->getId();
    }
}

Cette ligne compare le propriétaire de la facture à l'utilisateur connecté. Le reste du code est l'enrobage que Symfony demande à tout Voter, ici pour Symfony 7.3 ou plus.

La route appelle le Voter grâce à la ligne #[IsGranted] placée au-dessus d'elle. Au refus, Symfony répond 403 par défaut. L'option statusCode: 404 demande « introuvable », comme en Go :

#[Route('/invoices/{id}', methods: ['GET'])]
#[IsGranted(InvoiceVoter::VIEW, subject: 'invoice', statusCode: 404)]
public function show(Invoice $invoice): JsonResponse
{
    return $this->json(['id' => $invoice->getId(), 'amount_cents' => $invoice->getAmountCents()]);
}

Une différence avec le Go, à dire franchement. Le paramètre Invoice $invoice demande à Symfony d'aller chercher la facture par son identifiant, avant tout. Le Voter juge ensuite.

C'est donc la vérification après coup décrite plus haut. Son point fort : la vérification vit dans une seule classe, que chaque route appelle par son nom. Pour filtrer dès la recherche, il faudrait écrire soi-même une recherche par identifiant et propriétaire.

Le test rejoue la scène du test Go. WebTestCase démarre l'application. loginUser connecte Alice ou Bob sans passer par le formulaire de connexion :

public function testBobCannotReadAliceInvoice(): void
{
    $client = static::createClient();
    $users = static::getContainer()->get(UserRepository::class);
    $alice = $users->findOneBy(['email' => 'alice@example.test']);
    $bob = $users->findOneBy(['email' => 'bob@example.test']);

    // A : Alice crée une facture.
    $client->loginUser($alice);
    $client->jsonRequest('POST', '/invoices', ['amount_cents' => 12000]);
    self::assertResponseStatusCodeSame(201);
    $id = json_decode($client->getResponse()->getContent(), true)['id'];

    // Alice redemande sa facture : sans cette étape, un 404 partout passerait.
    $client->request('GET', '/invoices/'.$id);
    self::assertResponseIsSuccessful();

    // B : Bob demande la même facture.
    $client->loginUser($bob);
    $client->request('GET', '/invoices/'.$id);
    self::assertResponseStatusCodeSame(404); // introuvable, pas 200 ni 403
}

Le test suppose qu'Alice et Bob existent déjà dans la base de test. Il n'a pas été lancé contre une vraie application : seule sa syntaxe est vérifiée.

La preuve par le rouge n'existe donc que pour le Go. La route de création n'est pas montrée. La variante A-B-A demanderait une route de suppression, non écrite ici.

Un test par route plutôt qu'une revue de code

La tentation : compter sur la revue de code, quand un collègue lit les changements avant leur mise en ligne. Le correctif tient en une ligne, donc l'oubli aussi. Le collègue lit ce qui est écrit. Une ligne qui manque, il ne la voit pas.

Mon article sur l'audit de sécurité en passes successives posait la même question. Pendant un audit, chaque faille relevée s'appelle un « finding ». L'article ne gardait en test permanent que ce qui répondait oui à cette question :

Est-ce qu'un changement de code futur, fait de bonne foi par un développeur qui ne connaît pas ce finding, pourrait réintroduire la vulnérabilité ?

Pour BOLA, c'est oui à chaque nouvelle route qui prend un identifiant. Un développeur ajoute GET /invoices/{id}/pdf, va chercher la facture par son identifiant et oublie le propriétaire.

Un commit est un enregistrement de changements dans le code. Le même article le résume en deux phrases : « Le rapport se lit une fois. Les tests tournent à chaque commit. » L'OWASP demande la même chose : des tests sur l'autorisation, et aucune mise en ligne d'un changement qui les fait échouer.

En pratique :

  • chaque route qui prend un identifiant reçoit son test A-B ;
  • si cette route peut modifier ou supprimer la facture, elle reçoit aussi le test A-B-A.

Le test se recopie d'une route à l'autre, seules l'URL et les données envoyées changent. La fiche de lecture de Defending APIs détaille le reste du livre, et celle de Web Application Security couvre le côté attaquant.

Conclusion

À ma lecture, BOLA reste en tête du classement parce que la faille ne laisse aucune trace dans la réponse. Un 200 sur la facture d'Alice a exactement la même forme qu'un 200 sur la facture de Bob. La différence n'est pas dans la réponse : elle est dans l'identité de celui qui a demandé.

Un outil qui ne regarde qu'une réponse à la fois ne peut donc pas voir la faille. Le test A-B, lui, pose la question à chaque commit : qui a créé cette facture, et qui la demande ? Il suffit de deux comptes pour la poser.

Dans le grand livre du développeur

Un livre gratuit où chaque idée est racontée une seule fois, à sa place, du silicium au jugement humain.

Commentaires (0)