Files
team-tryouts/audit/01-securite.md
T
GGThedandClaude Opus 5 ed586233f6 docs(audit): rebase sur le depot de reference et reverification complete
Le miroir GitHub audite en premiere passe etait en retard de 16 commits
sur git.immortal.host/clubesportsudes/team-tryouts. L'audit est rebase
sur immortal/main @ bb0bc1c et l'ensemble des constats reverifie.

Resolus par l'equipe (archives dans 00-provenance.md) :
- seed automatique en production supprime
- print du token Discord supprime
- proxy_pass nginx corrige vers 127.0.0.1
- dossier supporting_scrits renomme

Nouveaux constats :
- SEC-20 flux OAuth2 Discord sans parametre state (CSRF de liaison)
- SEC-21 le deploiement SFTP pousse .git/ sur le serveur
- SEC-22 clear_db.py destructif sans garde-fou, admin/password en dur
- MNT-16 discord_pending.json versionne

Requalifies :
- SEC-01 secrets retires du fichier mais toujours dans l'historique des
  deux depots, et dans le HEAD du miroir GitHub -> revocation requise
- SEC-05 trusted_proxy='*' + bind 0.0.0.0 rend X-Forwarded-For usurpable,
  ce qui ouvre le rate limiting au lieu de le corriger
- SEC-07 l'OAuth2 ajoute ne contraint pas l'identite : le discord_user_id
  transite par un champ cache du formulaire
- MNT-05 psycopg[binary] non epingle, incompatible avec les URI
  postgresql:// que SQLAlchemy resout vers psycopg2

48 constats. Aucune modification du code applicatif.

Co-Authored-By: Claude Opus 5 <[email protected]>
2026-08-07 13:17:08 -04:00

44 KiB

1 — Sécurité

22 constats. Références de lignes sur immortal/main @ bb0bc1c.

Les constats résolus par l'équipe (ancien SEC-02 seed automatique, ancien SEC-04 token imprimé) sont archivés dans 00-provenance.md. Les identifiants des constats subsistants n'ont pas été renumérotés, pour préserver la traçabilité.

ID Constat Sévérité
SEC-01 Secrets de production toujours récupérables dans l'historique 🔴 Critique
SEC-03 CORS ouvert à toutes les origines avec credentials par défaut 🔴 Critique
SEC-05 trusted_proxy='*' sur interface publique → en-têtes de proxy usurpables 🟠 Élevé
SEC-06 Schémas de validation importés mais jamais appliqués sur les routes utilisateurs 🟠 Élevé
SEC-07 Identifiant Discord modifiable malgré l'OAuth2 → détournement des notifications 🟠 Élevé
SEC-08 Rate limiting en mémoire, non partagé et réinitialisé à chaque redémarrage 🟠 Élevé
SEC-20 Flux OAuth2 Discord sans paramètre state 🟠 Élevé
SEC-21 Le déploiement SFTP pousse le répertoire .git/ sur le serveur 🟠 É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 trivial, et limite d'inscription desserrée à 20/heure 🟡 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-22 clear_db.py : destruction totale sans garde-fou, identifiants en dur 🟡 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 toujours récupérables dans l'historique

Le fichier app/.env.exemple a été nettoyé au commit fd258de : les valeurs réelles y sont désormais remplacées par des marqueurs. Cela ne change rien au fait que les secrets sont compromis.

Recherche sur l'ensemble des références des deux dépôts :

$ git log --all --oneline -S "MTUyNzY3ODU3NjUyNTA1NDEyNQ"
fd258de Update app/.env.exemple
2d3721b Ajout d'un .env.exemple pour simplifier la collaboration

$ git log --all --oneline -S "0YO038Od2QcQCsCTlNDAMOAOlcPGrIND"
fd258de Update app/.env.exemple
2d3721b Ajout d'un .env.exemple pour simplifier la collaboration

Le blob introduit en 2d3721b reste atteignable par git show 2d3721b:app/.env.exemple sur immortal comme sur GitHub. Il contient :

Secret Portée
DISCORD_BOT_TOKEN Token du bot UdeS Esports — le commentaire du fichier le confirmait explicitement
DATABASE_URL PostgreSQL Render, hôte public, avec mot de passe
SECRET_KEY Clé de signature des sessions Flask

Aggravant : le miroir GitHub porte encore ces valeurs dans son HEAD. Elles sont visibles par simple navigation dans l'interface web, sans même cloner.

Impact. Inchangé depuis la première passe :

  • accès lecture/écriture complet à la base de production — identités, courriels, téléphones, notes personnelles, contrats ;
  • contrôle du bot Discord, donc envoi de messages privés en usurpant l'identité de l'organisation ;
  • avec la SECRET_KEY, forge de cookies de session Flask valides — authentification en tant que n'importe quel utilisateur, y compris admin, sans mot de passe.

Correction. Dans cet ordre :

  1. Révoquer, avant toute manipulation de git — c'est la seule action qui rend les valeurs divulguées inoffensives :
    • régénérer le token du bot (Discord Developer Portal → Bot → Reset Token) ;
    • faire tourner le mot de passe PostgreSQL sur Render ;
    • régénérer SECRET_KEY (python -c "import secrets; print(secrets.token_hex(32))") — cela invalidera toutes les sessions en cours, ce qui est le comportement souhaité.
  2. Purger l'historique des deux dépôts :
    git filter-repo --path app/.env.exemple --invert-paths
    
    puis forcer la réécriture sur toutes les branches, et prévenir les collaborateurs qu'ils doivent recloner.
  3. Traiter le miroir GitHub : soit le synchroniser après purge, soit le supprimer. Un miroir en retard de 16 commits qui expose des secrets dans son HEAD n'apporte rien et coûte beaucoup.
  4. Ajouter .env* au .gitignore avec une exception explicite : !.env.example.
  5. Ajouter un scan de secrets à la CI (gitleaks, ou detect-secrets en pre-commit).

Renommer aussi le fichier en .env.exampleexemple est un francisme qui échappe à la détection de la plupart des outils.


SEC-03 · 🔴

CORS ouvert à toutes les origines avec credentials par défaut

app/app.py:76-95 — inchangé depuis la première passe.

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 non 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 — y compris dans sa version nettoyée — ne définit toujours 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 :

  • /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 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 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-05 · 🟠

trusted_proxy='*' sur interface publique → en-têtes de proxy usurpables

wsgi.py:29-42

host = os.getenv('HOST', '0.0.0.0')  # Bind to localhost by default (Nginx reverse proxy)
...
serve(
    app, host=host, port=port, threads=threads,
    channel_timeout=30, cleanup_interval=30,
    # Waitress Proxy Settings
    trusted_proxy='*',
    trusted_proxy_count=1,
    trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
    clear_untrusted_proxy_headers=True
)

L'intention est correcte et clear_untrusted_proxy_headers=True est le bon réflexe. Deux réglages annulent le bénéfice :

  • trusted_proxy='*' demande à Waitress d'accepter X-Forwarded-For et X-Forwarded-Proto de n'importe quel pair, pas seulement de nginx ;
  • HOST vaut 0.0.0.0 — le commentaire de la ligne 26 annonce l'inverse de ce que fait le code. Le port applicatif est donc joignable directement, en contournant nginx.

Impact. La combinaison des deux rend les en-têtes de proxy entièrement contrôlables par un attaquant qui atteint le port applicatif :

  1. Contournement du rate limiting et du suivi de tentatives. app/extensions.py:16-19 utilise key_func=get_remote_address, qui lit request.remote_addr — valeur que Waitress renseigne désormais depuis X-Forwarded-For. Un attaquant qui change cet en-tête à chaque requête obtient un seau de rate limiting neuf à chaque fois : la limite de 10 per minute sur /auth/login (auth.py:96) ne s'applique plus, ni les limites par défaut 200/jour, 50/heure.

    Il faut noter que ce changement a inversé la nature du risque. Avant, remote_addr valait toujours l'IP de nginx : la protection était mal calibrée (un seau global partagé par tous) mais fermée. Elle est maintenant ouverte.

    Le verrouillage de compte après 5 échecs (auth.py:167-181) reste opérant, puisqu'il est indexé sur l'utilisateur et non sur l'IP — c'est aujourd'hui la seule barrière anti-bruteforce réellement en place.

  2. Contournement de la redirection HTTPS. app/app.py:182 teste X-Forwarded-Proto. En l'envoyant à https sur une connexion en clair, on désactive force_https() et l'application se comporte comme si la connexion était sécurisée. Même effet sur l'émission de HSTS (app.py:163).

  3. Journalisation empoisonnée. Toute IP écrite dans les logs devient une valeur fournie par le client — ce qui rend l'analyse post-incident peu fiable (et affaiblit d'avance la correction proposée en SEC-19).

Correction.

host = os.getenv('HOST', '127.0.0.1')
...
    trusted_proxy=os.getenv('TRUSTED_PROXY', '127.0.0.1'),
    trusted_proxy_count=1,
    trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
    clear_untrusted_proxy_headers=True,

Les deux corrections sont nécessaires : trusted_proxy restreint qui peut envoyer ces en-têtes, HOST restreint qui peut se connecter. Si l'hébergement Pterodactyl impose une écoute sur 0.0.0.0, renseigner alors l'adresse réelle du proxy dans TRUSTED_PROXY et filtrer le port au pare-feu.

Vérifier ensuite que request.remote_addr renvoie bien l'IP cliente, et non celle du proxy, avant de considérer le rate limiting comme fonctionnel.


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. 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 du mot de passe
edit_user :88-152 request.form.get('password')hash_password() si non vide
create_user :201-241 request.form.get('password')hash_password() sans contrôle
edit_profile :274-320 idem, sur son propre compte

Comparaison avec auth.py:228-230, où RegisterSchema est correctement chargé — l'inscription publique est validée, mais pas les trois chemins de création/modification de compte.

Impact.

  • Aucune politique de mot de passe sur ces routes : a est 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 via edit_profile.
  • Aucune validation de format sur email (le champ n'est même pas vérifié comme étant une adresse), username, phone.
  • create_user appelle hash_password(password) sans vérifier que password est non vide : le hachage d'une chaîne vide est stocké, et le compte devient accessible avec un mot de passe vide.
  • Dans edit_user, full_name et email sont assignés sans contrôle de nullité alors que les colonnes sont nullable=False (models/user_model/user.py:20-21) → IntegrityError non 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. Retirer le champ du payload quand il est vide avant le load().

Point d'attention pour edit_user : le bloc de changement de rôle par SQL brut (:114-126) exécute db.session.remove(). Toute validation doit intervenir avant ce bloc, sinon l'objet user manipulé ensuite n'est plus celui qui a été validé.


SEC-07 · 🟠

Identifiant Discord modifiable malgré l'OAuth2 → détournement des notifications

Un flux OAuth2 Discord complet a été ajouté (auth.py:326-456). Il obtient de Discord un identifiant authentifié et le stocke en session :

session['discord_oauth'] = {
    'id': user_data.get('id'),          # auth.py:448 — valeur authentifiée par Discord
    'username': user_data.get('username'),
    ...
}

Mais cette valeur est ensuite réinjectée dans le formulaire par un champ caché :

<!-- register.html:61 -->
<input type="hidden" name="discord_user_id" value="{{ discord_data.id }}">

et c'est la valeur renvoyée par le client qui est enregistrée (auth.py:253 puis :290) :

discord_user_id = validated.get('discord_user_id')
...
user = Player(..., discord_user_id=discord_user_id, ...)

La preuve d'identité obtenue de Discord est perdue au passage. Un utilisateur peut modifier le champ caché avant envoi, ou poster directement le formulaire sans jamais avoir ouvert le flux OAuth2. RegisterSchema valide désormais le format (validators.py:211-215 : 17-20 chiffres), ce qui est un progrès, mais ne prouve rien sur la propriété du compte.

La route edit_profile (users.py:284, :305), accessible à tout utilisateur authentifié, permet de la même façon de définir un identifiant arbitraire — et n'applique même pas la validation de format (SEC-06). Aucune contrainte d'unicité n'existe sur la colonne (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 :

  • demandes de sessions 1:1 avec leurs « discussion points », souvent confidentiels ;
  • notifications de matchs et d'entraînements ;
  • surtout, la capacité de répondre à la place de la cible : discord_bot.py traite les réactions / en message privé pour accepter ou refuser une demande 1:1. L'attaquant obtient 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.

Risque supplémentaire, nouveau : le flux paraît vérifié. Un relecteur qui constate la présence d'un OAuth2 Discord conclura raisonnablement que l'identifiant est authentifié. Il ne l'est pas.

Correction. La brique manquante est courte — la valeur authentifiée existe déjà en session, il suffit de ne plus la faire transiter par le client :

# auth.py, dans register() — ignorer ce que le formulaire prétend
discord_oauth = session.get('discord_oauth') or {}
discord_user_id = discord_oauth.get('id')          # seule source de vérité
discord_username = discord_oauth.get('username')

et retirer le champ caché de register.html:61 (l'afficher en lecture seule si l'on veut le montrer à l'utilisateur).

En complément :

  1. Ajouter une contrainte d'unicité sur User.discord_user_id (nécessite STD-01).
  2. Pour edit_profile, soit passer par le même flux OAuth2, soit réserver la modification de ce champ aux administrateurs.
  3. Traiter SEC-20 : sans paramètre state, le contenu de session['discord_oauth'] n'est lui-même pas digne de confiance.

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 sur son backend memory://, explicitement documenté comme non destiné à la production.

Impact.

  • L'état est par processus. Toute évolution vers plusieurs workers ou plusieurs instances fragmente les compteurs et multiplie d'autant la limite effective.
  • L'état est perdu à chaque redémarrage : les compteurs se réinitialisent à chaque redéploiement.
  • Combiné à SEC-05, la protection est de toute façon indexée sur une clé que le client contrôle.

Correction. Adosser le limiteur à un stockage partagé — Redis de préférence, ou la base PostgreSQL déjà présente :

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 rendue côté serveur, où chaque page consomme plusieurs requêtes (/matches/api/events, /users/disponibilities…). Exclure /static et /health du décompte.

Corriger SEC-05 d'abord : sans clé fiable, un stockage partagé ne sert à rien.


SEC-20 · 🟠

Flux OAuth2 Discord sans paramètre state

app/routes/auth.py:340-348

params = {
    'client_id': DISCORD_CLIENT_ID,
    'redirect_uri': DISCORD_REDIRECT_URI,
    'response_type': 'code',
    'scope': 'identify connections',
}
query = '&'.join(f'{k}={requests.utils.quote(v)}' for k, v in params.items())
auth_url = f'{DISCORD_API_BASE}/oauth2/authorize?{query}'

Aucun paramètre state n'est émis, et discord_callback (:351-456) n'en vérifie aucun : la requête de retour est acceptée sur la seule présence d'un code (:363-366).

state est le mécanisme anti-CSRF prévu par la RFC 6749 (§10.12) : une valeur aléatoire liée à la session, émise à l'aller et vérifiée au retour.

Impact. Un attaquant initie le flux avec son propre compte Discord, intercepte le code d'autorisation sans le consommer, puis amène la victime à visiter :

https://<app>/auth/discord/callback?code=<code_de_l_attaquant>

Le serveur échange ce code, obtient le profil Discord de l'attaquant, et le place dans session['discord_oauth'] — la session de la victime. Celle-ci voit alors le formulaire d'inscription pré-rempli avec l'identité Discord de l'attaquant, et l'enregistre sans raison de se méfier (le bandeau affiche « Discord account connected! », auth.py:455).

Combiné à SEC-07, c'est un chemin direct pour faire enregistrer à un tiers un discord_user_id contrôlé par l'attaquant — donc pour recevoir les notifications privées de ce compte.

Le flux ne servant aujourd'hui qu'à pré-remplir un formulaire d'inscription, la portée est bornée. Elle s'étendrait immédiatement si l'OAuth2 était réutilisé pour l'authentification ou pour la liaison de compte a posteriori, ce qui est l'évolution naturelle de ce code.

Correction.

# auth.py — discord_login()
state = secrets.token_urlsafe(32)
session['discord_oauth_state'] = state
params = {..., 'state': state}

# auth.py — discord_callback()
expected = session.pop('discord_oauth_state', None)
if not expected or not secrets.compare_digest(request.args.get('state', ''), expected):
    flash('Requête Discord invalide.', 'danger')
    return redirect(url_for('auth.register'))

Deux points secondaires dans le même bloc :

  • requests.utils.quote(v) (:346) lève un TypeError si DISCORD_REDIRECT_URI n'est pas défini — seul DISCORD_CLIENT_ID est vérifié (:336). Vérifier les trois variables, ou construire l'URL avec urllib.parse.urlencode.
  • :389flash(f'Failed to connect to Discord. Please try again.', 'danger') est une f-string sans substitution, et la variable e capturée à la ligne 388 n'est pas utilisée. Sans conséquence, mais signalé par Ruff (F541, F841).

SEC-21 · 🟠

Le déploiement SFTP pousse le répertoire .git/ sur le serveur

.gitea/workflows/git-to-ptero.yaml:41

mirror -R --verbose --parallel=4 ./ ./

mirror -R téléverse le répertoire courant du runner vers la racine distante. Après un actions/checkout, ce répertoire contient .git/ en entier — objets, packfiles, refs, historique complet.

Impact. Si la racine du dépôt est aussi la racine servie par le serveur web — ce qui est le cas par défaut sur un hébergement de type Pterodactyl où l'application est déployée telle quelle — alors .git/ devient téléchargeable. Les outils d'exploitation courants (git-dumper et équivalents) reconstruisent l'intégralité du dépôt à partir de .git/config, .git/HEAD et des objets, même sans listing de répertoire activé.

L'attaquant obtient alors le code source complet et tout l'historique — donc le commit 2d3721b et les secrets de production de SEC-01. Les deux constats se composent : la purge d'historique demandée en SEC-01 perd son intérêt si le déploiement republie l'historique à chaque exécution.

Deux points secondaires du même workflow :

  • StrictHostKeyChecking=no (:35) désactive la vérification de l'empreinte du serveur SFTP : le premier échange est vulnérable à une interception, et la clé privée de déploiement peut être présentée à un serveur usurpateur. Utiliser un known_hosts épinglé, alimenté par ssh-keyscan une fois et stocké en secret de dépôt.
  • Aucune exclusion. Outre .git/, le miroir pousse __pycache__/, les éventuels logs/ et tout fichier résiduel du runner.

Correction.

mirror -R --verbose --parallel=4 \
  --exclude-glob .git/ \
  --exclude-glob .github/ \
  --exclude-glob .gitea/ \
  --exclude-glob __pycache__/ \
  --exclude-glob '*.pyc' \
  --exclude-glob logs/ \
  --exclude-glob documents/ \
  ./ ./

Plus robuste : construire un artefact de déploiement propre (git archive HEAD | tar -x -C dist/) et ne téléverser que dist/. git archive n'inclut par construction ni .git/ ni les fichiers ignorés.

Vérifier par ailleurs, sur le serveur actuel, si .git/ est déjà présent et accessible — le workflow est déclenchable manuellement (workflow_dispatch, :4) et a pu être exécuté. Le cas échéant, supprimer le répertoire distant en plus d'appliquer le correctif.


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. Aucune utilisation de |nl2br dans les templates. Le filtre est enregistré (app.py:125) mais mort.

Risque. Piège en attente : le filtre porte un nom naturel, il est enregistré globalement, et la première personne qui écrira {{ note.content|nl2br }} — 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())

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; "
"img-src 'self' data: https://cdn.discordapp.com; "

'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 est de bonne qualité (frame-ancestors 'none', base-uri 'self', form-action 'self'). L'ajout de https://cdn.discordapp.com en img-src pour les avatars Discord est correctement ciblé.

'unsafe-inline' sur style-src est nettement moins grave et généralement toléré.

Correction. Par étapes :

  1. Extraire les <script> inline vers static/js/match_form.html (45 Ko), view_tryout.html, teams.html, calendar.html et désormais register.html (312 lignes ajoutées) en concentrent la majorité.
  2. Pour ce qui doit rester inline, générer un nonce par requête (secrets.token_urlsafe(16) dans un before_request) et passer à script-src 'self' 'nonce-{nonce}'.
  3. Épingler les ressources CDN avec integrity (SRI).

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:598-625 (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 (:558-560) qui contrôle .pdf. Les constantes ALLOWED_CONTRACT_EXTENSIONS et ALLOWED_SIGNED_EXTENSIONS (:29-30) ne sont référencées nulle part.

Facteurs atténuants. Le nom de fichier stocké est dérivé d'un UUID généré côté serveur, pas du nom fourni par le client — 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), 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.

  1. Appliquer la même vérification que upload_contract, en utilisant la constante déjà déclarée :
    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'))
    
  2. Vérifier les octets d'en-tête (%PDF-) plutôt que la seule extension.
  3. Conserver le stockage hors arborescence servie (documents/, gitignoré) — et vérifier que SEC-21 ne le republie pas.

SEC-12 · 🟡

Énumération d'utilisateurs via les messages de login

app/routes/auth.py:165-184

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 (:131-138) fuit également l'existence du compte, 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é — écart mesurable même sans lire les messages.

Impact. Constitution d'une liste d'identifiants valides, préalable à une 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 — d'autant que SEC-05 permet de contourner la limite de 10 tentatives par minute.

Correction.

  • Message identique dans tous les cas : « Identifiants invalides. », sans compteur de tentatives restantes ni mention de verrouillage.
  • Exécuter systématiquement check_password contre un hachage factice quand l'utilisateur n'existe pas :
    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 plutôt qu'à l'écran.

SEC-13 · 🟡

CAPTCHA trivial, et limite d'inscription desserrée à 20/heure

app/routes/auth.py:56-70

a = random.randint(1, 10)
b = random.randint(1, 10)
session['captcha_answer'] = a + b
return {'question': f'{a} + {b} = ?', 'id': captcha_id}

19 réponses possibles (2 à 20). La question figure 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é (:67) et stocké en session, mais n'est jamais comparé dans verify_captcha (:73-89) — le champ est décoratif.

Aggravation. La limite de la route /auth/register est passée de 3 per hour à 20 per hour (:190). Le rate limiting étant la seule protection réellement efficace ici, la barrière a été divisée par près de 7 — et SEC-05 la rend de toute façon contournable.

Impact. Création automatisée de comptes joueurs. Conséquences limitées en privilèges, mais pollution de la base, bruit dans les listes de sélection, et — combiné à SEC-15 — accès en masse aux coordonnées des membres.

Correction. Un CAPTCHA maison n'arrêtera pas de bots. Deux options :

  • Suffisant ici : valider l'inscription par un lien envoyé au courriel institutionnel, en restreignant le domaine (@usherbrooke.ca). Cela résout simultanément les 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 SEC-05 et SEC-08, qui conditionnent l'efficacité du rate limiting.


SEC-14 · 🟡

/health expose l'erreur brute de la base de données

app/app.py:200-206

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 l'hôte, le port, le nom de la base et l'utilisateur. C'est de la reconnaissance d'infrastructure offerte gratuitement, précisément au moment où le système est en difficulté.

Incohérent avec le gestionnaire 500 (:294-318), qui fait 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

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:244-249

@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 — peut énumérer /users/1/view, /users/2/view… et collecter les coordonnées de l'ensemble des membres : courriels, téléphones, identifiants Discord. Combiné à SEC-13, la barrière d'entrée est basse. Et la collecte des discord_user_id alimente directement SEC-07.

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 :

  1. Limiter les données transmises au template plutôt que de passer l'objet ORM complet.
  2. Restreindre l'accès selon la relation :
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-22 · 🟡

clear_db.py : destruction totale sans garde-fou, identifiants en dur

clear_db.py (racine du dépôt, 56 lignes) remplace l'ancien seed.py. Il supprime le contenu de 20 tables puis crée un unique compte administrateur.

tables = ['one_on_one_requests', , 'users']
for table in tables:
    db.session.execute(db.text(f'DELETE FROM {table}'))
db.session.commit()

admin = Admin(username='admin', password_hash=hash_password('password'), )

C'est une nette amélioration sur l'ancien seed : il n'est plus déclenché automatiquement au démarrage. Quatre problèmes subsistent.

  1. Aucune confirmation, aucun garde-fou d'environnement. python clear_db.py détruit l'intégralité des données de la base désignée par DATABASE_URL. Si le .env local pointe vers la base de production — ce qui était précisément la configuration livrée avant le nettoyage de .env.exemple (SEC-01) — la commande efface la production sans poser de question. Le fichier est à la racine, sous un nom qui invite à l'exécution.

  2. Mot de passe administrateur en dur. admin / password (:37), affiché en clair (:46-48). Ce mot de passe ne respecte pas la politique définie dans validators.py:22-24, que le script contourne en écrivant directement le hachage.

  3. Docstring erroné. Il indique python -m app.supporting_scripts.seed (:4) alors que le fichier est clear_db.py à la racine. Personne ne peut suivre cette instruction.

  4. Effet de bord au chargement. create_app() (:54) déclenche db.create_all() et démarre le bot Discord (STD-02, STD-03). Réinitialiser la base ouvre une connexion Discord.

Le f'DELETE FROM {table}' (:30) interpole une liste codée en dur : pas d'injection possible, mais le motif est à éviter par principe.

Impact. Perte totale de données par erreur de manipulation, et compte administrateur à mot de passe connu sur toute base réinitialisée.

Correction.

if os.getenv('ALLOW_DB_WIPE') != 'yes-i-am-sure':
    sys.exit('Refus : définir ALLOW_DB_WIPE=yes-i-am-sure pour confirmer.')

db_url = os.environ['DATABASE_URL']
print(f'Cible : {db_url.split("@")[-1]}')     # affiche l'hôte, jamais les identifiants
if input('Taper le nom de la base pour confirmer : ') != expected_db_name:
    sys.exit('Annulé.')

password = secrets.token_urlsafe(16)
admin = Admin(username='admin', password_hash=hash_password(password), )
print(f'Mot de passe admin (à noter maintenant) : {password}')

Déplacer le script hors de la racine (app/supporting_scripts/, conformément à son propre docstring) et l'exposer comme commande CLI Flask.

Action indépendante du code : vérifier en production si les comptes de l'ancien seed (admin, manager1, manager2, coach1, coach2, coach3, scout1) existent encore avec le mot de passe password. La suppression du script n'a pas supprimé les comptes.


SEC-16 · 🔵

Conversions int() non protégées sur entrées utilisateur

De nombreuses routes convertissent des champs de formulaire sans garde :

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/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 ne divulgue pas de trace et fait le db.session.rollback(). Le problème est la qualité de service et le bruit dans les logs, qui masque les incidents réels. matches.py:298-299 accepte de surcroît une liste d'identifiants sans borne.

Correction. Utiliser le convertisseur intégré, déjà employé ailleurs (users.py:1110 : request.form.get('player_id', type=int)) :

coach_id = request.form.get('coach_id', type=int)
if coach_id is None:
    flash('Coach invalide.', 'danger')
    return redirect(...)

À 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 (route /<int:tryout_id>/team/<int:team_id>/add)

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):
        
    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. Rien ne vérifie non plus 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, 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.

if team.tryout_id != tryout_id:
    abort(404)
player_id = request.form.get('player_id', type=int)
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
    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 et team_matches.py le font correctement (if participant.match_id != match_id) et servent de référence.


SEC-18 · 🔵

Aucune réinitialisation de mot de passe ni MFA

app/routes/auth.py expose login, register, discord_login, discord_callback 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 non validée, cf. SEC-06) et devra le lui transmettre hors bande ;
  • aucune authentification à deux facteurs, y compris pour le compte Admin ;
  • aucune expiration ni politique de rotation.

Le verrouillage après 5 échecs est en revanche bien implémenté, avec réinitialisation du compteur au succès — et c'est aujourd'hui, compte tenu de SEC-05, la principale barrière anti-bruteforce.

Impact. Risque organisationnel : l'absence de réinitialisation en libre-service pousse vers des pratiques de contournement (mots de passe communiqués par Discord, 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 à documenter comme risque accepté, à défaut d'être corrigé.

Correction. Par ordre de rapport valeur/effort :

  1. Réinitialisation par courriel avec jeton signé à durée limitée — itsdangerous est déjà présent : URLSafeTimedSerializer(app.config['SECRET_KEY']), jeton de 30 minutes, à usage unique.
  2. TOTP (pyotp) sur les rôles Admin et Manager uniquement, pour limiter la friction.
  3. Journaliser les changements de mot de passe (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 fichier rotatif auth.log, son filtre de redaction et propagate = False. Une fabrique get_auth_logger() est fournie (:148-154).

Aucun module n'importe get_auth_logger ni ne référence team_tryouts.auth — vérifié par recherche sur l'ensemble de app/. Le docstring de auth.py annonce pourtant « login with account lockout protection … and audit logging ».

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 à « qui s'est connecté au compte admin la semaine dernière ». Ce manque devient bloquant si un incident survient — et SEC-01 rend 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:157)
auth_logger.info('login success user=%s id=%s ip=%s', user.username, user.id, request.remote_addr)
# échec (auth.py:167)
auth_logger.warning('login failure user=%s ip=%s attempts=%s', username, request.remote_addr, )
# verrouillage (auth.py:169)
auth_logger.warning('account locked user=%s ip=%s', user.username, request.remote_addr)
# déconnexion, création de compte, callback OAuth2, changement de mot de passe et de rôle

Étendre aux opérations sensibles de users.py : create_user, edit_user (surtout role et is_active_account), delete_user.

Prérequis : corriger SEC-05 d'abord. En l'état, request.remote_addr est une valeur fournie par le client — journaliser une IP usurpable donne une traçabilité illusoire, ce qui est pire que pas de traçabilité du tout.