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]>
This commit is contained in:
@@ -0,0 +1,149 @@
|
|||||||
|
# 0 — Provenance et écart entre les dépôts
|
||||||
|
|
||||||
|
## Deux dépôts, un seul fait autorité
|
||||||
|
|
||||||
|
| Dépôt | Rôle | HEAD `main` |
|
||||||
|
|---|---|---|
|
||||||
|
| `git.immortal.host/clubesportsudes/team-tryouts` | **Référence** — c'est celui-ci qui est audité | `bb0bc1c` |
|
||||||
|
| `github.com/cedrick2711/team-tryouts` | Miroir, **en retard de 16 commits** | `08f02f7` |
|
||||||
|
|
||||||
|
Les deux partagent un ancêtre commun (`d6fe505`). Le miroir GitHub porte en outre 2 commits qui ne sont pas sur le dépôt de référence (`0d5a408 fix registration` et son merge `08f02f7`) ; le correctif correspondant existe sur `immortal` sous la forme du commit `7d30aff Corriger erreur d'enregistrement`.
|
||||||
|
|
||||||
|
**La première passe de cet audit a été menée sur le miroir GitHub**, avant que la bonne source ne soit connue. L'ensemble des constats a été revérifié contre `immortal/main`. Le présent document liste ce qui a changé.
|
||||||
|
|
||||||
|
### Les 16 commits d'écart
|
||||||
|
|
||||||
|
```
|
||||||
|
bb0bc1c ajout de plateforme de base pour les url TRN
|
||||||
|
f89e4de régler problème avec mise a jour des status de message du discord bot
|
||||||
|
fcf10bc bug fix: Manager ne pouvait pas voir les tryouts. probleme avec discord bot
|
||||||
|
68e3da6 fix probleme avec dispos
|
||||||
|
d25b35c régler erreur 500 sur changement de role par admin
|
||||||
|
aeba4d7 régler problème de changement de rôle
|
||||||
|
7bb8022 added a clear_db to start fresh with only an admin
|
||||||
|
0dd4ecd Erreur de frappe dans un des dossiers
|
||||||
|
0f7788e Changement du layout de la page d'enregistrement
|
||||||
|
47d5ec4 added discord oauth2 to get basic user info to complete profile when registering
|
||||||
|
10af8c0 small change for prod
|
||||||
|
7d30aff Corriger erreur d'enregistrement
|
||||||
|
4097416 Update wsgi.py
|
||||||
|
056cea0 Update .gitea/workflows/git-to-ptero.yaml
|
||||||
|
d3b5700 Add .gitea/workflows/git-to-ptero.yaml
|
||||||
|
fd258de Update app/.env.exemple
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constats de la première passe devenus caducs
|
||||||
|
|
||||||
|
Quatre constats ont été résolus par l'équipe entre les deux points de l'historique. Ils sont conservés ici pour mémoire, et retirés du décompte des documents 01 à 03.
|
||||||
|
|
||||||
|
### ✅ Seed automatique en production — résolu
|
||||||
|
|
||||||
|
Le bloc de `app/app.py` qui déclenchait le seed quand la table `users` était vide a été supprimé (commit `7bb8022`), et `app/supporting_scrits/seed.py` (460 lignes, comptes `manager1`, `coach1`, `scout1`… tous avec le mot de passe `password`) a été supprimé.
|
||||||
|
|
||||||
|
```diff
|
||||||
|
with app.app_context():
|
||||||
|
import app.models as models
|
||||||
|
- from app.models import User
|
||||||
|
db.create_all()
|
||||||
|
-
|
||||||
|
- # Seed database if empty
|
||||||
|
- if User.query.count() == 0:
|
||||||
|
- from app.supporting_scrits.seed import seed_database
|
||||||
|
- seed_database()
|
||||||
|
```
|
||||||
|
|
||||||
|
Un déploiement neuf ne crée donc plus de comptes par défaut.
|
||||||
|
|
||||||
|
> **Reste à traiter.** Le script de remplacement `clear_db.py` code toujours en dur `admin` / `password`, et il est bien plus dangereux que l'ancien seed sur un autre plan. Voir [SEC-22](01-securite.md#sec-22--).
|
||||||
|
>
|
||||||
|
> **À vérifier en production**, indépendamment du code : les comptes créés par l'ancien seed (`admin`, `manager1`, `manager2`, `coach1`, `coach2`, `coach3`, `scout1`) peuvent toujours exister en base avec le mot de passe `password`. La suppression du script ne supprime pas les comptes qu'il a créés.
|
||||||
|
|
||||||
|
### ✅ Token Discord imprimé sur stdout — résolu
|
||||||
|
|
||||||
|
La ligne `print(DISCORD_BOT_TOKEN or 'FAILED TO PRINT BOT TOKEN')` de `app/discord_bot.py:25` a été supprimée. Le module ne comporte plus aucun `print()`.
|
||||||
|
|
||||||
|
### ✅ `proxy_pass` vers `0.0.0.0` — résolu
|
||||||
|
|
||||||
|
`app/nginx.conf:122` : `proxy_pass http://0.0.0.0:5000;` → `proxy_pass http://127.0.0.1:5000;`
|
||||||
|
|
||||||
|
> **Reste à traiter.** L'incohérence de ports subsiste : `wsgi.py:24` utilise toujours `PORT` par défaut à **10000** alors que nginx envoie vers **5000** et que le docstring du même fichier annonce 5000. Voir [STD-06](03-standards-stack.md#std-06--).
|
||||||
|
|
||||||
|
### ✅ Faute de frappe `supporting_scrits` — résolu
|
||||||
|
|
||||||
|
Le dossier a été renommé `app/supporting_scripts/` (commit `0dd4ecd`).
|
||||||
|
|
||||||
|
> **Effet de bord non traité.** Le job CI « Security Scan » invoquait déjà un chemin erroné (`python security_scan.py` à la racine) ; le renommage l'éloigne encore. Voir [MNT-04](02-maintenabilite.md#mnt-04--).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Constats aggravés ou requalifiés
|
||||||
|
|
||||||
|
### ⚠️ Secrets exposés — le nettoyage n'a pas révoqué
|
||||||
|
|
||||||
|
`app/.env.exemple` a été nettoyé (commit `fd258de`) : les valeurs réelles ont été remplacées par des marqueurs (`flask_app_secret_key`, `my_discord_bot_token`, `URI_vers_db_posgres`).
|
||||||
|
|
||||||
|
**Mais les secrets restent intégralement récupérables.** Vérification par recherche dans l'historique complet des deux dépôts :
|
||||||
|
|
||||||
|
```
|
||||||
|
$ git log --all --oneline -S "MTUyNzY3ODU3NjUyNTA1NDEyNQ"
|
||||||
|
fd258de Update app/.env.exemple ← retrait
|
||||||
|
2d3721b Ajout d'un .env.exemple pour simplifier la collaboration ← introduction
|
||||||
|
```
|
||||||
|
|
||||||
|
Le blob contenant le token Discord, le mot de passe PostgreSQL et la `SECRET_KEY` est toujours atteignable par `git show 2d3721b:app/.env.exemple` sur **les deux dépôts**. Il est de surcroît **toujours dans le HEAD du miroir GitHub**, donc visible par simple navigation dans l'interface web.
|
||||||
|
|
||||||
|
Le constat reste donc critique et sa correction inchangée : **révoquer, puis purger**. Voir [SEC-01](01-securite.md#sec-01--).
|
||||||
|
|
||||||
|
### ⚠️ En-têtes de proxy — le correctif a inversé le risque
|
||||||
|
|
||||||
|
`wsgi.py` a reçu un bloc de configuration proxy (commit `4097416`) :
|
||||||
|
|
||||||
|
```python
|
||||||
|
trusted_proxy='*',
|
||||||
|
trusted_proxy_count=1,
|
||||||
|
trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
|
||||||
|
clear_untrusted_proxy_headers=True
|
||||||
|
```
|
||||||
|
|
||||||
|
L'intention est bonne, et `clear_untrusted_proxy_headers=True` est le bon réflexe. Mais `trusted_proxy='*'` signifie « faire confiance aux en-têtes de proxy quelle qu'en soit la provenance », et `HOST` vaut toujours `0.0.0.0` par défaut (`wsgi.py:26`).
|
||||||
|
|
||||||
|
Avant ce changement, `request.remote_addr` valait toujours l'IP de nginx : le rate limiting était appliqué à un seau global — gênant, mais fermé. Désormais, un attaquant qui atteint directement le port applicatif contrôle `X-Forwarded-For` et peut donc **présenter une IP différente à chaque requête**, ce qui neutralise le rate limiting et le suivi de tentatives par IP.
|
||||||
|
|
||||||
|
Le constat change de nature et reste élevé. Voir [SEC-05](01-securite.md#sec-05--).
|
||||||
|
|
||||||
|
### ⚠️ Identifiant Discord — la vérification ajoutée n'en est pas une
|
||||||
|
|
||||||
|
Un flux OAuth2 Discord complet a été ajouté à l'inscription (commit `47d5ec4`, `auth.py:326-456`). Il obtient de Discord un identifiant authentifié et le place en session.
|
||||||
|
|
||||||
|
Mais cet identifiant est ensuite réinjecté dans le formulaire d'inscription **par un champ caché** :
|
||||||
|
|
||||||
|
```html
|
||||||
|
<!-- register.html:61 -->
|
||||||
|
<input type="hidden" name="discord_user_id" value="{{ discord_data.id }}">
|
||||||
|
```
|
||||||
|
|
||||||
|
et c'est cette valeur — passée par le client — qui est enregistrée (`auth.py:253`, `:290`). Un utilisateur peut la modifier avant envoi, ou poster directement le formulaire sans jamais passer par Discord.
|
||||||
|
|
||||||
|
`RegisterSchema` valide désormais le format de `discord_user_id` (17-20 chiffres, `validators.py:211-215`), ce qui est un progrès, mais ne prouve rien sur la propriété du compte.
|
||||||
|
|
||||||
|
La conséquence pratique est inchangée par rapport à la première passe — et le risque de fausse assurance est nouveau, puisque le flux *paraît* vérifié. Voir [SEC-07](01-securite.md#sec-07--).
|
||||||
|
|
||||||
|
### ⚠️ Limite d'inscription desserrée
|
||||||
|
|
||||||
|
`auth.py:190` : `@limiter.limit("3 per hour")` → `@limiter.limit("20 per hour")`.
|
||||||
|
|
||||||
|
Le CAPTCHA arithmétique étant trivial ([SEC-13](01-securite.md#sec-13--)), le rate limiting était la protection réellement efficace contre la création automatisée de comptes. Passer à 20/heure la divise par un facteur ~7, et [SEC-05](01-securite.md#sec-05--) la rend contournable.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Éléments nouveaux ajoutés à l'audit
|
||||||
|
|
||||||
|
| Élément | Constat |
|
||||||
|
|---|---|
|
||||||
|
| `auth.py:326-456` — flux OAuth2 Discord | [SEC-20](01-securite.md#sec-20--) absence de paramètre `state` · [SEC-07](01-securite.md#sec-07--) identité non contraignante |
|
||||||
|
| `.gitea/workflows/git-to-ptero.yaml` | [SEC-21](01-securite.md#sec-21--) déploiement de `.git/` par SFTP |
|
||||||
|
| `clear_db.py` | [SEC-22](01-securite.md#sec-22--) script destructif sans garde-fou |
|
||||||
|
| `migrations/add_tryout_coaches.py` | [STD-01](03-standards-stack.md#std-01--) migration manuelle hors outillage |
|
||||||
|
| `discord_pending.json` | [MNT-16](02-maintenabilite.md#mnt-16--) état d'exécution versionné |
|
||||||
+370
-233
@@ -1,24 +1,27 @@
|
|||||||
# 1 — Sécurité
|
# 1 — Sécurité
|
||||||
|
|
||||||
19 constats. Les références de lignes correspondent à `main` @ `08f02f7`.
|
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é |
|
| ID | Constat | Sévérité |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [SEC-01](#sec-01--) | Secrets de production réels committés dans le dépôt | 🔴 Critique |
|
| [SEC-01](#sec-01--) | Secrets de production toujours récupérables dans l'historique | 🔴 Critique |
|
||||||
| [SEC-02](#sec-02--) | Seed automatique en production avec mot de passe `password` | 🔴 Critique |
|
|
||||||
| [SEC-03](#sec-03--) | CORS ouvert à toutes les origines avec credentials par défaut | 🔴 Critique |
|
| [SEC-03](#sec-03--) | CORS ouvert à toutes les origines avec credentials par défaut | 🔴 Critique |
|
||||||
| [SEC-04](#sec-04--) | Token du bot Discord imprimé sur stdout à l'import | 🔴 Critique |
|
| [SEC-05](#sec-05--) | `trusted_proxy='*'` sur interface publique → en-têtes de proxy usurpables | 🟠 Élevé |
|
||||||
| [SEC-05](#sec-05--) | En-têtes de proxy non validés → contournement HTTPS + rate limiting inopérant | 🟠 Élevé |
|
|
||||||
| [SEC-06](#sec-06--) | Schémas de validation importés mais jamais appliqués sur les routes utilisateurs | 🟠 Élevé |
|
| [SEC-06](#sec-06--) | Schémas de validation importés mais jamais appliqués sur les routes utilisateurs | 🟠 Élevé |
|
||||||
| [SEC-07](#sec-07--) | `discord_user_id` arbitraire → détournement des notifications privées | 🟠 É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-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-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-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-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-12](#sec-12--) | Énumération d'utilisateurs via les messages de login | 🟡 Moyen |
|
||||||
| [SEC-13](#sec-13--) | CAPTCHA arithmétique trivial | 🟡 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-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-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-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-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-18](#sec-18--) | Aucune réinitialisation de mot de passe ni MFA | 🔵 Faible |
|
||||||
@@ -28,77 +31,53 @@
|
|||||||
|
|
||||||
## SEC-01 · 🔴
|
## SEC-01 · 🔴
|
||||||
|
|
||||||
**Secrets de production réels committés dans le dépôt**
|
**Secrets de production toujours récupérables dans l'historique**
|
||||||
|
|
||||||
`app/.env.exemple` — fichier **suivi par git** — ne contient pas des valeurs d'exemple mais des identifiants réels :
|
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.**
|
||||||
|
|
||||||
| Ligne | Secret |
|
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 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `:6` | `SECRET_KEY=65476749453935` — clé de signature des sessions Flask |
|
| `DISCORD_BOT_TOKEN` | Token du bot UdeS Esports — le commentaire du fichier le confirmait explicitement |
|
||||||
| `:18` | `DISCORD_BOT_TOKEN=MTUyNzY3ODU3NjUyNTA1NDEyNQ.G1gPNQ.LeFV…` — token du bot UdeS Esports |
|
| `DATABASE_URL` | PostgreSQL Render, hôte public, **avec mot de passe** |
|
||||||
| `: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 |
|
| `SECRET_KEY` | Clé de signature des sessions Flask |
|
||||||
|
|
||||||
Le commentaire ligne 16-17 confirme explicitement qu'il s'agit du token de production : *« This is the UdeS Esports BOT token »*.
|
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.** 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 :
|
**Impact.** Inchangé depuis la première passe :
|
||||||
- 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 ;
|
- accès lecture/écriture complet à la base de production — identités, courriels, téléphones, notes personnelles, contrats ;
|
||||||
- le contrôle du bot Discord (envoi de DM en usurpant l'identité de l'organisation) ;
|
- contrôle du bot Discord, donc envoi de messages privés 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 compris `admin`, sans mot de passe.
|
- 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.
|
||||||
|
|
||||||
Le `.gitignore` ignore bien `.env`, mais le fichier a été committé sous le nom `.env.exemple`, qui échappe à la règle.
|
**Correction.** Dans cet ordre :
|
||||||
|
|
||||||
**Présent dans l'historique** depuis le commit `2d3721b` (*« Ajout d'un .env.exemple pour simplifier la collaboration »*). Supprimer le fichier ne suffira pas.
|
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) ;
|
||||||
**Correction.**
|
- faire tourner le mot de passe PostgreSQL sur Render ;
|
||||||
1. **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 la `SECRET_KEY` invalidera toutes les sessions en cours, ce qui est le comportement souhaité ici.
|
- 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. Remplacer le contenu du fichier par des valeurs factices (`SECRET_KEY=<générer avec …>`, `DATABASE_URL=postgresql://user:password@host:5432/dbname`).
|
2. **Purger l'historique des deux dépôts** :
|
||||||
3. 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.
|
```bash
|
||||||
4. Ajouter `.env*` (avec l'astérisque) au `.gitignore`, en gardant une exception explicite pour le modèle : `!.env.example`.
|
git filter-repo --path app/.env.exemple --invert-paths
|
||||||
5. Ajouter un scan de secrets à la CI (`gitleaks`, ou `detect-secrets` en 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`
|
|
||||||
|
|
||||||
```python
|
|
||||||
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()
|
|
||||||
```
|
```
|
||||||
|
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).
|
||||||
|
|
||||||
Ce bloc s'exécute **à chaque appel de `create_app()`**, sans distinction d'environnement — donc aussi via `wsgi.py`, c'est-à-dire en production.
|
> Renommer aussi le fichier en `.env.example` — `exemple` est un francisme qui échappe à la détection de la plupart des outils.
|
||||||
|
|
||||||
`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` :
|
|
||||||
|
|
||||||
```python
|
|
||||||
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`, `scout1` existent avec ces mots de passe, et les désactiver le cas échéant.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -106,7 +85,7 @@ Le mot de passe `password` ne respecte d'ailleurs pas la politique définie dans
|
|||||||
|
|
||||||
**CORS ouvert à toutes les origines avec credentials par défaut**
|
**CORS ouvert à toutes les origines avec credentials par défaut**
|
||||||
|
|
||||||
`app/app.py:76-95`
|
`app/app.py:76-95` — inchangé depuis la première passe.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
|
allowed_origins = os.getenv('CORS_ALLOWED_ORIGINS', '').split(',')
|
||||||
@@ -120,16 +99,16 @@ else:
|
|||||||
CORS(app, supports_credentials=True, methods=[…], max_age=3600)
|
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 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` **ne définit pas `CORS_ALLOWED_ORIGINS`** : la configuration livrée aux équipes tombe donc systématiquement dans la branche permissive.
|
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` — notamment :
|
**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 ;
|
- `/users/disponibilities` — disponibilités de tous les joueurs actifs, avec noms d'utilisateur ;
|
||||||
- `/matches/api/events` : calendrier complet, participants, sessions 1:1 approuvées ;
|
- `/matches/api/events` — calendrier complet, participants, sessions 1:1 approuvées ;
|
||||||
- `/users/profile`, `/users/<id>/view` : données personnelles.
|
- `/users/profile`, `/users/<id>/view` — données personnelles.
|
||||||
|
|
||||||
La protection CSRF (`CSRFProtect`) limite les écritures, mais n'empêche pas ces lectures.
|
La protection CSRF limite les écritures, mais n'empêche pas ces lectures.
|
||||||
|
|
||||||
**Correction.**
|
**Correction.**
|
||||||
|
|
||||||
@@ -142,75 +121,61 @@ elif app.debug:
|
|||||||
# sinon : pas de CORS du tout — même origine uniquement
|
# 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.
|
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-04 · 🔴
|
|
||||||
|
|
||||||
**Token du bot Discord imprimé sur stdout à l'import**
|
|
||||||
|
|
||||||
`app/discord_bot.py:24-25`
|
|
||||||
|
|
||||||
```python
|
|
||||||
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 :
|
|
||||||
|
|
||||||
```python
|
|
||||||
logger.info('Discord bot token: %s', 'configuré' if DISCORD_BOT_TOKEN else 'ABSENT')
|
|
||||||
```
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SEC-05 · 🟠
|
## SEC-05 · 🟠
|
||||||
|
|
||||||
**En-têtes de proxy non validés → contournement HTTPS et rate limiting inopérant**
|
**`trusted_proxy='*'` sur interface publique → en-têtes de proxy usurpables**
|
||||||
|
|
||||||
L'application lit `X-Forwarded-Proto` pour décider d'appliquer HSTS et la redirection HTTPS :
|
`wsgi.py:29-42`
|
||||||
|
|
||||||
`app/app.py:163` — `is_https = request.is_secure or request.headers.get('X-Forwarded-Proto') == 'https'`
|
```python
|
||||||
`app/app.py:182` — `if not request.is_secure and request.headers.get('X-Forwarded-Proto') != 'https':`
|
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
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
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.
|
L'intention est correcte et `clear_untrusted_proxy_headers=True` est le bon réflexe. Deux réglages annulent le bénéfice :
|
||||||
|
|
||||||
Ce défaut est amplifié par la configuration réseau :
|
- **`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.
|
||||||
|
|
||||||
- `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**.
|
**Impact.** La combinaison des deux rend les en-têtes de proxy entièrement contrôlables par un attaquant qui atteint le port applicatif :
|
||||||
- `app/nginx.conf:122` — `proxy_pass http://0.0.0.0:5000;` — `0.0.0.0` n'est pas une adresse de destination valide comme cible amont ; ce devrait être `127.0.0.1`.
|
|
||||||
|
|
||||||
**Impact.**
|
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`.
|
||||||
1. 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.
|
|
||||||
2. En envoyant `X-Forwarded-Proto: https` sur cette connexion en clair, on désactive la redirection HTTPS de `force_https()` et l'application se comporte comme si la connexion était sécurisée.
|
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.
|
||||||
3. **Corollaire plus grave — le rate limiting est neutralisé.** `app/extensions.py:16-19` utilise `key_func=get_remote_address`, qui lit `request.remote_addr`. Sans `ProxyFix`, cette valeur est l'IP de nginx pour *toutes* les requêtes proxifiées. Conséquences :
|
|
||||||
- la limite de `10 per minute` sur `/auth/login` (`auth.py:79`) devient un **seau global partagé par tous les utilisateurs** — la protection anti-bruteforce ne fonctionne pas par attaquant ;
|
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.
|
||||||
- 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/heure` s'appliquent à l'ensemble du trafic, ce qui rendra l'application inutilisable en usage normal dès quelques utilisateurs simultanés.
|
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.**
|
**Correction.**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from werkzeug.middleware.proxy_fix import ProxyFix
|
host = os.getenv('HOST', '127.0.0.1')
|
||||||
|
...
|
||||||
# après la création de l'app, uniquement si l'on est réellement derrière un proxy
|
trusted_proxy=os.getenv('TRUSTED_PROXY', '127.0.0.1'),
|
||||||
if os.getenv('BEHIND_PROXY', 'false').lower() == 'true':
|
trusted_proxy_count=1,
|
||||||
app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1, x_port=1)
|
trusted_proxy_headers={'x-forwarded-for', 'x-forwarded-proto'},
|
||||||
|
clear_untrusted_proxy_headers=True,
|
||||||
```
|
```
|
||||||
|
|
||||||
`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.
|
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.
|
||||||
|
|
||||||
En complément :
|
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.
|
||||||
- `wsgi.py` : passer le défaut de `HOST` à `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.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -218,23 +183,23 @@ En complément :
|
|||||||
|
|
||||||
**Schémas de validation importés mais jamais appliqués sur les routes utilisateurs**
|
**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.
|
`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 :
|
Les trois routes concernées lisent le formulaire brut :
|
||||||
|
|
||||||
| Route | Lignes | Traitement |
|
| Route | Lignes | Traitement du mot de passe |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `create_user` | `:196-226` | `request.form.get('password')` → `hash_password(password)` directement |
|
| `edit_user` | `:88-152` | `request.form.get('password')` → `hash_password()` si non vide |
|
||||||
| `edit_user` | `:98-133` | `password = request.form.get('password')` → `hash_password(password)` si non vide |
|
| `create_user` | `:201-241` | `request.form.get('password')` → `hash_password()` sans contrôle |
|
||||||
| `edit_profile` | `:255-295` | idem, sur son propre compte |
|
| `edit_profile` | `:274-320` | 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.
|
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.**
|
**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 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`.
|
- **Aucune validation de format** sur `email` (le champ n'est même pas vérifié comme étant une adresse), `username`, `phone`.
|
||||||
- `create_user` (`:216`) appelle `hash_password(password)` sans vérifier que `password` est non vide : un `password_hash` d'une chaîne vide est stocké, et le compte devient accessible avec un mot de passe vide.
|
- `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` (`:115-118`), `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.
|
- 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` :
|
**Correction.** Appliquer les schémas déjà écrits, sur le modèle de `auth.py` :
|
||||||
|
|
||||||
@@ -249,36 +214,69 @@ except ValidationError as err:
|
|||||||
return render_template('pages/edit_profile.html', ...)
|
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()`.
|
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 · 🟠
|
## SEC-07 · 🟠
|
||||||
|
|
||||||
**`discord_user_id` arbitraire → détournement des notifications privées**
|
**Identifiant Discord modifiable malgré l'OAuth2 → détournement des notifications**
|
||||||
|
|
||||||
`app/routes/users.py:262-263` et `:283-284` (route `edit_profile`, accessible à **tout utilisateur authentifié**) :
|
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
|
```python
|
||||||
discord_user_id = request.form.get('discord_user_id', '').strip()
|
session['discord_oauth'] = {
|
||||||
|
'id': user_data.get('id'), # auth.py:448 — valeur authentifiée par Discord
|
||||||
|
'username': user_data.get('username'),
|
||||||
...
|
...
|
||||||
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`).
|
Mais cette valeur est ensuite **réinjectée dans le formulaire par un champ caché** :
|
||||||
|
|
||||||
**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 :
|
```html
|
||||||
- les demandes de sessions 1:1 avec leurs *« discussion points »*, souvent confidentiels ;
|
<!-- register.html:61 -->
|
||||||
- les notifications de matchs et d'entraînements ;
|
<input type="hidden" name="discord_user_id" value="{{ discord_data.id }}">
|
||||||
- surtout, **la capacité de répondre à la place de la cible** : `discord_bot.py` traite 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.
|
```
|
||||||
|
|
||||||
|
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.
|
Deux utilisateurs peuvent en outre déclarer le même identifiant, ce qui rend le comportement non déterministe.
|
||||||
|
|
||||||
**Correction.**
|
**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.
|
||||||
1. 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.
|
|
||||||
2. Ajouter une contrainte d'unicité sur `User.discord_user_id`.
|
**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 :
|
||||||
3. **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.
|
|
||||||
4. En attendant, réserver la modification de ce champ aux administrateurs.
|
```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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -295,14 +293,14 @@ limiter = Limiter(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
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).
|
Aucun `storage_uri` n'est fourni. Flask-Limiter bascule sur son backend `memory://`, explicitement documenté comme non destiné à la production.
|
||||||
|
|
||||||
**Impact.**
|
**Impact.**
|
||||||
- L'état est **par processus**. `wsgi.py:25` démarre Waitress avec `cpu_count() * 2 + 1` threads — 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 **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** : un attaquant peut réinitialiser les compteurs si un redéploiement survient, et le verrouillage anti-bruteforce ne survit pas aux mises à jour.
|
- 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 appliquée à la mauvaise clé.
|
- 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 si l'on veut éviter une dépendance supplémentaire :
|
**Correction.** Adosser le limiteur à un stockage partagé — Redis de préférence, ou la base PostgreSQL déjà présente :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
limiter = Limiter(
|
limiter = Limiter(
|
||||||
@@ -312,7 +310,104 @@ limiter = Limiter(
|
|||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
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.
|
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://<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](#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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -331,9 +426,9 @@ def nl2br(value):
|
|||||||
|
|
||||||
`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()`.
|
`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.
|
**Statut actuel : non exploitable.** Aucune utilisation de `|nl2br` dans les templates. 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.
|
**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 :
|
**Correction.** Échapper avant de marquer :
|
||||||
|
|
||||||
@@ -346,7 +441,7 @@ def nl2br(value):
|
|||||||
return Markup('<br>').join(escape(str(value)).splitlines())
|
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.
|
Alternative sans code : supprimer le filtre et utiliser `white-space: pre-line` en CSS.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -359,20 +454,21 @@ def nl2br(value):
|
|||||||
```python
|
```python
|
||||||
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
"script-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net; "
|
||||||
"style-src 'self' 'unsafe-inline' https://cdnjs.cloudflare.com 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.
|
`'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'`).
|
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é.
|
`'unsafe-inline'` sur `style-src` est nettement moins grave et généralement toléré.
|
||||||
|
|
||||||
**Correction.** Chemin réaliste, par étapes :
|
**Correction.** Par étapes :
|
||||||
1. Extraire les `<script>` inline des templates vers des fichiers sous `static/js/` — un audit rapide montre que `match_form.html` (44 Ko), `view_tryout.html` (31 Ko), `teams.html` (23 Ko) et `calendar.html` (16 Ko) en concentrent la majorité.
|
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`, injecté dans le contexte Jinja) et passer à `script-src 'self' 'nonce-{nonce}'`.
|
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) plutôt que de faire confiance à l'origine seule.
|
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.
|
En attendant, traiter [SEC-09](#sec-09--) : sans point d'injection HTML, la CSP affaiblie n'est pas exploitable.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -380,7 +476,7 @@ En attendant, traiter SEC-09 : sans point d'injection HTML, la CSP affaiblie n'e
|
|||||||
|
|
||||||
**Aucune validation de type sur l'upload de contrat signé**
|
**Aucune validation de type sur l'upload de contrat signé**
|
||||||
|
|
||||||
`app/routes/users.py:577-604` (`upload_signed_contract`) :
|
`app/routes/users.py:598-625` (`upload_signed_contract`) :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
file = request.files['signed_file']
|
file = request.files['signed_file']
|
||||||
@@ -392,23 +488,22 @@ signed_filename = f"signed_{contract.stored_filename}"
|
|||||||
file.save(contract.file_path.replace(contract.stored_filename, signed_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).
|
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 (`: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.
|
**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.
|
**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.**
|
**Correction.**
|
||||||
1. Appliquer la même vérification que `upload_contract`, en utilisant les constantes déjà déclarées :
|
1. Appliquer la même vérification que `upload_contract`, en utilisant la constante déjà déclarée :
|
||||||
```python
|
```python
|
||||||
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
ext = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else ''
|
||||||
if ext not in ALLOWED_SIGNED_EXTENSIONS:
|
if ext not in ALLOWED_SIGNED_EXTENSIONS:
|
||||||
flash('Seuls les fichiers PDF sont acceptés.', 'danger')
|
flash('Seuls les fichiers PDF sont acceptés.', 'danger')
|
||||||
return redirect(url_for('users.list_contracts'))
|
return redirect(url_for('users.list_contracts'))
|
||||||
```
|
```
|
||||||
2. Ne pas se fier à l'extension seule : vérifier les octets d'en-tête (`%PDF-`) après lecture, ou utiliser `python-magic`.
|
2. Vérifier les octets d'en-tête (`%PDF-`) plutôt que la seule extension.
|
||||||
3. Servir les téléchargements avec `Content-Type: application/pdf` explicite et `Content-Disposition: attachment` (déjà le cas via `as_attachment=True`).
|
3. Conserver le stockage hors arborescence servie (`documents/`, gitignoré) — et vérifier que [SEC-21](#sec-21--) ne le republie pas.
|
||||||
4. Stocker les documents hors de l'arborescence servie par le serveur web — c'est déjà le cas (`documents/`, gitignoré), à préserver.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -416,7 +511,7 @@ Aucune vérification d'extension ni de type — contrairement à `upload_contrac
|
|||||||
|
|
||||||
**Énumération d'utilisateurs via les messages de login**
|
**Énumération d'utilisateurs via les messages de login**
|
||||||
|
|
||||||
`app/routes/auth.py:148-167`
|
`app/routes/auth.py:165-184`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
if user:
|
if user:
|
||||||
@@ -430,29 +525,29 @@ else:
|
|||||||
flash('Login unsuccessful. Please check username and password.', 'danger')
|
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**.
|
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é — un écart mesurable même sans lire les messages.
|
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 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.
|
**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](#sec-05--) permet de contourner la limite de 10 tentatives par minute.
|
||||||
|
|
||||||
**Correction.**
|
**Correction.**
|
||||||
- Retourner un message identique dans tous les cas : *« Identifiants invalides. »*, sans compteur de tentatives restantes ni mention de verrouillage.
|
- 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, pour égaliser les temps de réponse :
|
- Exécuter systématiquement `check_password` contre un hachage factice quand l'utilisateur n'existe pas :
|
||||||
```python
|
```python
|
||||||
DUMMY_HASH = generate_password_hash('dummy-password-for-timing-equalization')
|
DUMMY_HASH = generate_password_hash('dummy-password-for-timing-equalization')
|
||||||
...
|
...
|
||||||
check_password(user.password_hash if user else DUMMY_HASH, password)
|
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.
|
- Notifier le verrouillage par courriel au titulaire plutôt qu'à l'écran.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## SEC-13 · 🟡
|
## SEC-13 · 🟡
|
||||||
|
|
||||||
**CAPTCHA arithmétique trivial**
|
**CAPTCHA trivial, et limite d'inscription desserrée à 20/heure**
|
||||||
|
|
||||||
`app/routes/auth.py:39-53`
|
`app/routes/auth.py:56-70`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
a = random.randint(1, 10)
|
a = random.randint(1, 10)
|
||||||
@@ -461,19 +556,19 @@ session['captcha_answer'] = a + b
|
|||||||
return {'question': f'{a} + {b} = ?', 'id': captcha_id}
|
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.
|
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é (`: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.
|
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.
|
||||||
|
|
||||||
**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).
|
**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](#sec-05--) la rend de toute façon contournable.
|
||||||
|
|
||||||
**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.
|
**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](#sec-15--) — accès en masse aux coordonnées des membres.
|
||||||
|
|
||||||
**Correction.** Si l'objectif est réellement d'arrêter des bots, un CAPTCHA maison n'y parviendra pas. Deux options selon l'ambition :
|
**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 le problème des comptes jetables et vérifie l'appartenance à l'organisation.
|
- **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.
|
- 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.
|
Dans tous les cas, corriger [SEC-05](#sec-05--) et [SEC-08](#sec-08--), qui conditionnent l'efficacité du rate limiting.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -481,7 +576,7 @@ Dans tous les cas, corriger le rate limiting (SEC-05/SEC-08), qui est ici la pro
|
|||||||
|
|
||||||
**`/health` expose l'erreur brute de la base de données**
|
**`/health` expose l'erreur brute de la base de données**
|
||||||
|
|
||||||
`app/app.py:206-212`
|
`app/app.py:200-206`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
@@ -490,11 +585,11 @@ except Exception as e:
|
|||||||
return jsonify(health_data), 503
|
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.
|
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é.
|
**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é.
|
||||||
|
|
||||||
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 »*.
|
Incohérent avec le gestionnaire 500 (`:294-318`), qui fait l'inverse et le documente : *« Never exposes stack traces to users »*.
|
||||||
|
|
||||||
**Correction.**
|
**Correction.**
|
||||||
|
|
||||||
@@ -504,7 +599,7 @@ except Exception:
|
|||||||
return jsonify({'status': 'unhealthy', 'database': 'unreachable'}), 503
|
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.
|
Envisager aussi de restreindre `/health` au réseau interne via nginx.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -512,7 +607,7 @@ Le détail part dans les logs, où il est utile ; le client reçoit un statut bi
|
|||||||
|
|
||||||
**Profil complet de tout utilisateur visible par tout compte authentifié**
|
**Profil complet de tout utilisateur visible par tout compte authentifié**
|
||||||
|
|
||||||
`app/routes/users.py:231-236`
|
`app/routes/users.py:244-249`
|
||||||
|
|
||||||
```python
|
```python
|
||||||
@users_bp.route('/<int:user_id>/view')
|
@users_bp.route('/<int:user_id>/view')
|
||||||
@@ -526,13 +621,13 @@ Seule l'authentification est vérifiée. Aucun contrôle d'appartenance à la m
|
|||||||
|
|
||||||
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`).
|
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.
|
**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](#sec-13--), la barrière d'entrée est basse. Et la collecte des `discord_user_id` alimente directement [SEC-07](#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.
|
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 :
|
**Correction.** Deux niveaux, à combiner :
|
||||||
1. **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.
|
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 : membres d'une même `OrgTeam`, ou rôles d'encadrement (`Admin`, `Manager`, `Coach` de l'équipe du joueur, `Scout`).
|
2. **Restreindre l'accès** selon la relation :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def can_view_profile(viewer, target):
|
def can_view_profile(viewer, target):
|
||||||
@@ -544,26 +639,76 @@ def can_view_profile(viewer, target):
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
## 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.
|
||||||
|
|
||||||
|
```python
|
||||||
|
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](#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](03-standards-stack.md#std-02--), [STD-03](03-standards-stack.md#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.**
|
||||||
|
|
||||||
|
```python
|
||||||
|
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 · 🔵
|
## SEC-16 · 🔵
|
||||||
|
|
||||||
**Conversions `int()` non protégées sur entrées utilisateur**
|
**Conversions `int()` non protégées sur entrées utilisateur**
|
||||||
|
|
||||||
De nombreuses routes convertissent des champs de formulaire sans garde. Exemples :
|
De nombreuses routes convertissent des champs de formulaire sans garde :
|
||||||
|
|
||||||
| Fichier | Ligne | Code |
|
| Fichier | Ligne | Code |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `routes/teams.py` | `:120-121` | `int(coach_id) if coach_id else None` |
|
| `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/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` | `: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` | `: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')` |
|
| `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.
|
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.
|
**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é 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 :
|
**Correction.** Utiliser le convertisseur intégré, déjà employé ailleurs (`users.py:1110` : `request.form.get('player_id', type=int)`) :
|
||||||
|
|
||||||
```python
|
```python
|
||||||
coach_id = request.form.get('coach_id', type=int)
|
coach_id = request.form.get('coach_id', type=int)
|
||||||
@@ -572,7 +717,7 @@ if coach_id is None:
|
|||||||
return redirect(...)
|
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).
|
À traiter globalement lors de la généralisation des schémas Marshmallow ([SEC-06](#sec-06--)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -580,7 +725,7 @@ Pour les listes, valider chaque élément et plafonner la taille. À traiter glo
|
|||||||
|
|
||||||
**`add_to_team` ne vérifie pas la cohérence tryout / équipe / joueur**
|
**`add_to_team` ne vérifie pas la cohérence tryout / équipe / joueur**
|
||||||
|
|
||||||
`app/routes/tryouts.py:412-432`
|
`app/routes/tryouts.py` (route `/<int:tryout_id>/team/<int:team_id>/add`)
|
||||||
|
|
||||||
```python
|
```python
|
||||||
def add_to_team(tryout_id, team_id):
|
def add_to_team(tryout_id, team_id):
|
||||||
@@ -588,35 +733,27 @@ def add_to_team(tryout_id, team_id):
|
|||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
tryout = Tryout.query.get_or_404(tryout_id)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
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)
|
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`.
|
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 (coach, admin), ce qui produira des incohérences en cascade dans les matchs et les évaluations.
|
**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.
|
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.**
|
**Correction.**
|
||||||
|
|
||||||
```python
|
```python
|
||||||
team = Team.query.get_or_404(team_id)
|
|
||||||
tryout = Tryout.query.get_or_404(tryout_id)
|
|
||||||
if team.tryout_id != tryout_id:
|
if team.tryout_id != tryout_id:
|
||||||
abort(404)
|
abort(404)
|
||||||
if not current_user.can_manage_this_tryout(tryout):
|
|
||||||
...
|
|
||||||
player_id = request.form.get('player_id', type=int)
|
player_id = request.form.get('player_id', type=int)
|
||||||
registered = TryoutRegistration.query.filter_by(
|
if not TryoutRegistration.query.filter_by(tryout_id=tryout_id, player_id=player_id).first():
|
||||||
tryout_id=tryout_id, player_id=player_id).first()
|
|
||||||
if not registered:
|
|
||||||
flash("Ce joueur n'est pas inscrit à ce tryout.", 'danger')
|
flash("Ce joueur n'est pas inscrit à ce tryout.", 'danger')
|
||||||
return redirect(url_for('tryouts.view_tryout', tryout_id=tryout_id))
|
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.
|
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.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -624,19 +761,19 @@ Ce motif — *vérifier que la ressource enfant appartient bien au parent sur le
|
|||||||
|
|
||||||
**Aucune réinitialisation de mot de passe ni MFA**
|
**Aucune réinitialisation de mot de passe ni MFA**
|
||||||
|
|
||||||
`app/routes/auth.py` expose `login`, `register` et `logout`. Il n'existe :
|
`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 par ailleurs non validée, cf. SEC-06) et devra le lui transmettre par un canal hors bande ;
|
- **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](#sec-06--)) et devra le lui transmettre hors bande ;
|
||||||
- **aucune authentification à deux facteurs**, y compris pour le compte `Admin` qui a un contrôle total sur les utilisateurs ;
|
- **aucune authentification à deux facteurs**, y compris pour le compte `Admin` ;
|
||||||
- **aucune expiration ni politique de rotation** des mots de passe.
|
- **aucune expiration ni politique de rotation**.
|
||||||
|
|
||||||
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.
|
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](#sec-05--), la principale barrière anti-bruteforce.
|
||||||
|
|
||||||
**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é.
|
**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 :
|
**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 dans les dépendances (transitif de Flask) : `URLSafeTimedSerializer(app.config['SECRET_KEY'])`, jeton valable 30 minutes, à usage unique.
|
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.
|
2. TOTP (`pyotp`) sur les rôles `Admin` et `Manager` uniquement, pour limiter la friction.
|
||||||
3. Journaliser les changements de mot de passe dans `team_tryouts.auth` (voir SEC-19).
|
3. Journaliser les changements de mot de passe ([SEC-19](#sec-19--)).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -644,13 +781,13 @@ Le verrouillage après 5 échecs (`auth.py:19-20`, `:150-165`) est en revanche b
|
|||||||
|
|
||||||
**Journal d'audit d'authentification déclaré mais jamais alimenté**
|
**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`).
|
`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`).
|
||||||
|
|
||||||
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.
|
**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.
|
`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.
|
**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](#sec-01--) rend un incident plausible.
|
||||||
|
|
||||||
**Correction.** Alimenter le logger aux points de décision de `auth.py` :
|
**Correction.** Alimenter le logger aux points de décision de `auth.py` :
|
||||||
|
|
||||||
@@ -658,15 +795,15 @@ Or **aucun module n'importe `get_auth_logger` ni ne référence `team_tryouts.au
|
|||||||
from app.logging_config import get_auth_logger
|
from app.logging_config import get_auth_logger
|
||||||
auth_logger = get_auth_logger()
|
auth_logger = get_auth_logger()
|
||||||
|
|
||||||
# succès (auth.py:140)
|
# succès (auth.py:157)
|
||||||
auth_logger.info('login success user=%s id=%s ip=%s', user.username, user.id, request.remote_addr)
|
auth_logger.info('login success user=%s id=%s ip=%s', user.username, user.id, request.remote_addr)
|
||||||
# échec (auth.py:149)
|
# échec (auth.py:167)
|
||||||
auth_logger.warning('login failure user=%s ip=%s attempts=%s', username, request.remote_addr, …)
|
auth_logger.warning('login failure user=%s ip=%s attempts=%s', username, request.remote_addr, …)
|
||||||
# verrouillage (auth.py:152)
|
# verrouillage (auth.py:169)
|
||||||
auth_logger.warning('account locked user=%s ip=%s', user.username, request.remote_addr)
|
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
|
# 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 les changements de `role` et `is_active_account`), `delete_user`.
|
Étendre aux opérations sensibles de `users.py` : `create_user`, `edit_user` (surtout `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.
|
**Prérequis :** corriger [SEC-05](#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.
|
||||||
|
|||||||
+66
-20
@@ -1,6 +1,8 @@
|
|||||||
# 2 — Maintenabilité
|
# 2 — Maintenabilité
|
||||||
|
|
||||||
15 constats sur la structure du code, l'outillage et la chaîne de livraison.
|
16 constats sur la structure du code, l'outillage et la chaîne de livraison.
|
||||||
|
|
||||||
|
Références de lignes sur `immortal/main` @ `bb0bc1c`. Le renommage `supporting_scrits` → `supporting_scripts` relevé lors de la première passe a été effectué par l'équipe — voir [`00-provenance.md`](00-provenance.md).
|
||||||
|
|
||||||
| ID | Constat | Sévérité |
|
| ID | Constat | Sévérité |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
@@ -18,7 +20,8 @@
|
|||||||
| [MNT-12](#mnt-12--) | Duplication du parsing date/heure dans quatre modules | 🔵 Faible |
|
| [MNT-12](#mnt-12--) | Duplication du parsing date/heure dans quatre modules | 🔵 Faible |
|
||||||
| [MNT-13](#mnt-13--) | `datetime.utcnow()` déprécié — 12 occurrences | 🔵 Faible |
|
| [MNT-13](#mnt-13--) | `datetime.utcnow()` déprécié — 12 occurrences | 🔵 Faible |
|
||||||
| [MNT-14](#mnt-14--) | Aucune pagination sur les listes | 🔵 Faible |
|
| [MNT-14](#mnt-14--) | Aucune pagination sur les listes | 🔵 Faible |
|
||||||
| [MNT-15](#mnt-15--) | README en décalage avec le code, et dossier `supporting_scrits` mal orthographié | 🔵 Faible |
|
| [MNT-15](#mnt-15--) | README en décalage avec le code | 🔵 Faible |
|
||||||
|
| [MNT-16](#mnt-16--) | Fichier d'état d'exécution `discord_pending.json` versionné | 🔵 Faible |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -157,7 +160,9 @@ Ajouter `pytest`, `pytest-cov` et `factory-boy` à un `requirements-dev.txt`, pu
|
|||||||
run: python security_scan.py --skip-http
|
run: python security_scan.py --skip-http
|
||||||
```
|
```
|
||||||
|
|
||||||
Le script se trouve en réalité à `app/supporting_scrits/security_scan.py`. Il n'y a pas de `security_scan.py` à la racine du dépôt.
|
Le script se trouve en réalité à `app/supporting_scripts/security_scan.py`. Il n'y a pas de `security_scan.py` à la racine du dépôt.
|
||||||
|
|
||||||
|
> Le renommage du dossier (commit `0dd4ecd`, `supporting_scrits` → `supporting_scripts`) **n'a pas corrigé la CI** : le chemin invoqué était déjà erroné avant, il l'est toujours après.
|
||||||
|
|
||||||
Le job échoue donc à chaque exécution avec `can't open file 'security_scan.py': [Errno 2] No such file or directory`. Contrairement au job `test`, celui-ci n'a pas de `continue-on-error` : il apparaît **en rouge en permanence**.
|
Le job échoue donc à chaque exécution avec `can't open file 'security_scan.py': [Errno 2] No such file or directory`. Contrairement au job `test`, celui-ci n'a pas de `continue-on-error` : il apparaît **en rouge en permanence**.
|
||||||
|
|
||||||
@@ -175,7 +180,7 @@ Deux problèmes secondaires dans le même job :
|
|||||||
SECRET_KEY: ${{ secrets.CI_SECRET_KEY }}
|
SECRET_KEY: ${{ secrets.CI_SECRET_KEY }}
|
||||||
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci
|
DATABASE_URL: postgresql://postgres:postgres@localhost:5432/ci
|
||||||
FLASK_DEBUG: 'false'
|
FLASK_DEBUG: 'false'
|
||||||
run: python -m app.supporting_scrits.security_scan --skip-http
|
run: python -m app.supporting_scripts.security_scan --skip-http
|
||||||
```
|
```
|
||||||
|
|
||||||
Et faire échouer le job si le script renvoie un code non nul, ce qu'il fait déjà via son `exit(main())`.
|
Et faire échouer le job si le script renvoie un code non nul, ce qu'il fait déjà via son `exit(main())`.
|
||||||
@@ -198,7 +203,19 @@ Vérifier aussi que le job `lint` passe (voir MNT-06) : dans l'état actuel, `te
|
|||||||
|
|
||||||
**Impact.** Surface d'approvisionnement élargie sans contrepartie : trois paquets supplémentaires exécutent leur `setup.py` à l'installation et sont dans le chemin d'import. Les paquets aux noms proches de bibliothèques populaires (`dotenv`, `login`) sont précisément la cible privilégiée des attaques par confusion de dépendances. Ils sont ici bénins, mais leur présence indique que la liste n'est pas relue.
|
**Impact.** Surface d'approvisionnement élargie sans contrepartie : trois paquets supplémentaires exécutent leur `setup.py` à l'installation et sont dans le chemin d'import. Les paquets aux noms proches de bibliothèques populaires (`dotenv`, `login`) sont précisément la cible privilégiée des attaques par confusion de dépendances. Ils sont ici bénins, mais leur présence indique que la liste n'est pas relue.
|
||||||
|
|
||||||
Par ailleurs, `psycopg2==2.9.12` (`:35`) exige une chaîne de compilation C et les en-têtes PostgreSQL ; `psycopg2-binary` est le choix usuel pour un déploiement sans compilation, ou `psycopg[binary]` (v3) pour un projet neuf.
|
**Un quatrième point mérite une vérification urgente.** Le pilote PostgreSQL est passé de `psycopg2==2.9.12` à **`psycopg[binary]`, sans version épinglée** — seule entrée non épinglée d'un fichier qui l'est partout ailleurs. Deux conséquences :
|
||||||
|
|
||||||
|
- La reproductibilité est rompue : deux installations à quelques semaines d'intervalle n'obtiendront pas la même version du pilote.
|
||||||
|
- **`psycopg[binary]` est psycopg 3, pas psycopg2.** Or SQLAlchemy 2.0 résout le préfixe `postgresql://` vers le dialecte **psycopg2** par défaut. Si `DATABASE_URL` commence par `postgresql://` — ce qu'indiquait le fichier d'exemple avant son nettoyage — la création du moteur lèvera `ModuleNotFoundError: No module named 'psycopg2'` au démarrage.
|
||||||
|
|
||||||
|
Le fonctionnement actuel suppose donc que la variable de production utilise la forme explicite `postgresql+psycopg://…`. **À vérifier** : si l'application tourne, c'est le cas ; sinon, c'est la cause du dysfonctionnement. Dans les deux situations, la dépendance implicite entre le format de l'URL et le pilote installé doit être documentée, ou levée en normalisant l'URI au démarrage :
|
||||||
|
|
||||||
|
```python
|
||||||
|
url = os.getenv('DATABASE_URL', '')
|
||||||
|
if url.startswith('postgresql://'):
|
||||||
|
url = url.replace('postgresql://', 'postgresql+psycopg://', 1)
|
||||||
|
app.config['SQLALCHEMY_DATABASE_URI'] = url
|
||||||
|
```
|
||||||
|
|
||||||
**Correction.**
|
**Correction.**
|
||||||
1. Retirer `dotenv`, `login` et `discord` — vérifier au préalable qu'aucun `import` ne les référence (aucun n'apparaît dans le code).
|
1. Retirer `dotenv`, `login` et `discord` — vérifier au préalable qu'aucun `import` ne les référence (aucun n'apparaît dans le code).
|
||||||
@@ -208,11 +225,12 @@ Par ailleurs, `psycopg2==2.9.12` (`:35`) exige une chaîne de compilation C et l
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"Flask~=3.1", "Flask-SQLAlchemy~=3.1", "Flask-Login~=0.6",
|
"Flask~=3.1", "Flask-SQLAlchemy~=3.1", "Flask-Login~=0.6",
|
||||||
"Flask-WTF~=1.3", "Flask-Limiter~=4.1", "flask-cors~=6.0",
|
"Flask-WTF~=1.3", "Flask-Limiter~=4.1", "flask-cors~=6.0",
|
||||||
"SQLAlchemy~=2.0", "psycopg2-binary~=2.9", "marshmallow~=4.3",
|
"SQLAlchemy~=2.0", "psycopg[binary]~=3.2", "marshmallow~=4.3",
|
||||||
"python-dotenv~=1.2", "discord.py~=2.7", "APScheduler~=3.11",
|
"python-dotenv~=1.2", "discord.py~=2.7", "APScheduler~=3.11",
|
||||||
"waitress~=3.0", "requests~=2.34",
|
"waitress~=3.0", "requests~=2.34",
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
En épinglant `psycopg`, retenir une contrainte de version — l'absence d'épinglage est le point le plus risqué de la liste actuelle.
|
||||||
3. Fournir un `requirements-dev.txt` (`pytest`, `pytest-cov`, `ruff`, `pip-audit`).
|
3. Fournir un `requirements-dev.txt` (`pytest`, `pytest-cov`, `ruff`, `pip-audit`).
|
||||||
4. Une fois MNT-02 corrigé, générer des hachages (`pip-compile --generate-hashes`) pour que `pip-audit --require-hashes` de `ci.yml:31` ait un sens.
|
4. Une fois MNT-02 corrigé, générer des hachages (`pip-compile --generate-hashes`) pour que `pip-audit --require-hashes` de `ci.yml:31` ait un sens.
|
||||||
|
|
||||||
@@ -302,23 +320,25 @@ Volumétrie des modules de routes :
|
|||||||
|
|
||||||
| Module | Lignes |
|
| Module | Lignes |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `users.py` | **1 245** |
|
| `users.py` | **1 265** |
|
||||||
| `matches.py` | 583 |
|
| `matches.py` | 583 |
|
||||||
|
| `auth.py` | 473 |
|
||||||
| `teams.py` | 455 |
|
| `teams.py` | 455 |
|
||||||
| `tryouts.py` | 432 |
|
| `tryouts.py` | ~430 |
|
||||||
| `team_matches.py` | 309 |
|
| `team_matches.py` | 309 |
|
||||||
| `auth.py` | 296 |
|
|
||||||
| `evaluations.py` | 233 |
|
| `evaluations.py` | 233 |
|
||||||
| `main.py` | 139 |
|
| `main.py` | 139 |
|
||||||
|
|
||||||
`users.py` regroupe six domaines fonctionnels sans lien entre eux, matérialisés par les commentaires de section du fichier lui-même :
|
`users.py` regroupe six domaines fonctionnels sans lien entre eux, matérialisés par les commentaires de section du fichier lui-même :
|
||||||
|
|
||||||
1. gestion des comptes (`:76-228`) — CRUD administrateur ;
|
1. gestion des comptes (`:76-241`) — CRUD administrateur ;
|
||||||
2. profil personnel (`:231-299`) ;
|
2. profil personnel (`:244-320`) ;
|
||||||
3. disponibilités (`:302-441`) — API JSON ;
|
3. disponibilités (`:323-462`) — API JSON ;
|
||||||
4. contrats (`:444-629`) — téléversement et téléchargement de fichiers ;
|
4. contrats (`:465-650`) — téléversement et téléchargement de fichiers ;
|
||||||
5. sessions 1:1 (`:632-888`) — dont l'intégration Discord ;
|
5. sessions 1:1 (`:653-909`) — dont l'intégration Discord ;
|
||||||
6. notes (`:891-1245`) — personnelles et d'équipe, avec quatre routes de création presque identiques.
|
6. notes (`:912-1265`) — personnelles et d'équipe, avec quatre routes de création presque identiques.
|
||||||
|
|
||||||
|
`auth.py` a par ailleurs gagné 177 lignes avec le flux OAuth2 Discord (`:326-456`) et suit la même trajectoire : authentification par mot de passe, CAPTCHA et fédération d'identité désormais dans un seul fichier.
|
||||||
|
|
||||||
**Impact.** Un fichier de cette taille concentre les conflits de fusion, ralentit la navigation, et rend la revue de code superficielle. Le symptôme visible ici : les trois schémas de validation importés en tête ont cessé d'être utilisés (SEC-06) sans que personne ne le remarque, parce que la déclaration et l'usage sont séparés par plusieurs centaines de lignes.
|
**Impact.** Un fichier de cette taille concentre les conflits de fusion, ralentit la navigation, et rend la revue de code superficielle. Le symptôme visible ici : les trois schémas de validation importés en tête ont cessé d'être utilisés (SEC-06) sans que personne ne le remarque, parce que la déclaration et l'usage sont séparés par plusieurs centaines de lignes.
|
||||||
|
|
||||||
@@ -564,9 +584,9 @@ Pour les listes déroulantes, préférer un point d'API filtré par saisie (auto
|
|||||||
|
|
||||||
## MNT-15 · 🔵
|
## MNT-15 · 🔵
|
||||||
|
|
||||||
**README en décalage avec le code, et dossier `supporting_scrits` mal orthographié**
|
**README en décalage avec le code**
|
||||||
|
|
||||||
**README.** La section « Security Features Implemented » (`README.md:11-21`) énonce des garanties qui ne correspondent pas au code :
|
La section « Security Features Implemented » (`README.md:11-21`) énonce des garanties qui ne correspondent pas au code :
|
||||||
|
|
||||||
| Affirmation | Réalité |
|
| Affirmation | Réalité |
|
||||||
|---|---|
|
|---|---|
|
||||||
@@ -578,8 +598,34 @@ Le README ne mentionne par ailleurs ni la commande de démarrage exacte, ni la v
|
|||||||
|
|
||||||
**Impact.** Une documentation de sécurité fausse est plus nuisible qu'absente : elle sert de base aux décisions de déploiement et coupe court aux vérifications. C'est vraisemblablement ce qui explique que les constats SEC-06 et SEC-15 aient survécu.
|
**Impact.** Une documentation de sécurité fausse est plus nuisible qu'absente : elle sert de base aux décisions de déploiement et coupe court aux vérifications. C'est vraisemblablement ce qui explique que les constats SEC-06 et SEC-15 aient survécu.
|
||||||
|
|
||||||
**Nommage.** Le dossier `app/supporting_scrits/` contient une faute (`scrits` → `scripts`). Elle se propage dans tous les imports (`app.py:355`, `ci.yml`, docstrings) et dans les chemins que les développeurs tapent quotidiennement.
|
Le README ne documente par ailleurs **rien de ce qui a été ajouté depuis** : ni le flux OAuth2 Discord et ses trois variables d'environnement (`DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET`, `DISCORD_REDIRECT_URI`), ni `clear_db.py`, ni la chaîne de déploiement `.gitea/workflows/git-to-ptero.yaml`, ni le fait que le dépôt de référence n'est pas GitHub.
|
||||||
|
|
||||||
**Correction.**
|
**Correction.**
|
||||||
- Réécrire la section sécurité pour décrire l'état réel, en distinguant ce qui est implémenté de ce qui est prévu. Y ajouter les prérequis, l'installation et le démarrage.
|
- Réécrire la section sécurité pour décrire l'état réel, en distinguant ce qui est implémenté de ce qui est prévu.
|
||||||
- Renommer le dossier en `app/supporting_scripts/` (`git mv`), en mettant à jour `app/app.py:355` et `.github/workflows/ci.yml:72` — ce dernier étant de toute façon à corriger (MNT-04).
|
- Ajouter les prérequis (version de Python, PostgreSQL obligatoire au démarrage), l'installation, le démarrage, et le tableau des variables d'environnement.
|
||||||
|
- **Indiquer explicitement quel dépôt fait autorité.** C'est ce qui a manqué ici : l'absence de cette mention a conduit à auditer un miroir en retard de 16 commits. Un `README.md` sur le miroir GitHub renvoyant vers `git.immortal.host` — ou la suppression du miroir — éviterait la récidive.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## MNT-16 · 🔵
|
||||||
|
|
||||||
|
**Fichier d'état d'exécution `discord_pending.json` versionné**
|
||||||
|
|
||||||
|
Le fichier `discord_pending.json` est suivi par git à la racine du dépôt. Son contenu actuel :
|
||||||
|
|
||||||
|
```json
|
||||||
|
{}
|
||||||
|
```
|
||||||
|
|
||||||
|
Le nom et l'usage indiquent un état d'exécution du bot Discord — vraisemblablement le suivi des demandes en attente de réaction, équivalent persistant du dictionnaire `self.pending_requests` de `discord_bot.py:52`.
|
||||||
|
|
||||||
|
**Impact.** Un fichier d'état écrit par l'application et versionné pose trois problèmes :
|
||||||
|
|
||||||
|
1. **Conflits de fusion permanents.** Dès que le bot écrit dedans, le fichier apparaît modifié dans `git status`. Chaque développeur qui lance l'application localement produit une modification non intentionnelle, qu'il committera par inadvertance ou devra écarter à chaque fois.
|
||||||
|
2. **Écrasement au déploiement.** Le workflow SFTP ([SEC-21](01-securite.md#sec-21--)) pousse le contenu du dépôt sur le serveur : chaque déploiement **remet l'état du bot à `{}`**, perdant les demandes en attente. Les réactions ✅/❌ sur les messages Discord antérieurs cesseront d'être reconnues.
|
||||||
|
3. **Fuite potentielle.** Selon ce qui y est stocké (identifiants Discord, identifiants de demandes 1:1), le fichier peut contenir des données personnelles qui n'ont rien à faire dans un dépôt.
|
||||||
|
|
||||||
|
**Correction.**
|
||||||
|
- Retirer le fichier du suivi (`git rm --cached discord_pending.json`) et l'ajouter au `.gitignore`.
|
||||||
|
- Mieux : persister cet état **en base**, où vivent déjà les `OneOnOneRequest`. Un fichier JSON local ne survit ni au déploiement, ni à la mise à l'échelle ([STD-03](03-standards-stack.md#std-03--)), et n'est pas partageable entre plusieurs instances du bot.
|
||||||
|
- Vérifier au passage qu'aucune donnée personnelle n'a été committée dans ses versions antérieures (`git log -p -- discord_pending.json`).
|
||||||
|
|||||||
+28
-15
@@ -30,7 +30,13 @@ with app.app_context():
|
|||||||
db.create_all()
|
db.create_all()
|
||||||
```
|
```
|
||||||
|
|
||||||
Ni `Flask-Migrate` ni `alembic` ne figurent dans `requirements.txt`, et il n'existe pas de dossier `migrations/`.
|
Ni `Flask-Migrate` ni `alembic` ne figurent dans `requirements.txt`.
|
||||||
|
|
||||||
|
Un dossier `migrations/` existe désormais, mais il ne contient **pas** un outillage de migration : c'est un script unique écrit à la main, `migrations/add_tryout_coaches.py`, qui exécute du DDL en SQL brut et vérifie lui-même son idempotence en interrogeant `information_schema`.
|
||||||
|
|
||||||
|
Le script est correct dans ce qu'il fait — le SQL n'interpole aucune entrée utilisateur, la création de table est conditionnelle, et la migration de données vérifie qu'elle n'a pas déjà eu lieu. Il illustre surtout le manque : chaque évolution de schéma exige de réécrire à la main ce qu'Alembic fournit. Il n'y a ni table de version, ni ordonnancement, ni migration inverse, ni moyen de savoir quelles migrations ont été appliquées sur quelle base — l'idempotence est réimplémentée au cas par cas.
|
||||||
|
|
||||||
|
Il hérite en outre des effets de bord de la fabrique : `create_app()` à la ligne 15 déclenche `db.create_all()` et **démarre le bot Discord** ([STD-02](#std-02--), [STD-03](#std-03--)). Appliquer une migration ouvre une connexion Discord.
|
||||||
|
|
||||||
`db.create_all()` **ne crée que les tables absentes**. Il ne modifie jamais une table existante : ajouter une colonne, changer un type, ajouter une contrainte d'unicité ou une clé étrangère `ON DELETE CASCADE` n'a aucun effet sur une base déjà initialisée.
|
`db.create_all()` **ne crée que les tables absentes**. Il ne modifie jamais une table existante : ajouter une colonne, changer un type, ajouter une contrainte d'unicité ou une clé étrangère `ON DELETE CASCADE` n'a aucun effet sur une base déjà initialisée.
|
||||||
|
|
||||||
@@ -78,11 +84,17 @@ et retirer `db.create_all()` de la fabrique — les migrations s'appliquent lors
|
|||||||
|
|
||||||
| Ligne | Effet de bord |
|
| Ligne | Effet de bord |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `:351` | `db.create_all()` — écriture dans le schéma |
|
| `:349` | `db.create_all()` — écriture dans le schéma |
|
||||||
| `:354-356` | Insertion des données de démonstration si la table est vide |
|
| `:353-355` | Démarrage d'un thread portant un client Discord |
|
||||||
| `:359-361` | Démarrage d'un thread portant un client Discord |
|
|
||||||
| `logging_config.py:66-67` | `os.makedirs('logs')` dans le répertoire de travail courant |
|
| `logging_config.py:66-67` | `os.makedirs('logs')` dans le répertoire de travail courant |
|
||||||
|
|
||||||
|
L'insertion automatique de données de démonstration a été retirée (voir [`00-provenance.md`](00-provenance.md)) — c'était le plus grave de ces effets de bord. Les deux autres subsistent, et leurs conséquences se voient désormais très concrètement dans les deux scripts utilitaires ajoutés depuis :
|
||||||
|
|
||||||
|
- `clear_db.py:54` appelle `create_app()` pour vider la base — **et démarre donc un bot Discord** au passage ;
|
||||||
|
- `migrations/add_tryout_coaches.py:15` fait de même pour appliquer une migration de schéma.
|
||||||
|
|
||||||
|
Aucun de ces deux scripts n'a besoin de Discord ; tous deux l'obtiennent, parce qu'il n'existe pas de moyen de construire l'application sans.
|
||||||
|
|
||||||
Le motif de fabrique d'application (*application factory*) existe précisément pour permettre de construire plusieurs instances configurées différemment — c'est ce qui rend une application Flask testable. Ici, chaque appel à `create_app()` écrit dans la base et ouvre une connexion Discord.
|
Le motif de fabrique d'application (*application factory*) existe précisément pour permettre de construire plusieurs instances configurées différemment — c'est ce qui rend une application Flask testable. Ici, chaque appel à `create_app()` écrit dans la base et ouvre une connexion Discord.
|
||||||
|
|
||||||
**Impact.**
|
**Impact.**
|
||||||
@@ -191,7 +203,9 @@ app.config['SESSION_COOKIE_SECURE'] = os.getenv('SESSION_COOKIE_SECURE', 'true')
|
|||||||
...
|
...
|
||||||
```
|
```
|
||||||
|
|
||||||
D'autres réglages sont lus directement via `os.getenv()` au fil du code, hors de `app.config` : `FORCE_HTTPS` (`app.py:183`), `LOG_LEVEL` et `FLASK_DEBUG` (`logging_config.py:73,133`), `DISCORD_WEBHOOK_URL` (`users.py:636`, au moment de l'import), `DISCORD_BOT_TOKEN` (`discord_bot.py:24`). `load_dotenv()` est appelé dans deux modules distincts (`app.py:16`, `discord_bot.py:23`).
|
D'autres réglages sont lus directement via `os.getenv()` au fil du code, hors de `app.config` : `FORCE_HTTPS` (`app.py:183`), `LOG_LEVEL` et `FLASK_DEBUG` (`logging_config.py:73,133`), `DISCORD_WEBHOOK_URL` (`users.py:657`), `DISCORD_BOT_TOKEN` (`discord_bot.py:24`) et, depuis l'ajout de l'OAuth2, `DISCORD_CLIENT_ID`, `DISCORD_CLIENT_SECRET` et `DISCORD_REDIRECT_URI` (`auth.py:25-27`). `load_dotenv()` est appelé dans deux modules distincts (`app.py:16`, `discord_bot.py:23`).
|
||||||
|
|
||||||
|
Les trois dernières sont lues **au moment de l'import du module**, comme `DISCORD_WEBHOOK_URL`. Conséquence pratique : elles ne peuvent être ni surchargées en test, ni rechargées sans redémarrer le processus. Et rien ne vérifie leur présence au démarrage — `discord_login` teste `DISCORD_CLIENT_ID` (`auth.py:336`) mais pas les deux autres, si bien qu'un `DISCORD_REDIRECT_URI` manquant produit un `TypeError` à la ligne 346 au lieu d'un message clair (cf. [SEC-20](01-securite.md#sec-20--)).
|
||||||
|
|
||||||
Il n'existe **aucune notion d'environnement** : les mêmes valeurs par défaut s'appliquent en développement, en CI et en production. C'est la cause directe de SEC-02 (seed en production) et de SEC-03 (CORS permissif par défaut).
|
Il n'existe **aucune notion d'environnement** : les mêmes valeurs par défaut s'appliquent en développement, en CI et en production. C'est la cause directe de SEC-02 (seed en production) et de SEC-03 (CORS permissif par défaut).
|
||||||
|
|
||||||
@@ -238,26 +252,25 @@ Un seul `load_dotenv()`, au point d'entrée. Toute lecture de configuration pass
|
|||||||
| Fichier | Ligne | Écoute / cible |
|
| Fichier | Ligne | Écoute / cible |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| `run.py` | `:26` | `app.run(host='127.0.0.2', port=5000)` |
|
| `run.py` | `:26` | `app.run(host='127.0.0.2', port=5000)` |
|
||||||
| `app/app.py` | `:377` | `app.run(host='0.0.0.0', port=10000)` |
|
| `app/app.py` | `:371` | `app.run(host='0.0.0.0', port=10000)` |
|
||||||
| `wsgi.py` | `:24-26` | `PORT` défaut **10000**, `HOST` défaut `0.0.0.0` |
|
| `wsgi.py` | `:24-26` | `PORT` défaut **10000**, `HOST` défaut `0.0.0.0` |
|
||||||
| `app/.env.exemple` | `:25-26` | `HOST=127.0.0.1`, `PORT=5000` |
|
| `app/.env.exemple` | `:31-32` | `HOST=127.0.0.1`, `PORT=5000` |
|
||||||
| `app/nginx.conf` | `:122` | `proxy_pass http://0.0.0.0:5000` |
|
| `app/nginx.conf` | `:122` | `proxy_pass http://127.0.0.1:5000` ✅ corrigé |
|
||||||
|
|
||||||
Quatre combinaisons différentes pour trois points d'entrée. Points notables :
|
Le `proxy_pass` a été corrigé (commit `10af8c0`) : il visait `0.0.0.0:5000`, ce qui n'a pas de sens comme adresse de destination. Il reste quatre combinaisons différentes pour trois points d'entrée. Points notables :
|
||||||
|
|
||||||
- **`127.0.0.2` dans `run.py`** est presque certainement une faute de frappe pour `127.0.0.1`. L'adresse est techniquement valide (tout le bloc `127.0.0.0/8` est en boucle locale sous Linux), mais elle n'est pas joignable sous Windows sans configuration supplémentaire — or le projet cible Windows (Waitress, `nginx.conf` avec des chemins `C:/`).
|
- **`127.0.0.2` dans `run.py`** est presque certainement une faute de frappe pour `127.0.0.1`. L'adresse est techniquement valide (tout le bloc `127.0.0.0/8` est en boucle locale sous Linux), mais elle n'est pas joignable sous Windows sans configuration supplémentaire.
|
||||||
- **`proxy_pass http://0.0.0.0:5000`** : `0.0.0.0` désigne « toutes les interfaces » comme adresse d'écoute, mais n'a pas de sens comme adresse de destination. La cible correcte est `127.0.0.1:5000`.
|
- **`wsgi.py` écoute sur 10000 par défaut, nginx envoie désormais vers 5000.** Sans `PORT=5000` dans l'environnement, le proxy ne trouve pas l'application. Le docstring du même fichier (`:11`) annonce pourtant *« PORT: Port to listen on (default: 5000) »* — la documentation et le code se contredisent dans le même fichier.
|
||||||
- **`wsgi.py` écoute sur 10000 par défaut, nginx envoie vers 5000.** Sans `PORT=5000` dans l'environnement, le proxy ne trouve pas l'application.
|
- **`HOST` par défaut à `0.0.0.0`** dans `wsgi.py:26`, avec un commentaire annonçant l'inverse. C'est un facteur direct de [SEC-05](01-securite.md#sec-05--).
|
||||||
- Le commentaire de `wsgi.py:26` dit *« Bind to localhost by default (Nginx reverse proxy) »* alors que le défaut est `0.0.0.0` — l'intention est documentée, l'implémentation fait l'inverse (cf. SEC-05, dont c'est un facteur aggravant).
|
- Le commentaire de `wsgi.py:26` dit *« Bind to localhost by default (Nginx reverse proxy) »* alors que le défaut est `0.0.0.0` — l'intention est documentée, l'implémentation fait l'inverse (cf. SEC-05, dont c'est un facteur aggravant).
|
||||||
|
|
||||||
**Impact.** La chaîne nginx → Waitress ne fonctionne pas avec les valeurs par défaut : elle exige des variables d'environnement non documentées dans le README. Un déploiement suivant la documentation aboutit à un 502.
|
**Impact.** La chaîne nginx → Waitress ne fonctionne pas avec les valeurs par défaut : elle exige des variables d'environnement non documentées dans le README. Un déploiement suivant la documentation aboutit à un 502.
|
||||||
|
|
||||||
**Correction.**
|
**Correction.**
|
||||||
1. Un seul point d'entrée de production : `wsgi.py`, avec `HOST` par défaut à `127.0.0.1` et `PORT` par défaut à `5000` pour s'aligner sur nginx et sur `.env.exemple`.
|
1. Un seul point d'entrée de production : `wsgi.py`, avec `HOST` par défaut à `127.0.0.1` et `PORT` par défaut à `5000` pour s'aligner sur nginx et sur `.env.exemple`.
|
||||||
2. Supprimer le bloc `if __name__ == '__main__'` de `app/app.py:368-377`, qui fait doublon avec `run.py` et diverge de lui.
|
2. Supprimer le bloc `if __name__ == '__main__'` de `app/app.py`, qui fait doublon avec `run.py` et diverge de lui.
|
||||||
3. Corriger `run.py:26` en `127.0.0.1` et lui faire lire `HOST`/`PORT`.
|
3. Corriger `run.py:26` en `127.0.0.1` et lui faire lire `HOST`/`PORT`.
|
||||||
4. `nginx.conf:122` → `proxy_pass http://127.0.0.1:5000;`.
|
4. Documenter le tableau des ports dans le README.
|
||||||
5. Documenter le tableau des ports dans le README.
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
+39
-26
@@ -5,17 +5,20 @@ Audit statique de l'application `team-tryouts` (Flask 3.1 / SQLAlchemy 2.0 / Jin
|
|||||||
| | |
|
| | |
|
||||||
|---|---|
|
|---|---|
|
||||||
| **Branche** | `audit/securite-maintenabilite-standards` |
|
| **Branche** | `audit/securite-maintenabilite-standards` |
|
||||||
| **Base** | `main` @ `08f02f7` |
|
| **Base** | `immortal/main` @ `bb0bc1c` — dépôt de référence `git.immortal.host/clubesportsudes/team-tryouts` |
|
||||||
| **Date** | 2026-08-07 |
|
| **Date** | 2026-08-07 |
|
||||||
| **Périmètre** | Sécurité · Maintenabilité · Respect des standards de la stack |
|
| **Périmètre** | Sécurité · Maintenabilité · Respect des standards de la stack |
|
||||||
| **Méthode** | Revue de code statique (100 % du code Python, config CI/nginx, `.gitignore`, dépendances). Aucun test dynamique, aucune exécution de l'app. |
|
| **Méthode** | Revue de code statique (100 % du code Python, configuration CI/nginx/déploiement, `.gitignore`, dépendances). Aucun test dynamique, aucune exécution de l'application. |
|
||||||
|
|
||||||
|
> **Note de provenance.** Une première passe a été menée sur le miroir GitHub `cedrick2711/team-tryouts`, qui s'est révélé être **en retard de 16 commits**. L'audit a été rebasé sur le dépôt de référence et intégralement revérifié. Voir [`00-provenance.md`](00-provenance.md) pour le détail de l'écart et la liste des constats devenus caducs.
|
||||||
|
|
||||||
## Documents
|
## Documents
|
||||||
|
|
||||||
| Fichier | Contenu |
|
| Fichier | Contenu |
|
||||||
|---|---|
|
|---|---|
|
||||||
| [`01-securite.md`](01-securite.md) | 19 constats de sécurité, du critique au faible |
|
| [`00-provenance.md`](00-provenance.md) | Écart entre les deux dépôts, constats résolus |
|
||||||
| [`02-maintenabilite.md`](02-maintenabilite.md) | 15 constats de maintenabilité et d'outillage |
|
| [`01-securite.md`](01-securite.md) | 22 constats de sécurité |
|
||||||
|
| [`02-maintenabilite.md`](02-maintenabilite.md) | 16 constats de maintenabilité et d'outillage |
|
||||||
| [`03-standards-stack.md`](03-standards-stack.md) | 10 écarts aux conventions Flask / SQLAlchemy / 12-factor |
|
| [`03-standards-stack.md`](03-standards-stack.md) | 10 écarts aux conventions Flask / SQLAlchemy / 12-factor |
|
||||||
| [`plan-remediation.md`](plan-remediation.md) | Ordre de traitement proposé |
|
| [`plan-remediation.md`](plan-remediation.md) | Ordre de traitement proposé |
|
||||||
|
|
||||||
@@ -23,47 +26,57 @@ Audit statique de l'application `team-tryouts` (Flask 3.1 / SQLAlchemy 2.0 / Jin
|
|||||||
|
|
||||||
## Synthèse exécutive
|
## Synthèse exécutive
|
||||||
|
|
||||||
L'application est fonctionnellement riche et le socle est sain sur plusieurs points : ORM utilisé partout (**aucune injection SQL**), autoescape Jinja2 actif et **aucun `|safe`** dans les 40 templates, CSRF activé globalement, hachage de mots de passe via Werkzeug, modèle d'autorisation polymorphe cohérent (`can_manage_this_tryout`, `can_manage_this_org_team`), en-têtes de sécurité complets et verrouillage de compte après 5 échecs.
|
Le socle est sain sur plusieurs points : ORM utilisé partout (**aucune injection SQL**, y compris dans le SQL brut ajouté récemment, qui est correctement paramétré), autoescape Jinja2 actif et **aucun `|safe`** dans les templates, CSRF activé globalement, hachage Werkzeug, modèle d'autorisation polymorphe cohérent, en-têtes de sécurité complets, verrouillage de compte après 5 échecs.
|
||||||
|
|
||||||
Ces bonnes bases sont **annulées par une poignée de défauts de configuration et de déploiement**, pas par la logique métier. Les trois plus graves sont indépendants du code applicatif :
|
L'équipe a par ailleurs corrigé de sa propre initiative plusieurs points relevés lors de la première passe : le seed automatique en production a été supprimé, le token Discord n'est plus imprimé sur stdout, `proxy_pass` pointe désormais vers `127.0.0.1`, et la faute de frappe `supporting_scrits` a été corrigée. Ces constats sont archivés dans [`00-provenance.md`](00-provenance.md).
|
||||||
|
|
||||||
1. **Des secrets de production réels sont publiés dans le dépôt** (`app/.env.exemple`) : token du bot Discord, URL PostgreSQL Render complète avec mot de passe, `SECRET_KEY`. Ils sont dans l'historique git depuis le commit `2d3721b`.
|
**Il reste deux constats critiques**, tous deux de configuration :
|
||||||
2. **Un déploiement neuf s'auto-amorce avec des comptes `admin` / `password`** — le seed de démo se déclenche automatiquement en production quand la table `users` est vide.
|
|
||||||
3. **Le CORS par défaut accepte toutes les origines avec `supports_credentials=True`** dès que `CORS_ALLOWED_ORIGINS` n'est pas défini — ce qui est le cas dans le `.env.exemple` fourni.
|
|
||||||
|
|
||||||
Le README affirme « Authorization Checks: **Proper ownership validation on all sensitive operations** ». C'est vrai pour les tryouts, matchs et équipes, mais **faux pour la gestion des utilisateurs** : les trois routes `create_user`, `edit_user` et `edit_profile` importent des schémas de validation Marshmallow… et ne les appellent jamais. Aucune politique de mot de passe ne s'applique sur ces chemins.
|
1. **Les secrets de production sont toujours récupérables.** Le fichier `app/.env.exemple` a bien été nettoyé (commit `fd258de`), mais les valeurs réelles — token du bot Discord, mot de passe PostgreSQL, `SECRET_KEY` — **restent dans l'historique git des deux dépôts** (commit `2d3721b`), et sont encore présentes **dans le HEAD du miroir GitHub**. Nettoyer un fichier ne révoque pas un secret.
|
||||||
|
2. **Le CORS accepte toutes les origines avec `supports_credentials=True`** dès que `CORS_ALLOWED_ORIGINS` n'est pas défini — et le `.env.exemple` fourni ne le définit pas.
|
||||||
|
|
||||||
Côté outillage, la CI est en trompe-l'œil : le job « Security Scan » pointe vers un fichier qui n'existe pas à cet emplacement, le job « Tests » est un `echo` en `continue-on-error`, et `requirements.txt` est encodé en **UTF-16**, ce qui casse `pip-audit`. Enfin, `.gitignore` contient `*.html` : **tout nouveau template créé est silencieusement ignoré par git**.
|
Deux points méritent une attention particulière parce qu'ils **ressemblent à des correctifs mais n'en sont pas** :
|
||||||
|
|
||||||
|
- **Le paramétrage `trusted_proxy` de Waitress** (`wsgi.py:38-41`) a été ajouté pour traiter les en-têtes de proxy. Mais `trusted_proxy='*'` accepte ces en-têtes de **n'importe quelle source**, et le serveur écoute toujours sur `0.0.0.0`. Le résultat est l'inverse de l'intention : un attaquant joignant directement le port applicatif peut désormais **usurper `X-Forwarded-For` à chaque requête** et contourner intégralement le rate limiting et le suivi des tentatives de connexion. Voir [SEC-05](01-securite.md#sec-05--).
|
||||||
|
- **L'OAuth2 Discord** ajouté à l'inscription aurait pu résoudre le problème d'usurpation d'identifiant Discord. Mais l'identifiant vérifié par Discord est réinjecté dans le formulaire **via un champ caché** (`register.html:61`) avant d'être enregistré : il est donc modifiable par l'utilisateur. La vérification est cosmétique. Voir [SEC-07](01-securite.md#sec-07--).
|
||||||
|
|
||||||
|
Le flux OAuth2 est en outre **dépourvu de paramètre `state`**, ce qui l'expose à la CSRF classique de liaison de compte ([SEC-20](01-securite.md#sec-20--)).
|
||||||
|
|
||||||
|
Enfin, la chaîne de déploiement ajoutée (`.gitea/workflows/git-to-ptero.yaml`) **pousse le répertoire `.git/` complet sur le serveur SFTP** — donc l'historique contenant les secrets du point 1 ([SEC-21](01-securite.md#sec-21--)).
|
||||||
|
|
||||||
|
Côté outillage, rien n'a bougé : `requirements.txt` est toujours en UTF-16, le job CI « Security Scan » pointe toujours vers un chemin inexistant (et le renommage du dossier l'a même éloigné davantage), le job « Tests » reste un `echo`, et `.gitignore` contient toujours `*.html` — **tout nouveau template est silencieusement ignoré par git**.
|
||||||
|
|
||||||
## Répartition des constats
|
## Répartition des constats
|
||||||
|
|
||||||
| Sévérité | Sécurité | Maintenabilité | Standards | Total |
|
| Sévérité | Sécurité | Maintenabilité | Standards | Total |
|
||||||
|---|---|---|---|---|
|
|---|---|---|---|---|
|
||||||
| 🔴 Critique | 4 | — | — | **4** |
|
| 🔴 Critique | 2 | — | — | **2** |
|
||||||
| 🟠 Élevé | 4 | 4 | 1 | **9** |
|
| 🟠 Élevé | 6 | 4 | 1 | **11** |
|
||||||
| 🟡 Moyen | 7 | 7 | 6 | **20** |
|
| 🟡 Moyen | 8 | 8 | 6 | **22** |
|
||||||
| 🔵 Faible | 4 | 4 | 3 | **11** |
|
| 🔵 Faible | 6 | 4 | 3 | **13** |
|
||||||
| **Total** | **19** | **15** | **10** | **44** |
|
| **Total** | **22** | **16** | **10** | **48** |
|
||||||
|
|
||||||
## Les 5 actions à mener en premier
|
*(4 constats de la première passe ont été résolus par l'équipe — voir [`00-provenance.md`](00-provenance.md).)*
|
||||||
|
|
||||||
|
## Les 6 actions à mener en premier
|
||||||
|
|
||||||
| # | Action | Détail |
|
| # | Action | Détail |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 1 | **Révoquer les 3 secrets exposés** puis purger l'historique git | [SEC-01](01-securite.md#sec-01--) |
|
| 1 | **Révoquer les 3 secrets** — ils sont dans l'historique, le nettoyage du fichier n'a rien révoqué | [SEC-01](01-securite.md#sec-01--) |
|
||||||
| 2 | Désactiver le seed automatique en production | [SEC-02](01-securite.md#sec-02--) |
|
| 2 | Purger l'historique des deux dépôts, et mettre le miroir GitHub à jour ou le supprimer | [SEC-01](01-securite.md#sec-01--) |
|
||||||
| 3 | Refuser le démarrage si `CORS_ALLOWED_ORIGINS` est vide | [SEC-03](01-securite.md#sec-03--) |
|
| 3 | Refuser le démarrage si `CORS_ALLOWED_ORIGINS` est vide | [SEC-03](01-securite.md#sec-03--) |
|
||||||
| 4 | Supprimer le `print()` du token Discord | [SEC-04](01-securite.md#sec-04--) |
|
| 4 | `trusted_proxy='127.0.0.1'` + `HOST=127.0.0.1` | [SEC-05](01-securite.md#sec-05--) |
|
||||||
| 5 | Brancher les schémas Marshmallow sur les routes utilisateurs | [SEC-06](01-securite.md#sec-06--) |
|
| 5 | Ajouter le paramètre `state` au flux OAuth2 Discord | [SEC-20](01-securite.md#sec-20--) |
|
||||||
|
| 6 | Exclure `.git/` du déploiement SFTP | [SEC-21](01-securite.md#sec-21--) |
|
||||||
|
|
||||||
## Ce qui est déjà bien fait
|
## Ce qui est déjà bien fait
|
||||||
|
|
||||||
Pour éviter de le casser lors des corrections :
|
Pour éviter de le casser lors des corrections :
|
||||||
|
|
||||||
- **Aucune injection SQL** — l'ORM est utilisé sans exception ; le seul `text()` est un `SELECT 1` de healthcheck.
|
- **Aucune injection SQL.** L'ORM est utilisé sans exception. Les deux ajouts de SQL brut sont corrects : `users.py:120-123` utilise des paramètres liés, et `migrations/add_tryout_coaches.py` n'interpole aucune entrée utilisateur.
|
||||||
- **Aucun `|safe`, aucun `{% autoescape false %}`** dans les templates ; les `innerHTML` de `main.js` ne manipulent que des chaînes littérales.
|
- **Aucun `|safe`, aucun `{% autoescape false %}`** dans les templates ; les `innerHTML` de `main.js` ne manipulent que des chaînes littérales.
|
||||||
- **Autorisation par dispatch polymorphe** (`Admin`/`Manager`/`Coach`/`Player`/`Scout`) plutôt que par comparaison de chaînes de rôles — approche propre et testable.
|
- **Autorisation par dispatch polymorphe** (`Admin`/`Manager`/`Coach`/`Player`/`Scout`) plutôt que par comparaison de chaînes de rôles — approche propre et testable.
|
||||||
- **Protection contre la fixation de session** au login (`session.clear()` avec préservation du token CSRF, ligne `auth.py:133-138`).
|
- **Protection contre la fixation de session** au login (`auth.py:150-155`).
|
||||||
- **Validation anti-open-redirect** sur le paramètre `next` (`auth.py:23-36`).
|
- **Validation anti-open-redirect** sur le paramètre `next` (`auth.py:40-53`).
|
||||||
- **En-têtes de sécurité complets** et HSTS conditionné à HTTPS réel.
|
|
||||||
- **Filtre de redaction des secrets dans les logs** (`logging_config.py:18-50`).
|
- **Filtre de redaction des secrets dans les logs** (`logging_config.py:18-50`).
|
||||||
- **Découpage des modèles** en fichiers par entité, avec ordre d'import documenté par couches.
|
- **Le changement de rôle par SQL brut** (`users.py:114-126`) est une solution correcte à un vrai problème SQLAlchemy : modifier un discriminateur polymorphe sur une instance chargée corrompt l'*identity map*. Le commentaire explique le raisonnement. À conserver.
|
||||||
|
|||||||
+50
-37
@@ -2,21 +2,24 @@
|
|||||||
|
|
||||||
Ordre proposé. Les lots sont séquentiels : chacun lève des blocages du suivant.
|
Ordre proposé. Les lots sont séquentiels : chacun lève des blocages du suivant.
|
||||||
|
|
||||||
|
Établi sur `immortal/main` @ `bb0bc1c`. Les constats déjà résolus par l'équipe sont listés dans [`00-provenance.md`](00-provenance.md) et absents de ce plan.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Lot 0 — Aujourd'hui, avant tout commit
|
## Lot 0 — Aujourd'hui, avant tout commit
|
||||||
|
|
||||||
Ces actions ne sont pas du code. Elles sont urgentes parce que les secrets sont exposés dès maintenant.
|
Ces actions ne sont pas du code.
|
||||||
|
|
||||||
| # | Action | Réf. |
|
| # | Action | Réf. |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 0.1 | **Révoquer le token du bot Discord** (Developer Portal → Bot → Reset Token) | [SEC-01](01-securite.md#sec-01--) |
|
| 0.1 | **Révoquer le token du bot Discord** (Developer Portal → Bot → Reset Token) | [SEC-01](01-securite.md#sec-01--) |
|
||||||
| 0.2 | **Faire tourner le mot de passe PostgreSQL** sur Render | SEC-01 |
|
| 0.2 | **Faire tourner le mot de passe PostgreSQL** sur Render | SEC-01 |
|
||||||
| 0.3 | **Générer une nouvelle `SECRET_KEY`** — invalide toutes les sessions, c'est voulu | SEC-01 |
|
| 0.3 | **Générer une nouvelle `SECRET_KEY`** — invalide toutes les sessions, c'est voulu | SEC-01 |
|
||||||
| 0.4 | **Vérifier en production** si les comptes `admin`, `manager1/2`, `coach1/2/3`, `scout1` existent avec le mot de passe `password` ; les désactiver ou changer leur mot de passe | [SEC-02](01-securite.md#sec-02--) |
|
| 0.4 | **Vérifier en production** si les comptes de l'ancien seed (`admin`, `manager1`, `manager2`, `coach1`, `coach2`, `coach3`, `scout1`) existent avec le mot de passe `password` ; les désactiver ou changer leur mot de passe | [SEC-22](01-securite.md#sec-22--) |
|
||||||
| 0.5 | Consulter les logs Discord et PostgreSQL pour détecter un éventuel accès non autorisé | SEC-01 |
|
| 0.5 | **Vérifier si `.git/` est présent et téléchargeable** sur le serveur de production (`curl https://<app>/.git/HEAD`) ; le supprimer le cas échéant | [SEC-21](01-securite.md#sec-21--) |
|
||||||
|
| 0.6 | Consulter les logs Discord et PostgreSQL pour détecter un éventuel accès non autorisé | SEC-01 |
|
||||||
|
|
||||||
> 0.1 à 0.3 sont indépendantes du code et peuvent être faites immédiatement. La purge de l'historique git (0.6, ci-dessous) vient après, et ne remplace pas la révocation : les secrets ont pu être clonés.
|
> **Le nettoyage de `app/.env.exemple` (commit `fd258de`) n'a rien révoqué.** Les valeurs restent lisibles dans l'historique des deux dépôts, et dans le HEAD du miroir GitHub. 0.1 à 0.3 restent nécessaires et sont indépendantes de toute manipulation de git.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -24,12 +27,13 @@ Ces actions ne sont pas du code. Elles sont urgentes parce que les secrets sont
|
|||||||
|
|
||||||
| # | Action | Réf. | Effort |
|
| # | Action | Réf. | Effort |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 1.1 | Vider `app/.env.exemple` de ses valeurs réelles, le renommer `.env.example`, ajouter `.env*` au `.gitignore` | SEC-01 | 15 min |
|
| 1.1 | Purger l'historique des **deux** dépôts (`git filter-repo --path app/.env.exemple --invert-paths`), prévenir l'équipe de recloner | [SEC-01](01-securite.md#sec-01--) | 2 h |
|
||||||
| 1.2 | Purger l'historique git (`git filter-repo`), prévenir l'équipe de recloner | SEC-01 | 1 h |
|
| 1.2 | Synchroniser ou supprimer le miroir GitHub — il expose encore les secrets dans son HEAD | SEC-01 | 30 min |
|
||||||
| 1.3 | Conditionner le seed à `SEED_DEMO_DATA` ; générer les mots de passe de démo aléatoirement | SEC-02 | 30 min |
|
| 1.3 | Ajouter `.env*` au `.gitignore` avec `!.env.example` ; renommer `.env.exemple` → `.env.example` | SEC-01 | 15 min |
|
||||||
| 1.4 | Refuser le démarrage si `CORS_ALLOWED_ORIGINS` est vide hors développement | [SEC-03](01-securite.md#sec-03--) | 20 min |
|
| 1.4 | Refuser le démarrage si `CORS_ALLOWED_ORIGINS` est vide hors développement | [SEC-03](01-securite.md#sec-03--) | 20 min |
|
||||||
| 1.5 | Supprimer `print(DISCORD_BOT_TOKEN)` — `discord_bot.py:25` | [SEC-04](01-securite.md#sec-04--) | 2 min |
|
| 1.5 | `trusted_proxy='127.0.0.1'` + `HOST` par défaut à `127.0.0.1` dans `wsgi.py` | [SEC-05](01-securite.md#sec-05--) | 20 min |
|
||||||
| 1.6 | Ajouter un scan de secrets (`gitleaks`) à la CI | SEC-01 | 30 min |
|
| 1.6 | Exclure `.git/` (et `.github/`, `__pycache__/`, `logs/`) du miroir SFTP ; épingler `known_hosts` | [SEC-21](01-securite.md#sec-21--) | 1 h |
|
||||||
|
| 1.7 | Ajouter un scan de secrets (`gitleaks`) à la CI | SEC-01 | 30 min |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -39,12 +43,14 @@ Ces actions ne sont pas du code. Elles sont urgentes parce que les secrets sont
|
|||||||
|
|
||||||
| # | Action | Réf. | Effort |
|
| # | Action | Réf. | Effort |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 2.1 | Réencoder `requirements.txt` en UTF-8, ajouter `.gitattributes` | [MNT-02](02-maintenabilite.md#mnt-02--) | 15 min |
|
| 2.1 | **Vérifier le pilote PostgreSQL** — `psycopg[binary]` est psycopg 3 ; confirmer que `DATABASE_URL` utilise `postgresql+psycopg://`, ou normaliser l'URI au démarrage. Épingler la version. | [MNT-05](02-maintenabilite.md#mnt-05--) | 1 h |
|
||||||
| 2.2 | Corriger `.gitignore` (`*.html`, `docs/`), vérifier les fichiers déjà perdus | [MNT-01](02-maintenabilite.md#mnt-01--) | 30 min |
|
| 2.2 | Réencoder `requirements.txt` en UTF-8, ajouter `.gitattributes` | [MNT-02](02-maintenabilite.md#mnt-02--) | 15 min |
|
||||||
| 2.3 | Corriger le chemin de `security_scan.py` dans la CI et lui fournir `DATABASE_URL` | [MNT-04](02-maintenabilite.md#mnt-04--) | 20 min |
|
| 2.3 | Corriger `.gitignore` (`*.html`, `docs/`), vérifier les fichiers déjà perdus | [MNT-01](02-maintenabilite.md#mnt-01--) | 30 min |
|
||||||
| 2.4 | Ajouter `pyproject.toml` avec la configuration Ruff, formater en un commit isolé | [MNT-06](02-maintenabilite.md#mnt-06--) | 2 h |
|
| 2.4 | Corriger le chemin de `security_scan.py` dans la CI et lui fournir `DATABASE_URL` | [MNT-04](02-maintenabilite.md#mnt-04--) | 20 min |
|
||||||
| 2.5 | Retirer `dotenv`, `login`, `discord` ; `psycopg2` → `psycopg2-binary` | [MNT-05](02-maintenabilite.md#mnt-05--) | 30 min |
|
| 2.5 | Ajouter `pyproject.toml` avec la configuration Ruff, formater en un commit isolé | [MNT-06](02-maintenabilite.md#mnt-06--) | 2 h |
|
||||||
| 2.6 | Créer `tests/`, avec les tests de la matrice de permissions ; activer réellement le job CI | [MNT-03](02-maintenabilite.md#mnt-03--) | 1 j |
|
| 2.6 | Retirer `dotenv`, `login`, `discord` des dépendances | MNT-05 | 30 min |
|
||||||
|
| 2.7 | Retirer `discord_pending.json` du suivi git | [MNT-16](02-maintenabilite.md#mnt-16--) | 10 min |
|
||||||
|
| 2.8 | Créer `tests/`, avec les tests de la matrice de permissions ; activer réellement le job CI | [MNT-03](02-maintenabilite.md#mnt-03--) | 1 j |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -52,17 +58,19 @@ Ces actions ne sont pas du code. Elles sont urgentes parce que les secrets sont
|
|||||||
|
|
||||||
| # | Action | Réf. | Effort |
|
| # | Action | Réf. | Effort |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 3.1 | Appliquer `ProxyFix`, corriger `HOST` (`wsgi.py`) et `proxy_pass` (`nginx.conf`) | [SEC-05](01-securite.md#sec-05--) · [STD-06](03-standards-stack.md#std-06--) | 1 h |
|
| 3.1 | Ajouter le paramètre `state` au flux OAuth2 Discord | [SEC-20](01-securite.md#sec-20--) | 1 h |
|
||||||
| 3.2 | Brancher `CreateUserSchema`, `EditUserSchema`, `EditProfileSchema` sur leurs routes | [SEC-06](01-securite.md#sec-06--) | 3 h |
|
| 3.2 | Prendre `discord_user_id` depuis la session OAuth2, pas depuis le champ caché du formulaire | [SEC-07](01-securite.md#sec-07--) | 2 h |
|
||||||
| 3.3 | Adosser Flask-Limiter à Redis ou PostgreSQL ; réévaluer les seuils | [SEC-08](01-securite.md#sec-08--) | 2 h |
|
| 3.3 | Brancher `CreateUserSchema`, `EditUserSchema`, `EditProfileSchema` sur leurs routes | [SEC-06](01-securite.md#sec-06--) | 3 h |
|
||||||
| 3.4 | Restreindre `view_user` et réduire les données transmises au template | [SEC-15](01-securite.md#sec-15--) | 2 h |
|
| 3.4 | Garde-fous sur `clear_db.py` : confirmation, variable d'environnement, mot de passe aléatoire | [SEC-22](01-securite.md#sec-22--) | 1 h |
|
||||||
| 3.5 | Valider les téléversements de contrats signés | [SEC-11](01-securite.md#sec-11--) | 1 h |
|
| 3.5 | Adosser Flask-Limiter à Redis ou PostgreSQL ; réévaluer les seuils (dont `register`, passé à 20/h) | [SEC-08](01-securite.md#sec-08--) · [SEC-13](01-securite.md#sec-13--) | 2 h |
|
||||||
| 3.6 | Uniformiser les messages de login, égaliser les temps de réponse | [SEC-12](01-securite.md#sec-12--) | 1 h |
|
| 3.6 | Restreindre `view_user` et réduire les données transmises au template | [SEC-15](01-securite.md#sec-15--) | 2 h |
|
||||||
| 3.7 | Corriger `nl2br` (échapper avant `Markup`) ou le supprimer | [SEC-09](01-securite.md#sec-09--) | 15 min |
|
| 3.7 | Valider les téléversements de contrats signés | [SEC-11](01-securite.md#sec-11--) | 1 h |
|
||||||
| 3.8 | Masquer l'erreur brute de `/health` | [SEC-14](01-securite.md#sec-14--) | 10 min |
|
| 3.8 | Uniformiser les messages de login, égaliser les temps de réponse | [SEC-12](01-securite.md#sec-12--) | 1 h |
|
||||||
| 3.9 | Alimenter le logger `team_tryouts.auth` | [SEC-19](01-securite.md#sec-19--) | 2 h |
|
| 3.9 | Corriger `nl2br` (échapper avant `Markup`) ou le supprimer | [SEC-09](01-securite.md#sec-09--) | 15 min |
|
||||||
| 3.10 | Contrôler la cohérence tryout/équipe dans `add_to_team` | [SEC-17](01-securite.md#sec-17--) | 30 min |
|
| 3.10 | Masquer l'erreur brute de `/health` | [SEC-14](01-securite.md#sec-14--) | 10 min |
|
||||||
| 3.11 | Remplacer les `int()` nus par `request.form.get(..., type=int)` | [SEC-16](01-securite.md#sec-16--) | 2 h |
|
| 3.11 | Alimenter le logger `team_tryouts.auth` — **après** 1.5 | [SEC-19](01-securite.md#sec-19--) | 2 h |
|
||||||
|
| 3.12 | Contrôler la cohérence tryout/équipe dans `add_to_team` | [SEC-17](01-securite.md#sec-17--) | 30 min |
|
||||||
|
| 3.13 | Remplacer les `int()` nus par `request.form.get(..., type=int)` | [SEC-16](01-securite.md#sec-16--) | 2 h |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -72,14 +80,15 @@ Ce lot débloque des correctifs de sécurité qui exigent des changements de sch
|
|||||||
|
|
||||||
| # | Action | Réf. | Effort |
|
| # | Action | Réf. | Effort |
|
||||||
|---|---|---|---|
|
|---|---|---|---|
|
||||||
| 4.1 | Introduire Flask-Migrate, `flask db stamp head` sur la base existante | [STD-01](03-standards-stack.md#std-01--) | 3 h |
|
| 4.1 | Introduire Flask-Migrate, `flask db stamp head` ; reprendre `migrations/add_tryout_coaches.py` comme première révision Alembic | [STD-01](03-standards-stack.md#std-01--) | 3 h |
|
||||||
| 4.2 | Objets de configuration par environnement ; un seul `load_dotenv()` | [STD-05](03-standards-stack.md#std-05--) | 4 h |
|
| 4.2 | Objets de configuration par environnement ; un seul `load_dotenv()` ; vérifier les 3 variables Discord OAuth2 au démarrage | [STD-05](03-standards-stack.md#std-05--) | 4 h |
|
||||||
| 4.3 | Sortir les effets de bord de `create_app()` (create_all, seed, bot) | [STD-02](03-standards-stack.md#std-02--) | 3 h |
|
| 4.3 | Sortir les effets de bord de `create_app()` — bénéficie aussi à `clear_db.py` et aux migrations | [STD-02](03-standards-stack.md#std-02--) | 3 h |
|
||||||
| 4.4 | Séparer le bot Discord en processus distinct | [STD-03](03-standards-stack.md#std-03--) | 1 j |
|
| 4.4 | Séparer le bot Discord en processus distinct ; persister son état en base | [STD-03](03-standards-stack.md#std-03--) · [MNT-16](02-maintenabilite.md#mnt-16--) | 1 j |
|
||||||
| 4.5 | *(dépend de 4.1)* Unicité sur `discord_user_id` + vérification de possession | [SEC-07](01-securite.md#sec-07--) | 1 j |
|
| 4.5 | *(dépend de 4.1)* Unicité sur `discord_user_id` | [SEC-07](01-securite.md#sec-07--) | 2 h |
|
||||||
| 4.6 | *(dépend de 4.1)* Cascades FK sur `OrgTeam` ; corriger `delete_team` | [MNT-11](02-maintenabilite.md#mnt-11--) | 3 h |
|
| 4.6 | *(dépend de 4.1)* Cascades FK sur `OrgTeam` ; corriger `delete_team` | [MNT-11](02-maintenabilite.md#mnt-11--) | 3 h |
|
||||||
| 4.7 | *(dépend de 4.1)* Migrer vers `datetime.now(timezone.utc)` et `DateTime(timezone=True)` | [MNT-13](02-maintenabilite.md#mnt-13--) | 4 h |
|
| 4.7 | *(dépend de 4.1)* Migrer vers `datetime.now(timezone.utc)` et `DateTime(timezone=True)` | [MNT-13](02-maintenabilite.md#mnt-13--) | 4 h |
|
||||||
| 4.8 | Journaliser sur stdout ; handlers fichier optionnels | [STD-07](03-standards-stack.md#std-07--) | 1 h |
|
| 4.8 | Journaliser sur stdout ; handlers fichier optionnels | [STD-07](03-standards-stack.md#std-07--) | 1 h |
|
||||||
|
| 4.9 | Aligner les ports (`wsgi.py` défaut 5000, `run.py` en `127.0.0.1`) | [STD-06](03-standards-stack.md#std-06--) | 30 min |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -89,7 +98,7 @@ Sans urgence. À traiter au fil des évolutions plutôt qu'en chantier dédié.
|
|||||||
|
|
||||||
| # | Action | Réf. |
|
| # | Action | Réf. |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
| 5.1 | Découper `users.py` en cinq blueprints | [MNT-08](02-maintenabilite.md#mnt-08--) |
|
| 5.1 | Découper `users.py` (1 265 lignes) en cinq blueprints ; sortir l'OAuth2 de `auth.py` | [MNT-08](02-maintenabilite.md#mnt-08--) |
|
||||||
| 5.2 | Choisir une approche de validation unique ; retirer Flask-WTF ou WTForms | [STD-04](03-standards-stack.md#std-04--) |
|
| 5.2 | Choisir une approche de validation unique ; retirer Flask-WTF ou WTForms | [STD-04](03-standards-stack.md#std-04--) |
|
||||||
| 5.3 | Extraire les utilitaires date/heure | [MNT-12](02-maintenabilite.md#mnt-12--) |
|
| 5.3 | Extraire les utilitaires date/heure | [MNT-12](02-maintenabilite.md#mnt-12--) |
|
||||||
| 5.4 | Résorber les N+1 sur `view_tryout` et `api_events` | [MNT-10](02-maintenabilite.md#mnt-10--) |
|
| 5.4 | Résorber les N+1 sur `view_tryout` et `api_events` | [MNT-10](02-maintenabilite.md#mnt-10--) |
|
||||||
@@ -100,18 +109,19 @@ Sans urgence. À traiter au fil des évolutions plutôt qu'en chantier dédié.
|
|||||||
| 5.9 | Remplacer le CAPTCHA par une validation du courriel institutionnel | [SEC-13](01-securite.md#sec-13--) |
|
| 5.9 | Remplacer le CAPTCHA par une validation du courriel institutionnel | [SEC-13](01-securite.md#sec-13--) |
|
||||||
| 5.10 | Réparer `backup.py` (`pg_dump`) ou le restreindre aux documents | [MNT-07](02-maintenabilite.md#mnt-07--) |
|
| 5.10 | Réparer `backup.py` (`pg_dump`) ou le restreindre aux documents | [MNT-07](02-maintenabilite.md#mnt-07--) |
|
||||||
| 5.11 | Pagination des listes | [MNT-14](02-maintenabilite.md#mnt-14--) |
|
| 5.11 | Pagination des listes | [MNT-14](02-maintenabilite.md#mnt-14--) |
|
||||||
| 5.12 | `Dockerfile` + `docker-compose.yml` | [STD-10](03-standards-stack.md#std-10--) |
|
| 5.12 | `Dockerfile` + `docker-compose.yml` ; remplacer le miroir SFTP par un artefact | [STD-10](03-standards-stack.md#std-10--) |
|
||||||
| 5.13 | Réécrire le README ; renommer `supporting_scrits` → `supporting_scripts` | [MNT-15](02-maintenabilite.md#mnt-15--) |
|
| 5.13 | Réécrire le README, en indiquant quel dépôt fait autorité | [MNT-15](02-maintenabilite.md#mnt-15--) |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Dépendances entre lots
|
## Dépendances entre lots
|
||||||
|
|
||||||
```
|
```
|
||||||
Lot 0 (révocation)
|
Lot 0 (révocation, vérifications en prod)
|
||||||
└─→ Lot 1 (correctifs critiques)
|
└─→ Lot 1 (correctifs critiques)
|
||||||
└─→ Lot 2 (outillage : CI verte, premiers tests)
|
└─→ Lot 2 (outillage : pilote DB, CI verte, premiers tests)
|
||||||
├─→ Lot 3 (sécurité applicative)
|
├─→ Lot 3 (sécurité applicative)
|
||||||
|
│ └─ 3.11 journal d'audit (bloqué par 1.5 — sinon on journalise des IP usurpables)
|
||||||
└─→ Lot 4 (migrations, configuration)
|
└─→ Lot 4 (migrations, configuration)
|
||||||
├─→ 4.5 unicité discord_user_id (bloqué par 4.1)
|
├─→ 4.5 unicité discord_user_id (bloqué par 4.1)
|
||||||
├─→ 4.6 cascades FK (bloqué par 4.1)
|
├─→ 4.6 cascades FK (bloqué par 4.1)
|
||||||
@@ -119,4 +129,7 @@ Lot 0 (révocation)
|
|||||||
└─→ Lot 5 (dette de conception)
|
└─→ Lot 5 (dette de conception)
|
||||||
```
|
```
|
||||||
|
|
||||||
Le Lot 2 est délibérément placé avant les lots 3 et 4 : sans tests sur la matrice de permissions, les modifications d'autorisation du Lot 3 ne peuvent pas être validées autrement qu'à la main.
|
Deux dépendances méritent attention :
|
||||||
|
|
||||||
|
- **2.1 avant tout le reste du Lot 2** : si le pilote PostgreSQL est mal apparié à l'URI, l'application ne démarre pas, et aucun test d'intégration ne peut être écrit.
|
||||||
|
- **1.5 avant 3.11** : journaliser `request.remote_addr` tant que `trusted_proxy='*'` produit une traçabilité illusoire — pire que pas de traçabilité, parce qu'on lui fera confiance.
|
||||||
|
|||||||
Reference in New Issue
Block a user