Dix catégories, dix lignes de code, et la plupart passent une relecture. L'OWASP (Open Worldwide Application Security Project) est une fondation qui publie des ressources ouvertes sur la sécurité des applications, et la huitième édition de son Top 10, présentée en novembre 2025, s'appuie sur des données issues de plus de 2,8 millions d'applications testées.
Apprendre les dix intitulés prend une soirée. Repérer l'un de ces risques dans une pull request de quarante lignes, au milieu d'une requête SQL correcte et d'un template bien rangé, demande un autre exercice. Pour une personne en reconversion, un développeur junior ou un curieux non-informaticien, la liste ressemble d'abord à un glossaire : des noms de familles et des numéros, rarement du code.
Les catégories décrivent des causes. Une revue de code voit des lignes, et une ligne fautive ressemble presque toujours à une ligne normale : elle s'exécute sans erreur et fait ce que le ticket demandait. Tant que le lien entre la catégorie et sa forme dans le code n'a pas été fait sur des exemples, la relecture la laisse passer.
Cet article reprend les dix catégories de l'édition 2025 avec, pour chacune, une ligne fautive, ce qu'elle laisse passer, sa correction et la question à poser en revue. Les exemples visent Python 3.14 et Flask 3.1.3, la dernière version de Flask au moment où ces lignes sont écrites. Python 3.15 est sorti le 9 octobre 2026 et ne change rien à ces exemples. Le tableau final est fait pour rester ouvert pendant tes revues de code.
Une route propre, et une faille dedans
Voici une route Flask d'apparence banale. Elle affiche une facture à partir du numéro présent dans l'adresse, avec get_db(), g.user et login_required définis comme dans le tutoriel officiel de Flask.
@app.get("/factures/<int:facture_id>")
@login_required
def voir_facture(facture_id: int):
db = get_db()
facture = db.execute(
"SELECT * FROM facture WHERE id = ?", (facture_id,)
).fetchone()
if facture is None:
abort(404)
return render_template("facture.html", facture=facture)
Si tu la relis rapidement, elle coche toutes les cases. La route exige une session ouverte. La requête SQL est paramétrée, donc pas d'injection. Une facture inexistante renvoie une 404. Le template passe par Jinja, qui échappe le HTML par défaut.
N'importe quel client connecté peut pourtant lire toutes les factures de la base en remplaçant 41 par 42 dans l'adresse. La requête répond à « quelle facture porte ce numéro ? » et ignore « ce client a-t-il le droit de la voir ? ». OWASP appelle ça une référence directe non sécurisée à un objet (IDOR) et la range dans la première catégorie de sa liste.
Aucun linter ne signalera cette route, et aucun test qui se connecte avec le bon client ne la fera échouer. Tu ne vois le défaut qu'en posant à la ligne la question de sa catégorie, ce qui suppose de connaître cette question.
Le principe : une catégorie, une question posée à la ligne
Chaque catégorie regroupe des CWE (Common Weakness Enumeration), le catalogue de faiblesses logicielles maintenu par l'organisation américaine MITRE. L'édition 2025 en rassemble 248, sur les 968 que comptait ce catalogue à sa publication. Huit catégories sont tirées des données de test, les deux autres d'un sondage auprès des praticiens, parce que certains risques se mesurent mal avec des outils automatiques.
Quand tu relis une pull request, la question que chaque catégorie pose à une ligne rend plus de services que la liste de ses CWE. Qui a le droit de faire ça ? D'où vient cette valeur ? Qui verra ce message ? Que se passe-t-il si cet appel échoue ?
Ce qui a bougé entre 2021 et 2025
Le contrôle d'accès reste en tête et absorbe la falsification de requête côté serveur (SSRF, une attaque qui pousse le serveur à appeler une adresse choisie par l'attaquant). La mauvaise configuration grimpe de la cinquième à la deuxième place, et les composants vulnérables s'élargissent en défaillances de la chaîne d'approvisionnement logicielle. Une catégorie nouvelle ferme la liste : la mauvaise gestion des conditions exceptionnelles, autrement dit ce que fait le code face à une erreur ou une situation imprévue.
Un chiffre du rapport mérite qu'on s'y arrête. La chaîne d'approvisionnement compte le moins d'occurrences dans les données, avec seulement 11 CVE rattachées à ses CWE, mais les scores moyens d'exploitation et d'impact les plus élevés de la liste. À mon sens, c'est aussi la catégorie la plus facile à oublier sur un projet d'apprentissage, parce qu'elle vit dans des fichiers de configuration que personne n'ouvre pendant une revue de fonctionnalité.
Les dix lignes, de A01 à A10
Les exemples supposent que ton application Flask utilise une base SQLite et des sessions classiques.
A01 : contrôle d'accès défaillant
La route de la facture en est l'exemple type, et la correction tient dans la clause WHERE :
Corrigé
facture = db.execute(
"SELECT * FROM facture WHERE id = ? AND client_id = ?",
(facture_id, g.user["id"]),
).fetchone()
Une facture d'un autre client produit la même 404 qu'une facture inexistante, ce qui évite de confirmer qu'elle existe. Depuis l'édition 2025, la catégorie couvre aussi le SSRF : une route qui télécharge une adresse fournie par l'utilisateur pose la même question de droit, appliquée au serveur lui-même. Le CSRF (falsification de requête intersite) y figure également, alors que beaucoup de supports le rangent à côté de l'injection.
A02 : mauvaise configuration de sécurité
Deux lignes en bas du fichier principal suffisent :
Ligne fautive
if __name__ == "__main__":
app.run(host="0.0.0.0", debug=True)
Si tu les copies de ton poste vers un serveur, elles exposent le débogueur interactif de Werkzeug à quiconque atteint le port. La documentation de Flask est explicite : ce débogueur permet d'exécuter du code Python arbitraire depuis le navigateur, et le code PIN qui le protège ne doit pas servir de barrière de sécurité. Le host="0.0.0.0" ouvre en plus le serveur sur toutes les interfaces réseau.
Corrigé
# En local uniquement : le mode debug passe par la ligne de commande
flask --app monapp run --debug
# En production : un serveur WSGI, sans debug
gunicorn -w 4 "monapp:create_app()"
Ton fichier Python ne contient plus de réglage d'exécution, donc plus rien à oublier au moment de déployer. La chaîne complète jusqu'au certificat HTTPS est décrite dans l'article sur la mise en ligne d'un projet. La catégorie couvre aussi les en-têtes de sécurité absents, les cookies sans attribut Secure ni HttpOnly, les messages d'erreur trop bavards et les comptes par défaut jamais désactivés.
A03 : défaillances de la chaîne d'approvisionnement logicielle
Celle-ci se trouve dans un workflow GitHub Actions :
Ligne fautive
- uses: tj-actions/changed-files@v45
Une étiquette comme v45 désigne un commit, mais quiconque a les droits d'écriture sur le dépôt de l'action peut la déplacer. C'est arrivé les 14 et 15 mars 2025 : un attaquant a fait pointer les étiquettes v1 à v45.0.7 de tj-actions/changed-files vers un commit malveillant, qui affichait les secrets de la CI dans les journaux d'exécution. La faille porte le numéro CVE-2025-30066, et l'action servait dans plus de 23 000 dépôts.
Corrigé
# Épinglée sur le SHA complet d'un commit vérifié, la version en commentaire
- uses: un-editeur/une-action@<SHA complet de 40 caractères> # vX.Y.Z
Un SHA de commit désigne un contenu précis et ne se déplace pas. Pour tes dépendances Python, même logique : un fichier de verrouillage avec les empreintes des paquets, installé avec pip install --require-hashes, et pip-audit dans la CI pour signaler les versions vulnérables. Ce type de pipeline se monte dans la formation CI/CD avec GitHub Actions, et le sujet rejoint les obligations décrites dans l'article sur le Cyber Resilience Act.
A04 : défaillances cryptographiques
Voici sans doute la ligne la plus trompeuse de la série, parce qu'elle s'appuie sur un algorithme réputé solide :
Ligne fautive
hash_mdp = hashlib.sha256(mot_de_passe.encode()).hexdigest()
SHA-256 convient pour vérifier l'intégrité d'un fichier. Pour un mot de passe, sa rapidité devient un défaut : chaque essai d'un attaquant coûte très peu de calcul, et sans sel, deux mots de passe identiques donnent la même empreinte. OWASP précise qu'une fonction rapide se casse sur carte graphique, même salée.
Corrigé
from werkzeug.security import generate_password_hash, check_password_hash
hash_mdp = generate_password_hash(mot_de_passe) # scrypt par défaut, sel inclus
Werkzeug 3.1, installé avec Flask, utilise scrypt par défaut et range les paramètres dans la chaîne produite, ce qui permet de les durcir plus tard sans casser les anciens comptes. OWASP recommande Argon2, yescrypt, scrypt ou PBKDF2-HMAC-SHA-512. La catégorie couvre aussi l'aléatoire : si tu tires un jeton de réinitialisation avec random.randint, il se prédit, et le module secrets existe pour ce cas.
A05 : injection
L'injection a perdu deux places depuis 2021, et la ligne qui la provoque n'a pas changé de forme :
Ligne fautive
cur.execute(f"SELECT * FROM utilisateur WHERE email = '{email}'")
La f-string colle la saisie dans le texte de la requête, et une apostrophe dans le champ suffit à sortir de la chaîne SQL pour ajouter une condition.
Corrigé
cur.execute("SELECT * FROM utilisateur WHERE email = ?", (email,))
Le pilote transmet la valeur à part, elle ne peut plus modifier la structure de la requête. La catégorie inclut le XSS (injection de script dans une page), qu'OWASP décrit comme fréquent et de faible impact, face à une injection SQL plus rare et plus grave. Les deux attaques sont déclenchées pas à pas dans l'article CSRF, XSS, SQLi avec du code qui casse.
A06 : conception non sécurisée
Ici, le défaut vient d'une décision prise avant d'écrire le code :
Ligne fautive
total = float(request.form["prix"]) * int(request.form["quantite"])
Le prix arrive d'un champ caché du formulaire. Tout ce qui vient du navigateur se modifie avec les outils de développement, si bien que le client choisit son prix, et une quantité négative transforme l'achat en avoir. Pour OWASP, une implémentation parfaite ne rattrape pas un contrôle qui n'a jamais été prévu.
Corrigé
produit = db.execute(
"SELECT prix_centimes FROM produit WHERE id = ?", (produit_id,)
).fetchone()
if produit is None:
abort(404)
quantite = request.form.get("quantite", type=int)
if quantite is None or not 1 <= quantite <= 10:
abort(400)
total_centimes = produit["prix_centimes"] * quantite
Ton serveur ne reçoit plus qu'un identifiant et une quantité, et décide du reste avec une règle métier écrite noir sur blanc. Les montants sont en centimes entiers, parce que les nombres à virgule flottante arrondissent mal : le 0.1 + 0.2 de l'article sur les bizarreries de JavaScript donne le même résultat en Python. L'exemple d'OWASP pour cette catégorie est une chaîne de cinémas où un attaquant réserve six cents places en quelques requêtes, faute de plafond prévu.
A07 : défaillances d'authentification
La vérification du mot de passe est souvent irréprochable. Ce qui manque se situe autour :
Ligne fautive
@app.post("/connexion")
def connexion():
utilisateur = trouver_par_email(request.form["email"])
if utilisateur and check_password_hash(utilisateur["hash_mdp"], request.form["mot_de_passe"]):
...
Rien ne limite le nombre d'essais. Un script peut rejouer des couples identifiant et mot de passe issus de fuites, ce qu'OWASP appelle le credential stuffing, ou tester des variantes comme Hiver2025 puis Hiver2026. L'extension Flask-Limiter, en version 4.1 au moment de l'écriture, ajoute une limite par route :
Corrigé
from flask_limiter import Limiter
from flask_limiter.util import get_remote_address
limiter = Limiter(get_remote_address, app=app, storage_uri="redis://localhost:6379")
@app.post("/connexion")
@limiter.limit("5 per minute")
def connexion():
...
La limite par adresse IP freine un attaquant isolé. Pour les comptes sensibles, OWASP ajoute l'authentification multifacteur et la vérification des nouveaux mots de passe contre les listes de mots de passe déjà fuités. La suite, révocation de session et stockage du jeton, est traitée dans l'article JWT ou session.
A08 : défaillances d'intégrité des logiciels ou des données
Une ligne pratique pour conserver un panier entre deux visites :
Ligne fautive
panier = pickle.loads(base64.b64decode(request.cookies["panier"]))
Le cookie vient du navigateur, donc de l'utilisateur, et la documentation de Python prévient que désérialiser des données pickle forgées peut exécuter du code arbitraire. Flask lui-même a remplacé pickle par JSON pour ses cookies de session dès sa version 0.10, en 2013.
Corrigé
session["panier"] = {"42": 2} # sérialisé en JSON et signé par Flask
La session Flask est signée avec la SECRET_KEY, et une modification côté client invalide la signature. Son contenu se lit toutefois en décodant le cookie, donc n'y mets rien de confidentiel.
A09 : défaillances de journalisation et d'alerte
Cette ligne cumule deux défauts :
Ligne fautive
app.logger.info(f"Échec de connexion pour {email} avec le mot de passe {mot_de_passe}")
Le mot de passe saisi part en clair dans des journaux archivés et souvent envoyés vers des outils tiers. L'email, tapé par l'utilisateur, peut contenir un retour à la ligne qui fabrique une fausse entrée dans le fichier (CWE-117).
Corrigé
app.logger.warning("echec_connexion email=%r ip=%s", email, request.remote_addr)
Le %r affiche un retour à la ligne sous la forme \n au lieu de couper l'entrée en deux. Reste le mot qu'OWASP a ajouté au nom de la catégorie dans l'édition 2025 : selon le rapport, une bonne journalisation sans alerte ne vaut presque rien pour repérer un incident. Cinquante échecs en une minute sur le même compte doivent te parvenir sous forme de notification, réglée dans l'outil qui lit tes journaux.
A10 : mauvaise gestion des conditions exceptionnelles
La nouvelle catégorie tient souvent dans un except trop accommodant :
Ligne fautive
def peut_supprimer(user_id: int, document_id: int) -> bool:
try:
return service_droits.verifier(user_id, document_id)
except Exception:
return True # le service est parfois lent, on ne bloque pas l'utilisateur
Le jour où le service de droits tombe, tout le monde peut supprimer tous les documents. OWASP nomme ce comportement « failing open », échouer en position ouverte.
Corrigé
except ServiceDroitsIndisponible:
app.logger.error("service_droits_indisponible document=%s", document_id)
return False
L'exception attrapée est précise, et l'échec ferme la porte en laissant une trace. Le choix entre exceptions, codes de retour et type Result est détaillé dans l'article sur la gestion des erreurs.
Le tableau à garder sous la main
| Catégorie | Ligne à repérer | Question à poser |
|---|---|---|
| A01 Contrôle d'accès | Requête par identifiant sans filtre sur le propriétaire | À qui appartient cette ressource, et qui la demande ? |
| A02 Configuration | debug=True, réglage par défaut laissé en production | Ce réglage est-il celui de la production ? |
| A03 Chaîne d'approvisionnement | Action ou dépendance référencée par une étiquette mobile | Qui peut changer ce que désigne cette référence ? |
| A04 Cryptographie | sha256, md5 ou random appliqué à un secret | Cet algorithme est-il conçu pour cet usage ? |
| A05 Injection | Requête ou commande assemblée avec une saisie | La valeur peut-elle changer la structure de la requête ? |
| A06 Conception | Prix, rôle ou montant lu dans le formulaire | Quelle règle métier le serveur fait-il respecter ? |
| A07 Authentification | Route de connexion sans limite d'essais | Combien d'essais un script peut-il faire par minute ? |
| A08 Intégrité | pickle.loads sur une donnée reçue | Cette donnée est-elle vérifiée avant d'être chargée ? |
| A09 Journalisation | Secret dans un log, échec non journalisé | Qui sera prévenu si cet événement se répète ? |
| A10 Conditions exceptionnelles | except Exception qui autorise ou continue | Que devient la sécurité si cet appel échoue ? |
Les pièges qui font repasser la ligne
Prendre la requête paramétrée pour un certificat de sécurité. Paramétrer règle A05 et laisse le reste en l'état, comme le montre la route de la facture.
Limiter les essais derrière un proxy sans le dire à Flask. Si ton application tourne derrière Nginx, get_remote_address voit l'adresse du proxy pour toutes les requêtes, et tous les visiteurs partagent le même compteur. Le middleware ProxyFix de Werkzeug relit l'en-tête X-Forwarded-For, à configurer avec le nombre exact de proxys de confiance. Le stockage en mémoire pose une difficulté voisine : chaque processus Gunicorn tient son propre compteur, si bien qu'avec quatre processus la limite réelle est multipliée par quatre, d'où le Redis de l'exemple.
Lire le Top 10 comme une liste exhaustive. Ses catégories couvrent 248 faiblesses sur près d'un millier recensées, et OWASP présente la liste comme un document de sensibilisation. Pour vérifier ton application point par point, la fondation publie l'ASVS (Application Security Verification Standard), et une analyse statique comme celle décrite dans l'article sur CodeQL attrape une partie des lignes de ce tableau sans relecture humaine.
Corriger sans écrire le test qui va avec. OWASP recommande d'inclure le contrôle d'accès dans les tests unitaires et d'intégration. Écris un test qui se connecte avec le client 1, demande une facture du client 2 et attend une 404 : il protège la correction de A01 contre le refactoring qui, six mois plus tard, retirera la condition sur client_id en croyant simplifier la requête.
Questions fréquentes
Le Top 10 OWASP est-il une norme à respecter ?
OWASP le présente comme un document de sensibilisation sur les risques les plus répandus, pas comme une grille de vérification. Pour contrôler une application exigence par exigence, la référence de la même fondation est l'ASVS, dont la version 5.0 est citée dans les pages de l'édition 2025.
Un framework comme Flask ou Symfony protège-t-il déjà contre ces failles ?
En partie. Jinja échappe le HTML par défaut, les ORM paramètrent leurs requêtes, Werkzeug hache les mots de passe avec scrypt et Flask signe ses cookies de session. Aucun framework ne sait en revanche à quel client appartient une facture, ni ce qu'il faut faire quand un service de droits ne répond pas : ces décisions restent dans le code applicatif.
Les injections de prompt dans les applications d'IA font-elles partie du Top 10 ?
Non. Malgré le mot « injection », elles relèvent d'une liste séparée que l'OWASP consacre aux applications bâties sur des modèles de langage. Les règles à appliquer quand un modèle peut appeler des fonctions de l'application sont détaillées dans l'article sur le tool calling avec l'API OpenAI.
À quelle fréquence la liste est-elle mise à jour ?
Tous les trois à quatre ans environ. La première édition date de 2003, la précédente de 2021, et celle de 2025 est la huitième.
Par quelle catégorie commencer sur un projet existant ?
Commence par les deux premières. Selon le rapport 2025, 100 % des applications testées présentaient au moins une faiblesse de contrôle d'accès, et 100 % au moins une erreur de configuration. Passe chaque route qui reçoit un identifiant au crible de la question de A01, puis compare les réglages de production à ceux du poste de développement.