Aller au contenu
English

Exemples de menaces

  • Leçon 3
  • 50 min
  • Module 1 : Bases de l'identité et des autorisations

Ce que vous saurez faire

Voyez ce qui arrive quand l’API vérifie seulement une clé partagée. Une personne lit le billet de soutien d’une autre organisation en devinant son identifiant. Une personne en lecture seule modifie un billet. Vous essayez les deux sur votre propre API d’exercice, avec des données fictives.

Avant de commencerCe qu’il faut savoir et avoir sous la mainEnviron 50 minutes

Ce que vous devez savoir

Votre projet

  • ~/projects/ticket-api à l’instantané end-of-m01 : les deux commerces sont chargés, 154 passed, 1 deselected.

Sans danger et en local. Chaque tentative de cette leçon vise l’API d’exercice sur votre ordinateur, avec des données fictives. N’essayez jamais ces requêtes sur un système qui ne vous appartient pas, ou sans autorisation écrite.

Imaginez le vestiaire d’un théâtre. Vous donnez votre manteau et recevez un reçu portant le numéro 41. Après le spectacle, vous montrez le reçu, et le préposé vous apporte le manteau 41.

Imaginez maintenant un préposé paresseux. Il ne regarde pas votre reçu. Il demande seulement : « Quel numéro? » Vous dites « 42 », et il vous apporte le manteau d’une autre personne. Personne n’a rien brisé. Le préposé n’a pas vérifié que le manteau 42 vous appartient.

Le même théâtre a une porte avec une affiche : « Personnel seulement ». La porte n’est pas verrouillée. La plupart des visiteurs respectent l’affiche. Un visiteur curieux ouvre quand même la porte, et rien ne l’arrête.

L’API de billets a ces deux problèmes aujourd’hui. Dans cette leçon, vous observez chacun avec de vraies requêtes.

Sam travaille chez Larkfield. Le nouveau commerce l’intrigue. Larkfield numérote ses billets de T-30001 à T-30200. Les billets de Bramble ont donc probablement des numéros semblables. Sam essaie T-40001.

  1. Démarrez l’API avec la clé, dans un deuxième terminal où l’environnement virtuel est actif :

    Fenêtre de terminal
    API_KEY=local-dev-key fastapi dev

    Sur Windows, définissez d’abord $env:API_KEY = "local-dev-key", puis exécutez fastapi dev.

  2. Dans le premier terminal, envoyez la requête de Sam. Sur Windows, tapez curl.exe :

    Fenêtre de terminal
    curl -H "X-API-Key: local-dev-key" http://127.0.0.1:8000/v1/tickets/T-40001
    {"ticket_id":"T-40001","customer_id":"B-20101","customer_name":"Eleanor Price",
    "subject":"Charged twice for order BB-310729",
    "body":"My card was charged two times for order BB-310729, 47.50 dollars each time. Please refund one of the payments.",
    "team":"billing","priority":1,"status":"open",
    "attachments":[{"attachment_id":"e7e54964-6167-47fa-8c59-b0b14b2d7e4a","file_name":"receipt-BB-310729.pdf","size_bytes":724, …}], …}

    La réponse est raccourcie ici. Sam a maintenant le nom d’un client de Bramble, un numéro de commande, un montant et l’identifiant de son reçu.

  3. Essayez un numéro qui n’existe pas :

    Fenêtre de terminal
    curl -H "X-API-Key: local-dev-key" http://127.0.0.1:8000/v1/tickets/T-49999
    {"error":{"code":"not_found","message":"Ticket T-49999 does not exist.","request_id":"67f184db04b6","fields":[]}}

Les deux réponses diffèrent. Une personne curieuse peut donc essayer T-40001, T-40002, et ainsi de suite, pour lister tous les billets existants. Les identifiants sont faciles à deviner, car ils suivent une séquence.

Sam, membre du personnel de Larkfield, envoie GET /v1/tickets/T-40001 avec X-API-Key : local-dev-key. ticket-api vérifie si la clé est correcte : oui. La requête SELECT … FROM tickets WHERE ticket_id = 'T-40001' trouve le billet de Bramble Books, Charged twice for order BB-310729 (facturé deux fois pour la commande BB-310729). L'API renvoie le billet complet à Sam avec 200 OK. Un cadre rouge pointillé au-dessus de l'API indique la question que personne n'a posée : T-40001 appartient-il à une organisation dont Sam est membre?
L'API vérifie la clé, puis cherche l'identifiant. Rien ne vérifie si le billet appartient à une organisation dont Sam est membre.
Description textuelle du diagramme

Sam envoie GET /v1/tickets/T-40001 avec la clé partagée. L’API vérifie seulement que la clé est correcte. Elle exécute ensuite SELECT … FROM tickets WHERE ticket_id = 'T-40001', trouve le billet de Bramble Books et le renvoie avec 200 OK (succès). Il manque la vérification : « T-40001 appartient-il à une organisation dont Sam est membre? » Personne ne pose cette question.

Ce défaut porte un nom. L’API permet à un appelant d’accéder à un objet, ici un billet, auquel il n’a pas droit. C’est un défaut d’accès au niveau des objets. Les listes de sécurité l’appellent BOLA, Broken Object Level Authorization (autorisation défaillante au niveau des objets). Il arrive premier dans le classement OWASP API Security Top 10 (2023), une liste des risques d’API les plus courants. Comme le préposé paresseux, l’API fait confiance au numéro donné par l’appelant.

Tomás propose une solution : « Utilisez des identifiants aléatoires, comme pour les pièces jointes. Personne ne peut les deviner. » Regardez de nouveau l’étape 2. L’identifiant de la pièce jointe est aléatoire, e7e54964-…, et la réponse du billet le révèle. Un identifiant aléatoire rend les tentatives plus difficiles, mais ce n’est pas une vérification. Un identifiant peut être révélé par des liens, des journaux (logs) et des captures d’écran. L’appelant suivant obtient alors l’enregistrement. La seule solution est de vérifier, pour chaque enregistrement, qu’il appartient à une organisation dont l’appelant est membre.

Omar a un accès en lecture seule chez Larkfield. Imaginez que la page Web du service de soutien cache le bouton « Reply » (répondre) aux personnes en lecture seule. Omar n’a pas besoin de la page. Il envoie lui-même la requête :

Fenêtre de terminal
curl -X POST -H "X-API-Key: local-dev-key" -H "Content-Type: application/json" -d '{"author": "agent", "body": "Refund approved."}' http://127.0.0.1:8000/v1/tickets/T-30002/messages
{"message_id":390,"author":"agent","body":"Refund approved.","created_at":"2026-10-10T06:10:54.369461Z"}

Dans PowerShell, placez le corps dans un fichier, msg.json, et utilisez -d "@msg.json". L’API a enregistré le message : 201 Created (créé). Un client lit maintenant « Refund approved. » (remboursement approuvé), venant d’une personne qui ne peut rien approuver.

Deux chemins. Par la page : la page d'Omar, en lecture seule, montre le billet T-30002 sans bouton Reply (répondre). Aucune requête n'est donc envoyée. Sans passer par la page : le terminal d'Omar envoie curl -X POST …/T-30002/messages; ticket-api vérifie si la clé est correcte, oui; 201 Created, le message est enregistré. Une ligne indique : le bouton est dans le navigateur, la règle doit être dans l'API.
Un bouton caché arrête seulement les personnes qui utilisent la page. Une requête qui contourne la page rencontre seulement les vérifications de l'API.
Description textuelle du diagramme

Par la page, Omar voit le billet T-30002 sans bouton Reply, donc il n’envoie aucune requête. Sans passer par la page, Omar envoie la même requête POST depuis son terminal. L’API vérifie seulement la clé. Elle enregistre donc le message et répond 201 Created. Le bouton fait partie de la page dans le navigateur. La règle doit être dans l’API, car chaque requête atteint l’API.

C’est une élévation de privilèges : une personne fait quelque chose que son rôle ne permet pas. Ici, une personne en lecture seule agit comme un membre du personnel. La liste OWASP appelle le défaut de l’API Broken Function Level Authorization (autorisation défaillante au niveau des fonctions). L’API ne vérifie pas si cet appelant peut utiliser cette fonction.

Tomás a déjà dit : « On a caché le bouton de suppression, donc les personnes en lecture seule ne peuvent pas supprimer. » Vous pouvez maintenant lui répondre. Un bouton caché facilite la vie de l’utilisateur : il cache ce que celui-ci ne peut pas utiliser. Ce n’est jamais la protection. L’API doit vérifier le rôle sur le serveur, pour chaque requête.

Certains défauts ne demandent même pas de deviner. Ines est propriétaire de Bramble Books. Elle liste les billets « de son commerce » :

Fenêtre de terminal
curl -H "X-API-Key: local-dev-key" "http://127.0.0.1:8000/v1/tickets?limit=6"

Les six billets les plus récents sont renvoyés, provenant des deux commerces et mélangés selon l’heure :

T-40040 B-20102 Change the delivery address of BB-310461
T-30200 C-0025 wooden garden bench stopped working
T-30199 C-0027 Change delivery address
T-30198 C-0038 Merge two accounts
T-40039 B-20110 Where is my order BB-310304?
T-30197 C-0029 Payment failed but money taken

Chaque ligne montre seulement trois champs ici. La liste ne précise aucune organisation, donc elle renvoie tout. La recherche de billets semblables a le même problème. Ines demande des billets semblables à son T-40001. Deux des cinq résultats viennent de Larkfield : T-30050 et T-30076, des billets « Charged twice » (facturé deux fois) du commerce de jardinage. Les vecteurs sont proches parce que les problèmes sont les mêmes, et rien ne sépare les commerces.

Le cours sur les bases de données présentait une injection qui brisait une requête construite avec des chaînes, dans Requêtes sûres. Exécutez de nouveau sa démonstration :

Fenêtre de terminal
python -m scripts.injection_demo "x' OR '1'='1"
unsafe (string building):
SQL sent: SELECT customer_id, name, email FROM customers WHERE email = 'x' OR '1'='1'
52 row(s), first: ('C-0001', 'Olga Olsen', 'olga.olsen@example.com')
safe (parameter):
SQL sent: SELECT customer_id, name, email FROM customers WHERE email = %s value sent separately: "x' OR '1'='1"
0 row(s)

Dans le cours sur les bases de données, elle renvoyait les 40 clients de Larkfield. Maintenant, elle en renvoie 52 : les clients des deux commerces. Un bogue qui révélait les données d’un commerce en révèle maintenant deux. La version sûre avec un paramètre renvoie toujours 0 ligne. Arrêtez l’API avec Ctrl+C.

Le projet contient un script avec cinq tentatives fictives. Il envoie chaque requête au code de l’API, avec la clé, et évalue la réponse. Il n’a pas besoin d’un serveur en marche.

Fenêtre de terminal
python -m scripts.attempts
Made-up attempts against your own practice API. Every request is sent the same way
(with the API key, if one is set): the API cannot tell who is calling.
1. Sam (staff at Larkfield) tries to read Bramble Books' ticket T-40001 by guessing its ID.
GET /v1/tickets/T-40001 -> 200 (should get: 404)
LEAK: "Charged twice for order BB-310729"
2. Ines (owner at Bramble Books) tries to list the tickets of her own shop.
GET /v1/tickets?limit=100 -> 200 (should get: only bramble)
LEAK: 77 of 100 tickets are Larkfield's, e.g. T-30200
3. Omar (read-only at Larkfield) tries to add a message to T-30002.
POST /v1/tickets/T-30002/messages -> 201 (should get: 403)
ESCALATION: "(attempt: a read-only member writes)"
4. Tomás (no membership) tries to read Larkfield's ticket T-30002.
GET /v1/tickets/T-30002 -> 200 (should get: 404)
LEAK: "Charged twice for order LK-182074"
5. Ines (owner at Bramble Books) tries to find tickets similar to her ticket T-40001.
GET /v1/tickets/T-40001/similar?k=5 -> 200 (should get: only bramble)
LEAK: 2 of 5 tickets are Larkfield's, e.g. T-30050
5 of 5 attempts got what they should not get.

L’API affiche aussi ses propres lignes de journal entre les résultats. Pour voir seulement le rapport, ajoutez 2>/dev/null sur macOS et Linux, ou 2>$null dans PowerShell.

Les cinq tentatives obtiennent ce qu’elles ne devraient pas obtenir. Chacune échoue à l’une des trois questions :

Tentative Résultat attendu Résultat obtenu La question à laquelle l’API ne peut pas répondre
1. Sam lit T-40001 404 200 Dans quelle organisation? Sam n’est pas membre de Bramble.
2. Ines liste les billets Ceux de Bramble seulement 77 sur 100 sont ceux de Larkfield Dans quelle organisation? La liste n’en précise aucune.
3. Omar écrit 403 201 Que peut-il faire? read_only ne peut pas écrire.
4. Tomás lit T-30002 404 200 Qui est l’appelant? Tomás n’a aucune adhésion.
5. Billets semblables d’Ines Ceux de Bramble seulement 2 sur 5 sont ceux de Larkfield Dans quelle organisation? La recherche n’a aucun filtre.

Les réponses attendues viennent directement de la matrice d’accès. Utilisez l’explorateur pour les vérifier. Il commence là où la tentative 3 a échoué.

Explorer

Ce que l'API aurait dû répondre

Rôle dans cette organisation
read_only
L’API répond
403 role_cannot
La règle
read_only may not ticket.write

Omar a le rôle read_only chez Larkfield, et la règle dit « read_only may not ticket.write ». L’API répond 403. Un membre peut déjà voir les billets de l’organisation, donc un 403 ne révèle rien de nouveau.

  • 200 : permis
  • 403 : un membre, mais le rôle ne le permet pas
  • 404 : pas un membre, rien n’est montré
Omar : chaque action dans les deux organisations
ActionLarkfield
read_only
Bramble Books
aucune adhésion
ticket.readlister et lire les billets, trouver des billets semblables200 oui404
ticket.writeajouter un message à un billet403 (choisi)404
file.readobtenir un lien de téléchargement pour une pièce jointe200 oui404
file.uploadtéléverser une pièce jointe403404
document.readchercher dans les documents d’aide publics200 oui404
document.read_staffaussi les documents réservés au personnel403404
member.readlister les membres de l’organisation403404
member.manageinviter, changer un rôle, retirer un membre403404
export.createlancer une exportation des billets en arrière-plan403404
audit.readlire les événements d’audit de l’organisation403404

Chaque réponse vient de la fonction decide() du projet, dans ticket_api/access.py, affichée par python -m scripts.access_matrix --json. « 200 » veut dire permis : une requête qui crée un enregistrement, comme un nouveau message, répond 201. Les personnes sont fictives.

La matrice d'accès du projet (decide() dans ticket_api/access.py) pour chaque personne, organisation et action. L'API actuelle répond 200 ou 201 aux cinq tentatives.
Description textuelle de l’explorateur

L’explorateur a trois groupes de boutons : une personne, une organisation et une action. Il montre le rôle de la personne dans cette organisation, la réponse de la matrice d’accès (200, 403 ou 404) et la règle. Un tableau montre la ligne complète de cette personne dans les deux organisations.

Il commence avec Omar, Larkfield, ticket.write (écrire dans un billet). Omar est read_only, la règle dit « read_only may not ticket.write » (lecture seule ne permet pas d’écrire dans un billet), et la réponse est 403. L’API actuelle a répondu 201. Sam n’a aucune adhésion chez Bramble Books, donc ticket.read (lire un billet) répond 404; l’API actuelle a répondu 200. Tomás obtient 404 partout. Camille obtient 200 pour ticket.read chez Bramble Books, mais 403 pour ticket.write dans cette organisation.

Kwame relie chaque défaut au travail à venir :

Élément manquant Tentatives corrigées Où dans le cours
Connaître la personne : un utilisateur connecté avec un jeton vérifié, pas une clé partagée 4, et toutes les autres en ont besoin Comprendre la connexion et les jetons et Sessions et intégration à l’application
Vérifier l’adhésion et le rôle sur le serveur pour chaque route, sous /v1/tenants/{tenant}/… 1, 3, 4 L’autorisation dans l’API
Rendre l’organisation obligatoire dans chaque requête, recherche et lien de fichier 1, 2, 5 Requêtes sûres pour chaque locataire et Accès aux fichiers et à la récupération
Tester chaque personne sur chaque route dans les deux commerces toutes, durablement Tests négatifs

Tomás propose un autre raccourci : « Que le navigateur envoie un en-tête, X-Tenant-ID: bramble, et qu’on filtre avec ça. » Réfléchissez-y, sans code. Sam change un en-tête, et l’API lui montre les billets de Bramble. L’en-tête répond à « quelle organisation l’appelant veut-il », pas à « quelle organisation peut-il voir ». L’API doit répondre à la deuxième question à partir de sa propre table d’adhésions. Les tentatives de fin de cours envoient exactement cet en-tête falsifié, et l’API le refuse.

Ces tentatives valent la peine dès le premier jour de toute application partagée, avant l’arrivée de vrais clients. Elles coûtent peu : un script et quelques secondes. Le vrai coût vient des corrections, qui changent chaque route et chaque requête. C’est pourquoi le cours y consacre trois modules.

Erreur courante

Chaque exécution ajoute un autre message

Symptôme : chaque exécution de python -m scripts.attempts ajoute un message au billet T-30002. Après python -m scripts.load --reset et deux exécutions, il a trois messages au lieu d’un. Vérifiez dans psql :

SELECT message_id, author, body FROM messages WHERE ticket_id = 'T-30002' ORDER BY message_id;
message_id | author | body
------------+--------+---------------------------------------------------
2 | agent | Could you send the receipt or a screenshot of …
390 | agent | (attempt: a read-only member writes)
391 | agent | (attempt: a read-only member writes)
(3 rows)

Le premier message est raccourci ici.

Cause : la tentative 3 réussit actuellement, donc chaque exécution écrit vraiment un message. Vos identifiants de messages peuvent différer.

Correction : exécutez python -m scripts.load --reset. T-30002 a de nouveau un seul message. Après le module 4, l’API refuse la tentative et n’écrit rien.

Réinitialisation. python -m scripts.load --reset supprime tous les messages que vous avez ajoutés dans cette leçon. Si votre projet ne fonctionne plus, copiez de nouveau l’instantané end-of-m01, comme dans Utilisateurs et organisations.

Vérifiez votre compréhension

Expliquez la différence entre un défaut d'accès au niveau des objets (BOLA) et une élévation de privilèges. Donnez un exemple de chacun tiré de cette leçon.

Afficher la réponse

Un défaut d’accès au niveau des objets permet à un appelant d’accéder à un enregistrement auquel il n’a pas droit. Sam lit le billet T-40001 de Bramble en devinant son identifiant. Une élévation de privilèges permet à un appelant de faire une action que son rôle ne permet pas. Omar, en lecture seule, ajoute un message à T-30002. Le premier échoue à « dans quelle organisation? » pour un enregistrement. Le deuxième échoue à « que peut-il faire? ».

Vérifiez votre compréhension

Prédisez : Larkfield remplace chaque identifiant de billet par un UUID aléatoire, sans rien changer d'autre. La tentative 1 réussit-elle encore? Pourquoi?

Afficher la réponse

Elle réussit encore pour tout identifiant que Sam découvre, car l’API vérifie toujours seulement la clé. Les identifiants aléatoires sont plus difficiles à deviner. Mais ils peuvent être révélés par des liens, des journaux, des captures d’écran et d’autres réponses. L’identifiant de pièce jointe dans la réponse de T-40001 le montre. La correction consiste à vérifier que l’enregistrement appartient à une organisation dont l’appelant est membre.

Vérifiez votre compréhension

Vérification du module : Camille est membre du personnel chez Larkfield et en lecture seule chez Bramble Books. Pour chaque requête, donnez la réponse de la matrice d'accès (200, 403 ou 404) et la raison : (a) lire le billet T-40001 de Bramble par l'organisation Bramble; (b) y ajouter un message à T-40001; (c) ajouter un message au billet T-30002 de Larkfield; (d) inviter un nouveau membre chez Larkfield; (e) lire T-40001 par l'organisation Larkfield.

Afficher la réponse

(a) 200 : chez Bramble Books, Camille est read_only, et read_only permet ticket.read. (b) 403 : read_only ne permet pas ticket.write. Camille est membre et voit déjà le billet, donc un 403 ne révèle rien de nouveau. (c) 200, et l’API répond 201 pour le nouveau message : chez Larkfield, Camille est staff (personnel), et staff permet ticket.write. (d) 403 : staff ne permet pas member.manage (gérer les membres). Seule la propriétaire de Larkfield, Grace, peut le faire. (e) 404 : T-40001 n’est pas un billet de Larkfield. La recherche se fait dans Larkfield, donc le billet « n’existe pas » dans cette organisation, même si Camille peut le lire par l’organisation Bramble. Une personne obtient cinq réponses différentes, car chaque décision utilise le rôle d’une adhésion et l’organisation de l’enregistrement.

Vérifiez votre compréhension

Vérification pratique : exécutez les tentatives. Quelle est la dernière ligne, et quelle tentative modifie la base de données?

Afficher la réponse

python -m scripts.attempts se termine par 5 of 5 attempts got what they should not get. (les cinq tentatives ont obtenu ce qu’elles ne devraient pas obtenir). La tentative 3 (le message d’Omar) écrit une ligne dans messages. python -m scripts.load --reset la supprime.

Récapitulatif

  • Un défaut d’accès au niveau des objets (BOLA) : l’API renvoie un enregistrement parce que l’appelant connaît son identifiant. Sam a lu T-40001 de Bramble. Les identifiants aléatoires cachent les enregistrements; seule une vérification les protège.
  • Élévation de privilèges : une personne fait plus que son rôle ne permet. Omar, en lecture seule, a ajouté un message. Un bouton caché n’est pas une protection : la règle doit être dans l’API.
  • Sans organisation, les listes, les recherches et les anciens bogues mélangent les commerces. Sur 100 billets listés, 77 appartenaient à Larkfield, et la démonstration d’injection renvoie maintenant 52 clients.
  • python -m scripts.attempts : 5 sur 5 tentatives ont obtenu ce qu’elles ne devraient pas obtenir. À la fin du cours, les mêmes tentatives et cinq autres échouent toutes.

Récapitulatif du module. Vous pouvez maintenant poser les trois questions pour n’importe quelle requête. Vous pouvez stocker les utilisateurs, les organisations, les adhésions et les rôles, et lire la matrice d’accès. Vous pouvez aussi expliquer pourquoi Camille obtient des réponses différentes dans deux organisations. Votre projet correspond à end-of-m01.

Ensuite, l’API apprend qui est l’appelant. Vous découvrez OAuth et OpenID Connect, ainsi que le fournisseur d’identité qui connecte les personnes.

Leçon suivante : OAuth et OIDC