# Security Fixes Applied — Phase 2 Date: 2026-05-06 ## SEC-FIX-001 — Bootstrap admin et rattrapage des bases existantes ### Problème résiduel (après phase 1) La migration `_migrate_users_must_change_password()` ajoutait la colonne avec `DEFAULT 0`. Un compte admin existant avec le mot de passe par défaut "admin" n'était donc pas forcé à changer. ### Corrections appliquées **`backend/main.py`** - Nouvelle migration `_migrate_users_token_version()` — ajout de `token_version INTEGER NOT NULL DEFAULT 1` si absente. - Nouvelle migration `_migrate_force_admin_password_change()` : vérifie si l'admin a `must_change_password=0` et si son hash correspond au mot de passe "admin". Si oui, pose `must_change_password=1`. Idempotent, s'exécute à chaque démarrage. - `_migrate_users()` : mise à jour pour inclure `token_version` dans le CREATE TABLE initial. - Ordre de démarrage mis à jour : `_migrate_users_token_version()` puis `_migrate_force_admin_password_change()` avant `_migrate_users()`. **`backend/tests/conftest.py`** (nouveau) - Configure `DATABASE_URL` vers une base temporaire avant tout import applicatif. - `SECRET_KEY` fixé pour les tests. **`backend/tests/test_auth.py`** (nouveau) - Tests SEC-FIX-001 : base vide (must_change=1), CRUD bloqué avant changement, CRUD autorisé après, rattrapage admin existant, admin avec mot de passe personnalisé non touché, `INITIAL_ADMIN_PASSWORD`. - Tests SEC-FIX-004 : ancien token rejeté après changement, nouveau token valide, backward compat token sans `ver`, token avec mauvaise version. - Tests SEC-FIX-003 : validation mot de passe (trop court, sans chiffre, sans lettre, valide), username (invalide, trop long, valide), mauvais mot de passe courant. - Tests SEC-FIX-002 : rate limit IP (429), rate limit username (429), reset après login réussi. **`backend/requirements-test.txt`** (nouveau) - `pytest>=7.4`, `httpx>=0.25`. --- ## SEC-FIX-004 — Invalidation de session après changement de mot de passe ### Problème JWT valable 7 jours, pas d'invalidation après changement de mot de passe. ### Corrections appliquées **`backend/models.py`** - Colonne `token_version = Column(Integer, nullable=False, default=1, server_default="1")` ajoutée à `User`. **`backend/routers/auth.py`** - `TOKEN_EXPIRE_HOURS = 24` (réduit de 7 jours à 24 heures). - `create_token(username, version)` : payload inclut `{ ver: version }`. - `get_current_user` : vérifie `payload["ver"] == user.token_version`. Rejette avec 401 si différent. Rétrocompatibilité : `ver` absent dans le payload est traité comme `ver=1`. - `update_account` : incrémente `current_user.token_version` lors d'un changement de mot de passe. - `login` : transmet `user.token_version` au `create_token`. --- ## SEC-FIX-005 — CORS configurable ### Problème `allow_origins=["*"]` hardcodé. ### Corrections appliquées **`backend/main.py`** - Lecture de `ALLOWED_ORIGINS` env var (défaut `"*"` pour rétrocompatibilité). - `""` désactive le middleware CORS (même origine via proxy). - Valeur CSV : liste d'origines explicites. **`.env.example`** (nouveau) - Documentation de `ALLOWED_ORIGINS` avec exemples. --- ## SEC-FIX-010 — Sécurisation du secret JWT ### Problème `secret_key.txt` créé sans permissions restrictives. Pas de `.gitignore`. ### Corrections appliquées **`backend/routers/auth.py`** - Création du fichier secret via `os.open(..., os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)` pour imposer des permissions 0600 dès la création (owner read/write uniquement). **`.gitignore`** (nouveau) - Ignore `db_data/`, `*.db`, `.env`, `__pycache__/`, `node_modules/`, etc. --- ## SEC-FIX-011 — Durcissement des conteneurs ### Problème Conteneurs lancés en root sans contraintes de capabilities ni healthchecks. ### Corrections appliquées (Phase 2 initiale) **`frontend/Dockerfile`** - `npm install` → `npm ci` (build reproductible, respecte le lockfile). - Image de base finale : `nginxinc/nginx-unprivileged:alpine` — nginx s'exécute entièrement en UID 101 sans nécessiter `CAP_CHOWN` ni `CAP_SETUID`. Compatible avec `cap_drop: ALL`. - `EXPOSE 8080` (port non-privilégié, correspondant à la config nginx). **`frontend/nginx.conf`** - `listen 80` → `listen 8080` (port non-privilégié, ne nécessite pas CAP_NET_BIND_SERVICE). **`docker-compose.yml`** - `frontend` : `cap_drop: ALL`, `security_opt: no-new-privileges:true`, port `8080:8080`, healthcheck wget, `depends_on: backend: condition: service_healthy`. - Suppression de la section `volumes: db_data:` (bind mount, pas de volume nommé). **Approches abandonnées (Phase 2 initiale)** - `gosu` + `appuser` pour le backend : `cap_drop: ALL` supprime `CAP_SETUID` → `gosu` ne peut pas changer d'utilisateur depuis le processus uvicorn après démarrage. ### Correction complémentaire — backend non-root via `user:` Compose **Problème résiduel** : le backend tournait en UID 0 (root) faute de mécanisme interne pour changer d'utilisateur. La capability `DAC_OVERRIDE` avait été ajoutée comme contournement (accès en écriture au bind-mount), mais les fichiers `db_data/` étaient créés root:root sur l'hôte. **Solution** : la directive `user:` de Docker Compose démarre le processus directement sous l'UID/GID cible, sans nécessiter `CAP_SETUID` ni gosu. **`docker-compose.yml`** - `backend` : ajout de `user: "${DOCKER_UID:-1000}:${DOCKER_GID:-1000}"`. - `cap_add` : suppression de `DAC_OVERRIDE` (inutile — le processus est propriétaire du bind-mount). - `cap_add` conserve `NET_RAW` (ICMP ping). - `no-new-privileges` reste omis : ping utilise les file capabilities (`cap_net_raw=ep`) ; `no-new-privileges` supprime le bit effective du fichier, empêchant le sous-processus d'acquérir `CAP_NET_RAW` dans son ensemble effectif même si le parent le détient dans son ensemble permis. **`.env.example`** - `DOCKER_UID` / `DOCKER_GID` documentés avec procédure `id -u && id -g` et instruction `mkdir -p db_data`. **`README.md`** - Quick start : `docker compose --env-file .env up --build -d`. - Configuration : `APP_UID`/`APP_GID` remplacés par `DOCKER_UID`/`DOCKER_GID`. - Container hardening : table mise à jour (`DOCKER_UID:DOCKER_GID`), note `no-new-privileges` précisée. --- ## SEC-FIX-018 — Documentation ### Corrections appliquées **`README.md`** (nouveau) - Quick start, configuration, sécurité (HTTPS, rotation de clé, durcissement conteneurs), persistance des données, développement. **`.env.example`** (nouveau) - `SECRET_KEY`, `INITIAL_ADMIN_PASSWORD`, `ALLOWED_ORIGINS`, `APP_UID`/`APP_GID` documentés avec exemples. **`docs/backend.md`** - Routeurs protégés : `get_current_user` → `require_password_changed`. - Contrat login : ajout `must_change_password`. - Contrat account : codes d'erreur normalisés. - Modèle `User` : ajout `must_change_password`, `token_version`. - Section Auth : mise à jour expiry, payload, invalidation, rate limiting. - Section Migrations : ordre complet, description `_migrate_force_admin_password_change`. **`docs/architecture.md`** - Section Auth : schéma mis à jour (`mustChangePassword`, `AccountModal :forced`). - Section Docker Compose : non-root, cap_drop, suppression volume nommé. **`CLAUDE.md`** - Section Authentification : 24h, `token_version`, `must_change_password`, guard App.vue, `INITIAL_ADMIN_PASSWORD`. - Section Migrations : liste complète des 7 migrations. **`AGENTS.md`** - Section Authentication : réécriture complète. - Data Model : `User` mis à jour. --- ## Fichiers modifiés | Fichier | Fixes | |---------|-------| | `backend/database.py` | Support `DATABASE_URL` pour tests | | `backend/models.py` | SEC-FIX-004 (`token_version`) | | `backend/main.py` | SEC-FIX-001 (rattrapage), SEC-FIX-004 (migration), SEC-FIX-005 (CORS) | | `backend/routers/auth.py` | SEC-FIX-004 (versioning), SEC-FIX-010 (0600), expiry 24h | | `backend/Dockerfile` | SEC-FIX-011 (cap_drop, DAC_OVERRIDE) | | `backend/requirements-test.txt` | Tests (nouveau) | | `backend/tests/__init__.py` | Tests (nouveau) | | `backend/tests/conftest.py` | Tests (nouveau) | | `backend/tests/test_auth.py` | Tests SEC-FIX-001, 002, 003, 004 (nouveau) | | `frontend/Dockerfile` | SEC-FIX-011 (nginxinc/nginx-unprivileged, npm ci) | | `frontend/nginx.conf` | SEC-FIX-011 (port 8080) | | `docker-compose.yml` | SEC-FIX-011 (cap_drop, healthchecks) | | `.gitignore` | SEC-FIX-010 (nouveau) | | `.env.example` | SEC-FIX-005, SEC-FIX-010, SEC-FIX-018 (nouveau) | | `README.md` | SEC-FIX-018 (nouveau) | | `docs/backend.md` | SEC-FIX-018 | | `docs/architecture.md` | SEC-FIX-018 | | `CLAUDE.md` | SEC-FIX-018 | | `AGENTS.md` | SEC-FIX-018 | --- ## Risques couverts | Risque | Avant | Après | |--------|-------|-------| | Bootstrap admin/admin sur instance existante | Non corrigé | `_migrate_force_admin_password_change()` force le changement au prochain démarrage | | JWT valable 7 jours après compromission | Oui | 24h + invalidation immédiate par `token_version` | | CORS permissif | Hardcodé `*` | Configurable via `ALLOWED_ORIGINS`, défaut `*` rétrocompatible | | Secret JWT lisible (permissions) | Mode par défaut (0644 ou plus) | 0600 dès la création | | Secrets dans le dépôt | Pas de `.gitignore` | `db_data/`, `.env`, `*.db` ignorés | | Process frontend root | Oui (nginx master + workers en root) | `nginxinc/nginx-unprivileged` — tout le process tree en UID 101 | | Process backend root | Oui | `user: DOCKER_UID:DOCKER_GID` — process = utilisateur hôte, fichiers `db_data/` non root-owned | | Capabilities superflues | Aucun cap_drop | `cap_drop: ALL` sur les deux conteneurs ; backend ajoute `NET_RAW` uniquement | | `DAC_OVERRIDE` exposée | Oui (contournement root) | Supprimée — inutile avec non-root propriétaire du bind-mount | | Pas de healthchecks | Aucun | Ajoutés sur les deux services | | Build frontend non reproductible | `npm install` | `npm ci` | --- ## Limitations restantes - JWT stocké en `localStorage` (pas de cookie HttpOnly) — mitigation : expiry 24h + token versioning. - Rate limiting backend en mémoire (perdu au redémarrage, ne se partage pas entre workers) — mitigation : Nginx `limit_req` couvre le cas multi-worker. - `no-new-privileges` absent du backend : `no-new-privileges` supprime le bit effective des file capabilities ; le sous-processus ping ne peut pas acquérir `CAP_NET_RAW` dans son ensemble effectif même si le parent le détient en permis. Mitigation : le processus tourne désormais sous un UID non-privilégié. - Politique de mot de passe minimale (`password1` passe) — pas de liste noire de mots de passe communs. - Pas de logs structurés d'audit (SEC-FIX-012 non traité dans cette phase).