# 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`](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](#sec-01--) | Secrets de production toujours récupérables dans l'historique | 🔴 Critique | | [SEC-03](#sec-03--) | CORS ouvert à toutes les origines avec credentials par défaut | 🔴 Critique | | [SEC-05](#sec-05--) | `trusted_proxy='*'` sur interface publique → en-têtes de proxy usurpables | 🟠 Élevé | | [SEC-06](#sec-06--) | Schémas de validation importés mais jamais appliqués sur les routes utilisateurs | 🟠 Élevé | | [SEC-07](#sec-07--) | Identifiant Discord modifiable malgré l'OAuth2 → détournement des notifications | 🟠 Élevé | | [SEC-08](#sec-08--) | Rate limiting en mémoire, non partagé et réinitialisé à chaque redémarrage | 🟠 Élevé | | [SEC-20](#sec-20--) | Flux OAuth2 Discord sans paramètre `state` | 🟠 Élevé | | [SEC-21](#sec-21--) | Le déploiement SFTP pousse le répertoire `.git/` sur le serveur | 🟠 Élevé | | [SEC-09](#sec-09--) | `nl2br` marque du HTML utilisateur non échappé comme sûr | 🟡 Moyen | | [SEC-10](#sec-10--) | CSP avec `'unsafe-inline'` sur `script-src` | 🟡 Moyen | | [SEC-11](#sec-11--) | Aucune validation de type sur l'upload de contrat signé | 🟡 Moyen | | [SEC-12](#sec-12--) | Énumération d'utilisateurs via les messages de login | 🟡 Moyen | | [SEC-13](#sec-13--) | CAPTCHA trivial, et limite d'inscription desserrée à 20/heure | 🟡 Moyen | | [SEC-14](#sec-14--) | `/health` expose l'erreur brute de la base de données | 🟡 Moyen | | [SEC-15](#sec-15--) | Profil complet de tout utilisateur visible par tout compte authentifié | 🟡 Moyen | | [SEC-22](#sec-22--) | `clear_db.py` : destruction totale sans garde-fou, identifiants en dur | 🟡 Moyen | | [SEC-16](#sec-16--) | Conversions `int()` non protégées sur entrées utilisateur | 🔵 Faible | | [SEC-17](#sec-17--) | `add_to_team` ne vérifie pas la cohérence tryout/équipe/joueur | 🔵 Faible | | [SEC-18](#sec-18--) | Aucune réinitialisation de mot de passe ni MFA | 🔵 Faible | | [SEC-19](#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** : ```bash 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.example` — `exemple` 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. ```python 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//view` — données personnelles. La protection CSRF limite les écritures, mais n'empêche pas ces lectures. **Correction.** ```python 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` ```python 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](#sec-19--)). **Correction.** ```python 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` : ```python 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 : ```python 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é** : ```html ``` et c'est la valeur **renvoyée par le client** qui est enregistrée (`auth.py:253` puis `:290`) : ```python 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](#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 : ```python # 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](03-standards-stack.md#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](#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` ```python 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](#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 : ```python 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](#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` ```python 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:///auth/discord/callback?code= ``` 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](#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.** ```python # 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`. - `:389` — `flash(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` ```yaml 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](#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.** ```yaml 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` ```python def nl2br(value): if value: return markupsafe.Markup('
'.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 : ```python from markupsafe import Markup, escape def nl2br(value): if not value: return '' return Markup('
').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` ```python "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 `