Revue statique de l'ensemble du code Python, de la configuration CI/nginx, du .gitignore et des dependances. 44 constats documentes avec references fichier:ligne, impact et correctif propose. - audit/01-securite.md 19 constats (4 critiques) - audit/02-maintenabilite.md 15 constats - audit/03-standards-stack.md 10 ecarts aux conventions Flask/SQLAlchemy - audit/plan-remediation.md ordre de traitement en 6 lots Points critiques : secrets de production reels committes dans app/.env.exemple, seed automatique en production avec mot de passe password, CORS ouvert a toutes les origines avec credentials par defaut, token du bot Discord imprime sur stdout au demarrage. Aucune modification du code applicatif. Co-Authored-By: Claude Opus 5 <[email protected]>
38 KiB
1 — Sécurité
19 constats. Les références de lignes correspondent à main @ 08f02f7.
| ID | Constat | Sévérité |
|---|---|---|
| SEC-01 | Secrets de production réels committés dans le dépôt | 🔴 Critique |
| SEC-02 | Seed automatique en production avec mot de passe password |
🔴 Critique |
| SEC-03 | CORS ouvert à toutes les origines avec credentials par défaut | 🔴 Critique |
| SEC-04 | Token du bot Discord imprimé sur stdout à l'import | 🔴 Critique |
| SEC-05 | En-têtes de proxy non validés → contournement HTTPS + rate limiting inopérant | 🟠 Élevé |
| SEC-06 | Schémas de validation importés mais jamais appliqués sur les routes utilisateurs | 🟠 Élevé |
| SEC-07 | discord_user_id arbitraire → détournement des notifications privées |
🟠 Élevé |
| SEC-08 | Rate limiting en mémoire, non partagé et réinitialisé à chaque redémarrage | 🟠 Élevé |
| SEC-09 | nl2br marque du HTML utilisateur non échappé comme sûr |
🟡 Moyen |
| SEC-10 | CSP avec 'unsafe-inline' sur script-src |
🟡 Moyen |
| SEC-11 | Aucune validation de type sur l'upload de contrat signé | 🟡 Moyen |
| SEC-12 | Énumération d'utilisateurs via les messages de login | 🟡 Moyen |
| SEC-13 | CAPTCHA arithmétique trivial | 🟡 Moyen |
| SEC-14 | /health expose l'erreur brute de la base de données |
🟡 Moyen |
| SEC-15 | Profil complet de tout utilisateur visible par tout compte authentifié | 🟡 Moyen |
| SEC-16 | Conversions int() non protégées sur entrées utilisateur |
🔵 Faible |
| SEC-17 | add_to_team ne vérifie pas la cohérence tryout/équipe/joueur |
🔵 Faible |
| SEC-18 | Aucune réinitialisation de mot de passe ni MFA | 🔵 Faible |
| SEC-19 | Journal d'audit d'authentification déclaré mais jamais alimenté | 🔵 Faible |
SEC-01 · 🔴
Secrets de production réels committés dans le dépôt
app/.env.exemple — fichier suivi par git — ne contient pas des valeurs d'exemple mais des identifiants réels :
| Ligne | Secret |
|---|---|
:6 |
SECRET_KEY=65476749453935 — clé de signature des sessions Flask |
:18 |
DISCORD_BOT_TOKEN=MTUyNzY3ODU3NjUyNTA1NDEyNQ.G1gPNQ.LeFV… — token du bot UdeS Esports |
:21 |
DATABASE_URL=postgresql://team_tryouts_db_user:0YO038Od2QcQ…@dpg-…render.com/team_tryouts_db — base PostgreSQL Render, hôte public, avec mot de passe |
Le commentaire ligne 16-17 confirme explicitement qu'il s'agit du token de production : « This is the UdeS Esports BOT token ».
Impact. Toute personne ayant accès au dépôt — y compris via un fork, un clone, ou si le dépôt devient public — obtient :
- un accès lecture/écriture complet à la base de production (l'hôte Render est joignable depuis Internet) : identités, courriels, téléphones, notes personnelles des joueurs, contrats ;
- le contrôle du bot Discord (envoi de DM en usurpant l'identité de l'organisation) ;
- la capacité de forger des cookies de session Flask valides grâce à la
SECRET_KEY, donc de s'authentifier en tant que n'importe quel utilisateur, y comprisadmin, sans mot de passe.
Le .gitignore ignore bien .env, mais le fichier a été committé sous le nom .env.exemple, qui échappe à la règle.
Présent dans l'historique depuis le commit 2d3721b (« Ajout d'un .env.exemple pour simplifier la collaboration »). Supprimer le fichier ne suffira pas.
Correction.
- Révoquer immédiatement, avant toute autre action : régénérer le token du bot dans le Discord Developer Portal, faire tourner le mot de passe PostgreSQL sur Render, générer une nouvelle
SECRET_KEY(python -c "import secrets; print(secrets.token_hex(32))"). La rotation de laSECRET_KEYinvalidera toutes les sessions en cours, ce qui est le comportement souhaité ici. - Remplacer le contenu du fichier par des valeurs factices (
SECRET_KEY=<générer avec …>,DATABASE_URL=postgresql://user:password@host:5432/dbname). - Purger l'historique (
git filter-repo --path app/.env.exemple --invert-paths, ou BFG), puis forcer la réécriture sur toutes les branches et prévenir les collaborateurs qu'ils doivent recloner. - Ajouter
.env*(avec l'astérisque) au.gitignore, en gardant une exception explicite pour le modèle :!.env.example. - Ajouter un scan de secrets à la CI (
gitleaks, oudetect-secretsen pre-commit) pour empêcher la récidive.
Renommer aussi le fichier en
.env.example— l'orthographe actuelle est un francisme qui casse la détection automatique de la plupart des outils.
SEC-02 · 🔴
Seed automatique en production avec mot de passe password
app/app.py:348-356
with app.app_context():
import app.models as models
from app.models import User
db.create_all()
if User.query.count() == 0:
from app.supporting_scrits.seed import seed_database
seed_database()
Ce bloc s'exécute à chaque appel de create_app(), sans distinction d'environnement — donc aussi via wsgi.py, c'est-à-dire en production.
app/supporting_scrits/seed.py crée alors des comptes de démonstration dont le mot de passe est la chaîne littérale password :
username='admin', password_hash=hash_password('password'), # :42
username='manager1', password_hash=hash_password('password'), # :48
username='coach1', password_hash=hash_password('password'), # :60
username='scout1', password_hash=hash_password('password'), # :79
…et les affiche en clair au démarrage (seed.py:449-453).
Impact. Tout déploiement neuf, toute restauration sur base vide, toute migration vers une nouvelle instance crée un compte admin / password accessible depuis Internet. C'est un contournement complet de l'authentification. Le compte admin a can_manage_users() == True : création, modification et suppression de tous les utilisateurs.
Le mot de passe password ne respecte d'ailleurs pas la politique définie dans validators.py:22-24 (8 caractères, majuscule, minuscule, chiffre) — ce qui montre que le seed contourne toute la couche de validation.
Correction.
- Conditionner le seed :
if os.getenv('SEED_DEMO_DATA', 'false').lower() == 'true' and User.query.count() == 0:. - Mieux : sortir le seed du factory et en faire une commande CLI Flask (
flask seed-demo), exécutée explicitement en développement. - Faire générer les mots de passe de démo aléatoirement (
secrets.token_urlsafe(16)) et les afficher une seule fois, plutôt que d'utiliser une constante. - Vérifier immédiatement en production si les comptes
admin,manager1,manager2,coach1,coach2,coach3,scout1existent avec ces mots de passe, et les désactiver le cas échéant.
SEC-03 · 🔴
CORS ouvert à toutes les origines avec credentials par défaut
app/app.py:76-95
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
allowed_origins = [origin.strip() for origin in allowed_origins if origin.strip()]
if allowed_origins:
CORS(app, origins=allowed_origins, supports_credentials=True, …)
else:
# When no origins specified, allow all (development) or none (production)
# In production with a reverse proxy, CORS is handled at the Nginx level
CORS(app, supports_credentials=True, methods=[…], max_age=3600)
Le commentaire décrit une intention qui n'est pas implémentée : la branche else autorise toutes les origines, en développement comme en production. flask-cors avec supports_credentials=True et sans origins reflète l'en-tête Origin de la requête dans Access-Control-Allow-Origin et ajoute Access-Control-Allow-Credentials: true.
Le commentaire renvoie la responsabilité à nginx, mais app/nginx.conf ne contient aucune directive CORS. Et app/.env.exemple ne définit pas CORS_ALLOWED_ORIGINS : la configuration livrée aux équipes tombe donc systématiquement dans la branche permissive.
Impact. N'importe quel site tiers visité par un utilisateur connecté peut lire, avec ses cookies de session, le contenu de toutes les routes GET — notamment :
/users/disponibilities: disponibilités de tous les joueurs actifs, avec noms d'utilisateur ;/matches/api/events: calendrier complet, participants, sessions 1:1 approuvées ;/users/profile,/users/<id>/view: données personnelles.
La protection CSRF (CSRFProtect) limite les écritures, mais n'empêche pas ces lectures.
Correction.
allowed_origins = [o.strip() for o in os.getenv('CORS_ALLOWED_ORIGINS', '').split(',') if o.strip()]
if allowed_origins:
CORS(app, origins=allowed_origins, supports_credentials=True, methods=[...], max_age=3600)
elif app.debug:
CORS(app, origins=['http://localhost:5000'], supports_credentials=True)
# sinon : pas de CORS du tout — même origine uniquement
L'application étant rendue côté serveur (Jinja2) et consommant ses propres API en même-origine, le cas nominal est de ne pas activer CORS du tout. Documenter CORS_ALLOWED_ORIGINS dans le fichier d'exemple.
SEC-04 · 🔴
Token du bot Discord imprimé sur stdout à l'import
app/discord_bot.py:24-25
DISCORD_BOT_TOKEN = os.getenv('DISCORD_BOT_TOKEN')
print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN')
Le token est écrit en clair sur la sortie standard à chaque import du module, donc à chaque démarrage de l'application.
Impact. Le secret se retrouve dans les logs du superviseur de processus, les journaux de la plateforme d'hébergement, les logs de conteneur, et la sortie des jobs CI. Ces destinations sont typiquement conservées longtemps, indexées, et accessibles à un public plus large que les variables d'environnement elles-mêmes.
À noter : le SensitiveDataFilter de logging_config.py ne peut rien ici — il filtre les enregistrements du module logging, pas les appels à print().
Correction. Supprimer la ligne. Si un diagnostic de configuration est nécessaire au démarrage :
logger.info('Discord bot token: %s', 'configuré' if DISCORD_BOT_TOKEN else 'ABSENT')
SEC-05 · 🟠
En-têtes de proxy non validés → contournement HTTPS et rate limiting inopérant
L'application lit X-Forwarded-Proto pour décider d'appliquer HSTS et la redirection HTTPS :
app/app.py:163 — is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'
app/app.py:182 — if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':
Or ProxyFix n'est jamais appliqué et aucune liste de proxys de confiance n'est configurée. L'en-tête est accepté tel quel, quelle que soit sa provenance.
Ce défaut est amplifié par la configuration réseau :
wsgi.py:26—host = os.getenv('HOST', '0.0.0.0'), avec le commentaire trompeur « Bind to localhost by default ». Le serveur Waitress écoute en réalité sur toutes les interfaces.app/nginx.conf:122—proxy_pass http://0.0.0.0:5000;—0.0.0.0n'est pas une adresse de destination valide comme cible amont ; ce devrait être127.0.0.1.
Impact.
- Le port de l'application est joignable directement, en contournant nginx — donc sans TLS, sans les en-têtes de sécurité ajoutés par nginx.
- En envoyant
X-Forwarded-Proto: httpssur cette connexion en clair, on désactive la redirection HTTPS deforce_https()et l'application se comporte comme si la connexion était sécurisée. - Corollaire plus grave — le rate limiting est neutralisé.
app/extensions.py:16-19utilisekey_func=get_remote_address, qui litrequest.remote_addr. SansProxyFix, cette valeur est l'IP de nginx pour toutes les requêtes proxifiées. Conséquences :- la limite de
10 per minutesur/auth/login(auth.py:79) devient un seau global partagé par tous les utilisateurs — la protection anti-bruteforce ne fonctionne pas par attaquant ; - inversement, un seul client peut consommer le quota global et bloquer le login de toute l'organisation (déni de service trivial) ;
- les limites par défaut
200/jour, 50/heures'appliquent à l'ensemble du trafic, ce qui rendra l'application inutilisable en usage normal dès quelques utilisateurs simultanés.
- la limite de
Correction.
from werkzeug.middleware.proxy_fix import ProxyFix
# après la création de l'app, uniquement si l'on est réellement derrière un proxy
if os.getenv('BEHIND_PROXY', 'false').lower() == 'true':
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
x_for=1 indique de ne faire confiance qu'au dernier saut — celui de nginx. Ne jamais activer ce middleware si l'application n'est pas derrière un proxy, sinon X-Forwarded-For devient falsifiable par le client.
En complément :
wsgi.py: passer le défaut deHOSTà127.0.0.1(ce que le commentaire annonce déjà) ;nginx.conf:122:proxy_pass http://127.0.0.1:5000;;- filtrer au pare-feu le port applicatif.
SEC-06 · 🟠
Schémas de validation importés mais jamais appliqués sur les routes utilisateurs
app/routes/users.py:23-26 importe CreateUserSchema, EditUserSchema et EditProfileSchema. Vérification par comptage d'occurrences : chacun de ces trois noms n'apparaît qu'une seule fois dans le fichier — sur la ligne d'import. Ils ne sont jamais instanciés.
Les trois routes concernées lisent le formulaire brut :
| Route | Lignes | Traitement |
|---|---|---|
create_user |
:196-226 |
request.form.get('password') → hash_password(password) directement |
edit_user |
:98-133 |
password = request.form.get('password') → hash_password(password) si non vide |
edit_profile |
:255-295 |
idem, sur son propre compte |
Comparaison avec auth.py:206-218, où RegisterSchema est correctement chargé — l'inscription publique est donc validée, mais pas les trois autres chemins de création/modification de compte.
Impact.
- Aucune politique de mot de passe sur ces routes :
aest accepté. Un président créant les comptes de l'équipe peut leur attribuer des mots de passe d'un caractère, et n'importe quel utilisateur peut affaiblir le sien viaedit_profile. - Aucune validation de format sur
email(le champ n'est même pas vérifié comme étant une adresse),username,phone. create_user(:216) appellehash_password(password)sans vérifier quepasswordest non vide : unpassword_hashd'une chaîne vide est stocké, et le compte devient accessible avec un mot de passe vide.- Dans
edit_user(:115-118),full_nameetemailsont assignés sans contrôle de nullité, alors que les colonnes sontnullable=False(models/user_model/user.py:20-21) →IntegrityErrornon gérée → 500.
Correction. Appliquer les schémas déjà écrits, sur le modèle de auth.py :
schema = EditProfileSchema()
try:
validated = schema.load(request.form)
except ValidationError as err:
for field, messages in err.messages.items():
for msg in messages:
flash(f'{field}: {msg}', 'danger')
return render_template('pages/edit_profile.html', ...)
Attention : EditUserSchema et EditProfileSchema déclarent password avec load_default='' et validate=validate_password — un mot de passe vide échouera donc la validation. Il faut soit passer validate=validate.And(...) conditionnel, soit retirer le champ du payload quand il est vide avant le load().
SEC-07 · 🟠
discord_user_id arbitraire → détournement des notifications privées
app/routes/users.py:262-263 et :283-284 (route edit_profile, accessible à tout utilisateur authentifié) :
discord_user_id = request.form.get('discord_user_id', '').strip()
...
current_user.discord_user_id = discord_user_id or None
Aucune validation (conséquence de SEC-06 : validate_discord_user_id existe dans validators.py:83-97 mais n'est pas appelée), et aucune vérification de propriété : rien ne prouve que l'utilisateur contrôle réellement ce compte Discord. Aucune contrainte d'unicité sur la colonne non plus (models/user_model/user.py:32).
Impact. Un joueur peut renseigner l'identifiant Discord d'une autre personne — un coach, un membre de la direction. Il reçoit alors à sa place les messages privés du bot. Selon les flux décrits dans le README, cela inclut :
- les demandes de sessions 1:1 avec leurs « discussion points », souvent confidentiels ;
- les notifications de matchs et d'entraînements ;
- surtout, la capacité de répondre à la place de la cible :
discord_bot.pytraite les réactions ✅/❌ en DM pour accepter ou refuser une demande 1:1 (on_reaction_add,:118). L'attaquant obtient donc un pouvoir de décision qui ne lui appartient pas.
Deux utilisateurs peuvent en outre déclarer le même identifiant, ce qui rend le comportement non déterministe.
Correction.
- Appliquer
validate_discord_user_id(corrigé par SEC-06) — nécessaire mais très insuffisant : il ne vérifie que le format 17-20 chiffres. - Ajouter une contrainte d'unicité sur
User.discord_user_id. - Implémenter une vérification de possession : à la saisie, envoyer un code à usage unique en DM sur l'identifiant déclaré et exiger sa saisie sur la plateforme avant d'activer le lien. C'est la seule correction qui traite réellement le problème.
- En attendant, réserver la modification de ce champ aux administrateurs.
SEC-08 · 🟠
Rate limiting en mémoire, non partagé et réinitialisé à chaque redémarrage
app/extensions.py:16-19
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"]
)
Aucun storage_uri n'est fourni. Flask-Limiter bascule alors sur son backend memory://, qui est explicitement documenté comme non destiné à la production (la bibliothèque émet d'ailleurs un avertissement au démarrage).
Impact.
- L'état est par processus.
wsgi.py:25démarre Waitress aveccpu_count() * 2 + 1threads — cela reste un processus, donc le compteur est partagé ici ; mais toute évolution vers plusieurs workers ou plusieurs instances (montée en charge, déploiement bleu-vert) fragmente les compteurs et multiplie d'autant la limite effective. - L'état est perdu à chaque redémarrage : un attaquant peut réinitialiser les compteurs si un redéploiement survient, et le verrouillage anti-bruteforce ne survit pas aux mises à jour.
- Combiné à SEC-05, la protection est de toute façon appliquée à la mauvaise clé.
Correction. Adosser le limiteur à un stockage partagé — Redis de préférence, ou la base PostgreSQL déjà présente si l'on veut éviter une dépendance supplémentaire :
limiter = Limiter(
key_func=get_remote_address,
default_limits=["200 per day", "50 per hour"],
storage_uri=os.getenv('RATELIMIT_STORAGE_URI', 'memory://'),
)
Réévaluer aussi les valeurs : 50 per hour par IP est très bas pour une application web rendue côté serveur, où chaque page consomme plusieurs requêtes (/matches/api/events, /users/disponibilities…). Exclure les routes /static et /health du décompte.
SEC-09 · 🟡
nl2br marque du HTML utilisateur non échappé comme sûr
app/app.py:19-30
def nl2br(value):
if value:
return markupsafe.Markup('<br>'.join(str(value).splitlines()))
return ''
markupsafe.Markup() désactive l'échappement automatique de Jinja2 pour la chaîne produite. Le contenu utilisateur est inséré tel quel, sans passer par escape().
Statut actuel : non exploitable. Une recherche sur l'ensemble des templates ne trouve aucune utilisation de |nl2br. Le filtre est enregistré (app.py:125) mais mort.
Risque. C'est un piège en attente : le filtre porte un nom naturel, il est enregistré globalement, et la première personne qui écrira {{ note.content|nl2br }} — un usage évident sur PersonalNote, TeamNote ou les discussion points des 1:1 — introduira une XSS stockée sans s'en rendre compte. Ces contenus sont saisis par des coachs et joueurs et affichés à d'autres utilisateurs.
Correction. Échapper avant de marquer :
from markupsafe import Markup, escape
def nl2br(value):
if not value:
return ''
return Markup('<br>').join(escape(str(value)).splitlines())
escape() neutralise le HTML utilisateur ; seuls les <br> insérés par le filtre restent actifs. Alternative sans code : supprimer le filtre et utiliser white-space: pre-line en CSS.
SEC-10 · 🟡
CSP avec 'unsafe-inline' sur script-src
app/app.py:149-159
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com https://cdn.jsdelivr.net; "
'unsafe-inline' sur script-src annule l'essentiel du bénéfice de la CSP : c'est précisément l'injection de <script> inline que la directive est censée bloquer. La politique ne protège plus que contre le chargement de scripts externes.
Le reste de la politique est de bonne qualité (frame-ancestors 'none', base-uri 'self', form-action 'self', object-src implicitement couvert par default-src 'self').
'unsafe-inline' sur style-src est nettement moins grave et généralement toléré.
Correction. Chemin réaliste, par étapes :
- Extraire les
<script>inline des templates vers des fichiers sousstatic/js/— un audit rapide montre quematch_form.html(44 Ko),view_tryout.html(31 Ko),teams.html(23 Ko) etcalendar.html(16 Ko) en concentrent la majorité. - Pour ce qui doit rester inline, générer un nonce par requête (
secrets.token_urlsafe(16)dans unbefore_request, injecté dans le contexte Jinja) et passer àscript-src 'self' 'nonce-{nonce}'. - Épingler les ressources CDN avec
integrity(SRI) plutôt que de faire confiance à l'origine seule.
En attendant, traiter SEC-09 : sans point d'injection HTML, la CSP affaiblie n'est pas exploitable.
SEC-11 · 🟡
Aucune validation de type sur l'upload de contrat signé
app/routes/users.py:577-604 (upload_signed_contract) :
file = request.files['signed_file']
if file.filename == '':
flash('No file selected.', 'danger')
return redirect(url_for('users.list_contracts'))
signed_filename = f"signed_{contract.stored_filename}"
file.save(contract.file_path.replace(contract.stored_filename, signed_filename))
Aucune vérification d'extension ni de type — contrairement à upload_contract (:537-539) qui contrôle .pdf. Les constantes ALLOWED_CONTRACT_EXTENSIONS et ALLOWED_SIGNED_EXTENSIONS (:29-30) ne sont référencées nulle part (une seule occurrence chacune : leur déclaration).
Facteurs atténuants. Le nom de fichier stocké est dérivé d'un UUID généré côté serveur (:556-557), pas du nom fourni par le client — il n'y a donc pas de traversée de répertoire, et le fichier est toujours écrit avec le suffixe .pdf. Les fichiers sont servis par send_file(..., as_attachment=True) (:615, :629), donc téléchargés plutôt qu'interprétés. Le risque d'exécution est faible.
Impact réel. Stockage de contenu arbitraire (jusqu'à 16 Mo) sous une extension .pdf trompeuse : usage du serveur comme relais de distribution de fichiers malveillants vers des utilisateurs légitimes, qui recevront un « contrat » qui n'en est pas un. Et corruption fonctionnelle des dossiers de contrats.
Correction.
- Appliquer la même vérification que
upload_contract, en utilisant les constantes déjà déclarées :ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else '' if ext not in ALLOWED_SIGNED_EXTENSIONS: flash('Seuls les fichiers PDF sont acceptés.', 'danger') return redirect(url_for('users.list_contracts')) - Ne pas se fier à l'extension seule : vérifier les octets d'en-tête (
%PDF-) après lecture, ou utiliserpython-magic. - Servir les téléchargements avec
Content-Type: application/pdfexplicite etContent-Disposition: attachment(déjà le cas viaas_attachment=True). - Stocker les documents hors de l'arborescence servie par le serveur web — c'est déjà le cas (
documents/, gitignoré), à préserver.
SEC-12 · 🟡
Énumération d'utilisateurs via les messages de login
app/routes/auth.py:148-167
if user:
user.failed_login_attempts += 1
if user.failed_login_attempts >= MAX_LOGIN_ATTEMPTS:
flash(f'Account locked after {MAX_LOGIN_ATTEMPTS} failed attempts. …')
else:
remaining = MAX_LOGIN_ATTEMPTS - user.failed_login_attempts
flash(f'Login unsuccessful. {remaining} attempt(s) remaining before lockout.', 'danger')
else:
flash('Login unsuccessful. Please check username and password.', 'danger')
Le message diffère selon que le compte existe ou non. Le message de verrouillage (:114-121) fuit également l'existence du compte, et de surcroît avant toute vérification du mot de passe.
S'y ajoute une différence de temps de réponse : quand l'utilisateur n'existe pas, check_password n'est jamais appelé, donc le coût du hachage n'est pas payé — un écart mesurable même sans lire les messages.
Impact. Constitution d'une liste d'identifiants valides, préalable à une attaque par pulvérisation de mots de passe ou à de l'hameçonnage ciblé. Sur une organisation étudiante dont les noms d'utilisateurs sont devinables, l'impact est réel.
Correction.
- Retourner un message identique dans tous les cas : « Identifiants invalides. », sans compteur de tentatives restantes ni mention de verrouillage.
- Exécuter systématiquement
check_passwordcontre un hachage factice quand l'utilisateur n'existe pas, pour égaliser les temps de réponse :DUMMY_HASH = generate_password_hash('dummy-password-for-timing-equalization') ... check_password(user.password_hash if user else DUMMY_HASH, password) - Notifier le verrouillage par courriel au titulaire du compte plutôt qu'à l'écran.
SEC-13 · 🟡
CAPTCHA arithmétique trivial
app/routes/auth.py:39-53
a = random.randint(1, 10)
b = random.randint(1, 10)
session['captcha_answer'] = a + b
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
L'espace des réponses possibles est de 19 valeurs (2 à 20). La question est présente en clair dans le HTML sous forme N + M = ?, donc résoluble par une expression régulière de trois lignes. random est le générateur pseudo-aléatoire standard, non cryptographique.
Le captcha_id est généré (:50) et stocké en session, mais n'est jamais comparé lors de la vérification (verify_captcha, :56-72, ne fait que dépiler captcha_answer et ignorer captcha_id) — le champ est décoratif.
Facteur atténuant. La route /auth/register est limitée à 3 per hour (:173), ce qui borne fortement l'exploitation — sous réserve que le rate limiting fonctionne (voir SEC-05 et SEC-08, qui le compromettent).
Impact. Création automatisée de comptes joueurs. Conséquences limitées (un joueur n'a pas de privilèges), mais pollution de la base et bruit dans les listes de sélection.
Correction. Si l'objectif est réellement d'arrêter des bots, un CAPTCHA maison n'y parviendra pas. Deux options selon l'ambition :
- Suffisant ici : valider l'inscription par un lien envoyé au courriel institutionnel, en restreignant le domaine (
@usherbrooke.ca). Cela résout simultanément le problème des comptes jetables et vérifie l'appartenance à l'organisation. - Sinon, intégrer un service dédié (hCaptcha, Turnstile) — au prix d'une dépendance externe et d'un assouplissement de la CSP.
Dans tous les cas, corriger le rate limiting (SEC-05/SEC-08), qui est ici la protection réellement efficace.
SEC-14 · 🟡
/health expose l'erreur brute de la base de données
app/app.py:206-212
except Exception as e:
health_data['status'] = 'unhealthy'
health_data['database'] = f'error: {str(e)}'
return jsonify(health_data), 503
La route /health n'est pas protégée par @login_required — elle est publique, comme attendu d'un endpoint de supervision. Mais en cas d'incident, l'exception SQLAlchemy est renvoyée telle quelle au client.
Impact. Les erreurs psycopg2 incluent typiquement le nom d'hôte, le port, le nom de la base et le nom d'utilisateur (par exemple could not connect to server: … host "dpg-….render.com" port 5432 … database "team_tryouts_db" user "team_tryouts_db_user"). C'est de la reconnaissance d'infrastructure offerte gratuitement, précisément au moment où le système est en difficulté.
C'est d'autant plus incohérent que le gestionnaire d'erreur 500 (:300-324) fait exactement l'inverse, et le documente : « Never exposes stack traces to users ».
Correction.
except Exception:
app.logger.error('Health check: échec de connexion à la base', exc_info=True)
return jsonify({'status': 'unhealthy', 'database': 'unreachable'}), 503
Le détail part dans les logs, où il est utile ; le client reçoit un statut binaire, qui est tout ce dont un équilibreur de charge a besoin. Envisager aussi de restreindre /health au réseau interne via nginx.
SEC-15 · 🟡
Profil complet de tout utilisateur visible par tout compte authentifié
app/routes/users.py:231-236
@users_bp.route('/<int:user_id>/view')
@login_required
def view_user(user_id):
user = User.query.get_or_404(user_id)
return render_template('pages/view_user.html', profile_user=user)
Seule l'authentification est vérifiée. Aucun contrôle d'appartenance à la même équipe, ni de rôle.
L'objet User transmis au template porte email, phone, discord_username, discord_user_id, full_name, league_os_profile (models/user_model/user.py:16-33).
Impact. Tout compte — y compris un compte joueur créé par inscription publique (/auth/register, ouverte à tous) — peut énumérer /users/1/view, /users/2/view… et collecter les coordonnées personnelles de l'ensemble des membres de l'organisation : courriels, téléphones, identifiants Discord. Combiné à SEC-13 (inscription faiblement protégée), la barrière d'entrée est basse.
Note : list_users (:76-85) est bien réservé aux Admin. C'est la vue de détail unitaire qui a été oubliée.
Correction. Deux niveaux, à combiner :
- Limiter les données transmises au template plutôt que de passer l'objet ORM complet — les coordonnées n'ont pas à figurer sur un profil consulté par un pair.
- Restreindre l'accès selon la relation : membres d'une même
OrgTeam, ou rôles d'encadrement (Admin,Manager,Coachde l'équipe du joueur,Scout).
def can_view_profile(viewer, target):
if isinstance(viewer, (Admin, Manager, Scout)) or viewer.id == target.id:
return True
viewer_teams = {t.id for t in viewer.get_org_teams()}
return bool(viewer_teams & {t.id for t in target.get_org_teams()})
SEC-16 · 🔵
Conversions int() non protégées sur entrées utilisateur
De nombreuses routes convertissent des champs de formulaire sans garde. Exemples :
| Fichier | Ligne | Code |
|---|---|---|
routes/teams.py |
:120-121 |
int(coach_id) if coach_id else None |
routes/teams.py |
:241, :272 |
User.query.get_or_404(int(coach_id)) |
routes/tryouts.py |
:70, :72-74 |
int(max_players), int(target_org_team_id)… |
routes/tryouts.py |
:428 |
TeamMember(team_id=team_id, player_id=int(player_id), …) |
routes/matches.py |
:298-299 |
[int(p) for p in team1_player_ids.split(',') if p] |
routes/matches.py |
:314, :318 |
int(pid) sur request.form.getlist('player_ids') |
Une valeur non numérique lève ValueError, non interceptée → HTTP 500 au lieu d'un 400.
Impact. Faible en confidentialité : le gestionnaire 500 (app.py:300-324) ne divulgue pas de trace et fait bien le db.session.rollback(). Le problème est la qualité de service et le bruit dans les logs d'erreur, qui masque les incidents réels. matches.py:298-299 accepte de surcroît une liste d'identifiants sans borne — un player_ids très long entraîne autant d'INSERT.
Correction. Utiliser le convertisseur intégré de Flask, déjà employé ailleurs dans le code (users.py:1089 : request.form.get('player_id', type=int)), qui renvoie None au lieu de lever :
coach_id = request.form.get('coach_id', type=int)
if coach_id is None:
flash('Coach invalide.', 'danger')
return redirect(...)
Pour les listes, valider chaque élément et plafonner la taille. À traiter globalement lors de la généralisation des schémas Marshmallow (SEC-06).
SEC-17 · 🔵
add_to_team ne vérifie pas la cohérence tryout / équipe / joueur
app/routes/tryouts.py:412-432
def add_to_team(tryout_id, team_id):
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
if not current_user.can_manage_this_tryout(tryout):
…
player_id = request.form.get('player_id')
…
member = TeamMember(team_id=team_id, player_id=int(player_id), position=position)
Le contrôle d'autorisation porte sur tryout_id, mais l'écriture porte sur team_id. Il n'est jamais vérifié que team.tryout_id == tryout_id. De même, rien ne vérifie que player_id correspond à un joueur inscrit à ce tryout — ni même à un Player.
Impact. Un coach ou manager légitime sur le tryout A peut, en modifiant team_id dans la requête, ajouter des joueurs à une équipe rattachée au tryout B qu'il ne gère pas. Il peut aussi rattacher un utilisateur non inscrit, ou un compte non-joueur (coach, admin), ce qui produira des incohérences en cascade dans les matchs et les évaluations.
Exploitation limitée aux comptes d'encadrement — d'où la sévérité faible — mais c'est un contournement du modèle d'autorisation par ailleurs bien construit.
Correction.
team = Team.query.get_or_404(team_id)
tryout = Tryout.query.get_or_404(tryout_id)
if team.tryout_id != tryout_id:
abort(404)
if not current_user.can_manage_this_tryout(tryout):
...
player_id = request.form.get('player_id', type=int)
registered = TryoutRegistration.query.filter_by(
tryout_id=tryout_id, player_id=player_id).first()
if not registered:
flash("Ce joueur n'est pas inscrit à ce tryout.", 'danger')
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
Ce motif — vérifier que la ressource enfant appartient bien au parent sur lequel porte l'autorisation — mérite d'être appliqué systématiquement. matches.py:562-575 et team_matches.py:288-301 le font correctement (if participant.match_id != match_id) et peuvent servir de référence.
SEC-18 · 🔵
Aucune réinitialisation de mot de passe ni MFA
app/routes/auth.py expose login, register et logout. Il n'existe :
- aucun mécanisme de réinitialisation de mot de passe — un utilisateur qui oublie le sien doit passer par un administrateur, qui le définira via
edit_user(route par ailleurs non validée, cf. SEC-06) et devra le lui transmettre par un canal hors bande ; - aucune authentification à deux facteurs, y compris pour le compte
Adminqui a un contrôle total sur les utilisateurs ; - aucune expiration ni politique de rotation des mots de passe.
Le verrouillage après 5 échecs (auth.py:19-20, :150-165) est en revanche bien implémenté, avec réinitialisation du compteur au succès.
Impact. Risque organisationnel plus que technique : l'absence de réinitialisation en libre-service pousse vers des pratiques de contournement (mots de passe communiqués par Discord, mots de passe partagés, comptes non désactivés au départ d'un membre). Pour une application détenant des contrats et des évaluations nominatives, l'absence de MFA sur le compte président est un point à documenter comme risque accepté, à défaut d'être corrigé.
Correction. Par ordre de rapport valeur/effort :
- Réinitialisation par courriel avec jeton signé à durée limitée —
itsdangerousest déjà présent dans les dépendances (transitif de Flask) :URLSafeTimedSerializer(app.config['SECRET_KEY']), jeton valable 30 minutes, à usage unique. - TOTP (
pyotp) sur les rôlesAdminetManageruniquement, pour limiter la friction. - Journaliser les changements de mot de passe dans
team_tryouts.auth(voir SEC-19).
SEC-19 · 🔵
Journal d'audit d'authentification déclaré mais jamais alimenté
app/logging_config.py:102-115 configure un logger dédié team_tryouts.auth, avec son propre fichier rotatif auth.log, son filtre de redaction et propagate = False. Une fabrique get_auth_logger() est fournie (:148-154).
Or aucun module n'importe get_auth_logger ni ne référence team_tryouts.auth. Le docstring de auth.py annonce pourtant « login with account lockout protection … and audit logging » — la journalisation d'audit n'existe pas.
auth.log est donc créé vide à chaque démarrage et le restera.
Impact. Aucune traçabilité des événements d'authentification : impossible de détecter une campagne de bruteforce, d'identifier l'origine d'une compromission, ou de répondre à une question aussi simple que « qui s'est connecté au compte admin la semaine dernière ». Ce manque devient bloquant si un incident survient — et les constats SEC-01 et SEC-02 rendent un incident plausible.
Correction. Alimenter le logger aux points de décision de auth.py :
from app.logging_config import get_auth_logger
auth_logger = get_auth_logger()
# succès (auth.py:140)
auth_logger.info('login success user=%s id=%s ip=%s', user.username, user.id, request.remote_addr)
# échec (auth.py:149)
auth_logger.warning('login failure user=%s ip=%s attempts=%s', username, request.remote_addr, …)
# verrouillage (auth.py:152)
auth_logger.warning('account locked user=%s ip=%s', user.username, request.remote_addr)
# déconnexion, création de compte, changement de mot de passe, changement de rôle
Étendre aux opérations sensibles de users.py : create_user, edit_user (surtout les changements de role et is_active_account), delete_user.
Attention : request.remote_addr n'aura de valeur qu'une fois SEC-05 corrigé — sans ProxyFix, toutes les entrées porteront l'IP de nginx.