Aller au contenu principal

Une boucle for contre 11,7 millions de Français : autopsie d'une faille IDOR.

Analysez la faille IDOR exploitée chez l'ANTS. Comprenez cette vulnérabilité critique et découvrez comment la prévenir efficacement avec Symfony et PHP pour sécuriser vos applications web.

Pierre 11 min de lecture
Sommaire · 9 0%

Des erreurs, tout le monde en fait, et moi le premier. Un retour de fonction mal typé, un paramètre qu'on a oublié de rendre nullable, une casse incorrecte sur un nom de fichier. Des bourdes qu'une QA sérieuse rattrape — sauf un jour de distraction, un collègue qui vous interrompt, un hot fix à pousser en urgence : la 500 part en prod. Sur le coup, on voudrait que la foudre nous frappe et disparaître sous terre. Avec le temps, on en rit et l'anecdote forge l'expérience.

Sauf qu'ici, on est très loin de la blague de bureau. Une 500 sur un blog se patche en dix minutes et personne n'en meurt. Une URL qui fuite douze millions de comptes citoyens reste sept mois en l'air et personne ne dort tranquille. La différence d'échelle change tout. Et elle se code à un endroit précis : dans la vérification d'autorisation qu'un développeur a oublié d'écrire, et qu'aucun test n'est venu réclamer.

Le 15 avril 2026, le portail moncompte.ants.gouv.fr est compromis. Un mineur de 15 ans exfiltre 11,7 millions de comptes — peut-être 19 millions selon les enchères du marché noir. La méthode tient en une phrase : il a changé un chiffre dans une URL. L'auteur lui-même qualifie la faille de « vraiment stupide ».

Le scandale politique sera traité ailleurs, et mieux. Ce billet fait deux choses, et deux seulement : expliquer pourquoi cette fuite est problématique en 2026, et montrer ligne à ligne comment on l'évite quand on développe avec Symfony.

Anatomie de la faille IDOR

On ne connaît pas le code de l'ANTS, et on n'a pas à le connaître pour caractériser la faille. La signature d'IDOR est dans l'URL, pas dans les sources : un identifiant numérique séquentiel, modifiable, qui change la ressource retournée. Tout ce qui suit part de ce seul fait observable.

GET /api/account/{id}

  1. utilisateur authentifié ?       → sinon, 401
  2. account ← SELECT * FROM accounts WHERE id = {id}
  3. return account en JSON

Trois étapes. Une vérification de session, une requête par identifiant, une réponse. Et un trou béant entre la 1 et la 2.

L'utilisateur est authentifié — l'étape 1 le garantit. Il a le droit d'appeler cet endpoint — la session est valide, le cookie est signé, tout est en règle au niveau « qui es-tu ». Mais rien, absolument rien, entre la lecture du paramètre et la requête en base, ne vérifie que {id} correspond à son compte. Un utilisateur connecté qui passe /api/account/1, /api/account/2, /api/account/3reçoit en retour les comptes de personnes qu'il n'a jamais rencontrées. C'est exactement ça, IDOR. Un objet, une référence directe, aucune vérification d'autorisation entre les deux.

L'attaquant derrière la fuite ANTS n'a pas eu besoin d'un exploit, d'une CVE, d'un hash retourné. Il a eu besoin d'une boucle for. C'est ce qu'il qualifie lui-même de « vraiment stupide », et il a raison.

Le pattern existe à l'identique en PHP, Python, Node, Java, Go, Ruby. Le framework ne fait rien à l'affaire : le problème n'est pas dans la stack, il est dans l'absence d'une étape entre l'authentification et la lecture en base — la vérification que cet utilisateur a le droit d'accéder à cette ressource précise. C'est la couche autorisation, et c'est elle qui manquait.

Avant même de parler de comment on l'implémente correctement, il faut dire ceci : pour un endpoint « mon compte », ce type de pattern n'aurait jamais dû exister. L'ID n'a rien à faire dans l'URL — on y revient plus bas. Mais admettons qu'on doive exposer une ressource paramétrée par identifiant, parce que c'est parfois légitime (un document partagé, une ressource d'admin). Voilà comment on l'autorise correctement.

Ce qui ne suffit pas : durcir la session

On va t'opposer la session. Il faut la durcir, oui :

YAML
framework:
    session:
        cookie_secure: auto # le cookie ne part qu'en HTTPS.
        cookie_samesite: lax # 'strict' si pas de flux entrant tiers
        cookie_httponly: true
        handler_id: null
        gc_maxlifetime: 7200

Tout ça est nécessaire et rigoureusement sans effet sur la fuite ANTS : une session impeccable autour d'un endpoint sans contrôle d'autorisation, c'est une porte blindée sur une cloison de placo.

Le cœur du sujet : l'autorisation par ressource

Trois patterns, du moins bon au meilleur. Tous les trois marchent. Le troisième est celui à prendre par défaut.

La vérification manuelle dans le Controller

PHP
#[Route('/api/account/{id}', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function show(int $id, AccountRepository $repo): JsonResponse
{
    $account = $repo->find($id);

    if ($account === null || $account->getOwner() !== $this->getUser()) {
        throw $this->createAccessDeniedException();
    }

    return $this->json($account);
}

Tant qu'il n'y a qu'un Controller à maintenir et qu'il reste sous tes yeux, ça fonctionne. La faille arrive plus tard, quand quelqu'un dupliquera la méthode pour une ressource voisine — un sous-compte, un document, peu importe — et que dans le copier-coller, la requête sera adaptée mais pas la vérification d'owner.

En revanche, il y a un piège supplémentaire bien plus vicieux. L'attribut #[IsGranted('ROLE_USER')] ressemble à un dispositif de sécurité — c'est précisément son rôle d'y ressembler. Mais il ne fait qu'attester d'un rôle, jamais d'une propriété. En revue, ça suffit souvent : il y a quelque chose en haut du Controller qui parle de sécurité, le reviewer regarde la logique métier, il merge. C'est sorti en prod comme ça plus de fois que je ne voudrais l'admettre.

Le Voter Symfony

Le Voter centralise la logique d'autorisation dans une classe dédiée, testable, réutilisable, qu'on appelle depuis l'attribut #[IsGranted] avec un sujet.

PHP
namespace App\Security\Voter;

use App\Entity\Account\Account;
use Symfony\Component\Security\Core\Authentication\Token\TokenInterface;
use Symfony\Component\Security\Core\Authorization\Voter\Voter;

final class AccountVoter extends Voter
{
    public const string VIEW = 'ACCOUNT_VIEW';
    public const string EDIT = 'ACCOUNT_EDIT';
    public const string DELETE = 'ACCOUNT_DELETE';

    protected function supports(string $attribute, mixed $subject): bool
    {
        return \in_array($attribute, [self::VIEW, self::EDIT, self::DELETE], true)
            && $subject instanceof Account;
    }

    protected function voteOnAttribute(string $attribute, mixed $subject, TokenInterface $token): bool
    {
        \assert($subject instanceof Account);
        $user = $token->getUser();

        return match ($attribute) {
            self::VIEW, self::EDIT, self::DELETE => $subject->getOwner() === $user,
        };
    }
}

Et dans le Controller :

PHP
#[Route('/api/account/{id}', methods: ['GET'])]
#[IsGranted(AccountVoter::VIEW, subject: 'account')]
public function show(Account $account): JsonResponse
{
    return $this->json($account);
}

L'attribut subject: 'account' est la clé. C'est ce mot, ces huit caractères, qui transforment un contrôle d'authentification en contrôle d'autorisation. Sans subject#[IsGranted] ne vérifie qu'un rôle. Avec subject, il appelle le Voter, qui voit la ressource concrète, et qui peut donc se prononcer.C'est probablement ce qu'il manquait à l'ANTS. Pas un Voter complet — juste les neuf caractères de subject:.

MapEntity avec scope d'autorisation

L'idée, plus propre encore : ne jamais hydrater une entité que l'utilisateur courant n'a pas le droit de voir. Si le résolveur ne trouve pas, c'est un 404, pas un 403 — et on ne fuite même pas l'existence de la ressource.

PHP
#[Route('/api/account/{id}', methods: ['GET'])]
#[IsGranted('ROLE_USER')]
public function show(
    #[MapEntity(expr: 'repository.findOneByIdAndOwner(id, user)')]
    Account $account,
): JsonResponse {
    return $this->json($account);
}

Et dans le repository :

PHP
public function findOneByIdAndOwner(int $id, User $owner): ?Account
{
    return $this->findOneBy(['id' => $id, 'owner' => $owner]);
}

Trois bénéfices, dans cet ordre :

  1. Aucune fuite d'information. Un attaquant qui boucle sur les IDs reçoit 404 Not Found pour tous les comptes qui ne lui appartiennent pas. Il n'apprend ni qu'ils existent, ni qu'il n'a pas le droit. La différence entre 403 et 404 est minuscule pour vous, énorme pour un scanner automatique.
  2. L'autorisation est dans la requête SQL. Pas dans une couche au-dessus. Ce n'est pas une vérification qui peut être oubliée après le find — c'est un filtre qui rend le find sans danger.
  3. Le Controller ne sait plus rien de la sécurité. Il prend un Account, la sérialise. Toute la logique d'autorisation est dans une seule requête, dans un seul repository, testable en unitaire avec une fixture.

La règle qui aurait suffi : pas d'ID dans l'URL

IDOR n'existe que si on lui donne une prise. Et la prise, c'est l'identifiant exposé.

Pour les ressources possédées par l'utilisateur authentifié — mon compte, mon profil, mes documents, mes commandes — l'identifiant n'a rien à faire dans l'URL. Il est déjà dans la session. Le Controller le récupère via #[CurrentUser], point.

Pas de {id}. Pas de find($id). Pas de Voter à écrire pour cet endpoint, parce qu'il n'y a structurellement rien à autoriser : on ne peut accéder qu'à son propre compte. Si l'URL ne contient pas d'ID, il n'y a rien à incrémenter, rien à fuzzer, rien à exfiltrer. La faille n'a pas de surface.

C'est exactement le Controller que l'ANTS aurait dû avoir. moncompte.ants.gouv.fr — le sous-domaine le dit littéralement : Mon compte ; au singulier ; le mien. Il n'y avait aucune raison d'exposer un endpoint paramétré par identifiant pour une ressource dont la définition même est « celle de l'utilisateur connecté ».

Et pour les ressources qui nécessitent vraiment un identifiant dans l'URL ? Un article public, un document partagé, une ressource d'admin. Là, l'identifiant ne doit jamais être l'ID auto-incrémenté de la base. Slug pour les contenus publics, UUID v7 ou ULID pour le reste. L'ID interne reste interne — c'est une clé de jointure, pas une URL. Un attaquant qui boucle sur des ULID a une espérance de succès nulle ; un attaquant qui incrémente un entier a une espérance de succès égale à 1.

Le principe à retenir tient en une phrase : un ID exposé est une dette de sécurité. Soit il n'a rien à faire dans l'URL parce que la ressource est implicite, soit il faut le remplacer par un identifiant pensé pour l'exposition.

Les tests qui auraient sauvé l'ANTS

PHP
namespace App\Tests\Functional\Account;

use App\Factory\Account\AccountFactory;
use App\Factory\User\UserFactory;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Zenstruck\Foundry\Test\ResetDatabase;

final class AccountAccessControlTest extends WebTestCase
{
    use ResetDatabase;

    public function testUserCannotAccessAnotherUserAccount(): void
    {
        $alice = UserFactory::createOne();
        $bob = UserFactory::createOne();
        $bobAccount = AccountFactory::createOne(['owner' => $bob]);

        $client = static::createClient();
        $client->loginUser($alice->_real());

        $client->request('GET', '/api/account/'.$bobAccount->getId());

        self::assertResponseStatusCodeSame(404);
    }
}

Huit lignes utiles. Il aurait empêché la fuite ANTS. Il n'a pas été écrit. Et la raison n'est pas technique — WebTestCase existe depuis Symfony 2.x, loginUser() depuis la 5.1, Foundry depuis longtemps. La raison est culturelle : les tests d'autorisation ne sont pas des tests « de feature », ce sont des tests « de non-feature ». Ils ne valident pas qu'on peut faire quelque chose, ils valident qu'on ne peut pas faire quelque chose. Et personne n'a envie d'écrire des tests négatifs quand le sprint est serré.

Insister là-dessus : chaque endpoint qui prend un identifiant en paramètre mérite ce test. Pas un test ad hoc pour les endpoints sensibles — un pattern, systématique, sur tout endpoint qui prend un ID. Vous pouvez même le générer automatiquement avec un data provider qui itère sur tous les couples (utilisateur autorisé, utilisateur intrus, ressource).

PHP
/**
 * @return iterable<string, array{string, string, int}>
 */
public static function unauthorizedAccessProvider(): iterable
{
    yield 'GET /api/account/{id}'  => ['GET',  '/api/account/%d',  404];
    yield 'PUT /api/account/{id}'  => ['PUT',  '/api/account/%d',  404];
    yield 'GET /api/document/{id}' => ['GET',  '/api/document/%d', 404];
    // ... un yield par endpoint qui prend un ID
}

#[DataProvider('unauthorizedAccessProvider')]
public function testIntruderCannotAccessResource(string $method, string $urlPattern, int $expectedStatus): void
{
    // setup intrus + ressource d'autrui, puis assertion
}

Le mot de la fin

La sécurité ne s'ajoute pas à un système après l'incident, elle s'y inscrit avant. Au moment où on dessine la matrice d'autorisation sur un coin de papier, où on tranche entre un entier auto-incrémenté et un ULID, où on écrit — ou pas — le premier WebTestCase qui vérifie qu'Alice n'accède pas au compte de Bob, où on relit une PR à 18h un vendredi en se disant que ça ira. Ce sont ces moments-là, presque toujours sans relief, qui font la différence entre un service qui tient et un service qu'on patche à 200 millions d'euros une fois la fuite passée au 20 heures.

L'ANTS n'est pas une anomalie, c'est un symptôme. Celui d'une culture dans laquelle l'autorisation par ressource est encore traitée comme un raffinement, alors que c'est l'os même du métier. Tant qu'elle le restera, chaque API qui part en prod avec des IDs séquentiels en URL et sans test d'intrusion croisée prépare la prochaine fuite — y compris les miennes, parfois, quand je relis avec l'œil d'aujourd'hui des Controllers écrits quatorze ans plus tôt. Si après ce billet vous ouvrez votre repository pour y chercher les find($id) qui n'ont pas un getOwner() à dix lignes près, il aura servi à quelque chose.

Cet article vous a-t-il aidé ?

Vos réactions ne sont pas encore enregistrées — bientôt disponible.

Activez uniquement ce que vous souhaitez. Vos choix sont conservés 6 mois.

Strictement nécessaires

Indispensables au fonctionnement du site (session, sécurité, préférence d'affichage). Aucune donnée n'est partagée à des tiers et aucun consentement n'est requis.

Toujours actif

Mesure d'audience

Statistiques anonymes via Umami Cloud (hébergement UE) : pages vues, source du trafic, navigateur. Pas de cookie tiers, pas de profilage, pas de partage commercial.