passe le portail en HTTPS sur syllabuslab.fr, avec en-têtes de sécurité et CSP

Certificat Let's Encrypt obtenu et renouvelé par certbot (authentificateur
nginx), expiration surveillée par Prometheus. TLS selon Mozilla v6.0
intermediate, HSTS, CSP stricte avec nonce sur l'application et adaptée
sur la vitrine, xterm.js servi par le portail. Page de statut prévue sur
status.syllabuslab.fr, administration de Grafana et Kuma par tunnel seul.

Corrige aussi une faille XSS dans la page de classe, la page /cours en
erreur depuis l'étape 8, et NodeSource relancé à chaque déploiement.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Adam Belkacemi 2026-10-09 10:39:50 +02:00
parent aa004c0736
commit 618cd7efbe
30 changed files with 987 additions and 24 deletions

View file

@ -0,0 +1,61 @@
# ADR 0004 — HTTPS, CSP en deux niveaux, et ce qui est exposé sur Internet
- **Date** : 2026-10-09
- **Statut** : accepté
## Contexte
Étape 12 : le portail passe sur `syllabuslab.fr` en HTTPS. Il faut un
certificat qui se renouvelle seul, des en-têtes de sécurité, une CSP
« adaptée à xterm.js », Grafana et Uptime Kuma derrière authentification,
et une page de statut publique.
## Décisions
1. **certbot du dépôt Debian, `certonly` avec l'authentificateur nginx.**
Il obtient et renouvelle le certificat sans jamais modifier nos fichiers :
Ansible reste seul maître du vhost. Pas d'e-mail ACME (Let's Encrypt
n'envoie plus d'avis d'expiration) : c'est Prometheus qui alerte
(`CertificatExpireBientot`, moins de 14 jours).
2. **TLS : Mozilla v6.0 « intermediate »** (TLS 1.2 et 1.3, suites ECDHE),
pour les vieux postes des établissements. Sans X25519MLKEM768 (OpenSSL
3.0 sur Debian 12) ni OCSP stapling (abandonné par Let's Encrypt).
HSTS 2 ans avec `includeSubDomains`, sans `preload` (réversible).
3. **CSP en deux niveaux, posée par Laravel** (elle dépend de la page) :
- *application* (connexion, inscription, toute page authentifiée) :
`script-src 'self' 'nonce-…'`. Les scripts en ligne portent le nonce
de la requête ; xterm.js est servi par le portail (Vite) et non plus
par jsDelivr ; les gestionnaires `onclick`/`onsubmit` sont remplacés
par des écouteurs. Une injection de script serait bloquée ;
- *vitrine* (pages reprises du design) : scripts en ligne et CDN
autorisés. Ces pages contiennent 52 scripts en ligne et le CDN
d'exécution de Tailwind ; elles n'affichent aucune donnée d'utilisateur.
Les violations sont remontées à `/csp-rapport` (journal Laravel).
4. **Administration de Grafana et d'Uptime Kuma : tunnel SSH seulement**
(choix d'Adam). Il faut la clé SSH, puis le mot de passe de l'outil.
Rien de plus n'est exposé sur Internet.
5. **Page de statut sur `status.syllabuslab.fr`** : Nginx ne laisse passer
que la page de statut, son API en lecture (GET/HEAD) et ses fichiers
statiques. Tableau de bord, `/socket.io` (connexion admin) et `/metrics`
renvoient 404. Le rôle n'active ce site que si le nom résout vers le
serveur, et ajoute alors le nom au certificat (`--expand`).
## Alternatives écartées
- **CSP stricte partout** : demanderait de réécrire les ~50 pages du design
(scripts en ligne, Tailwind par CDN). Reporté (voir `docs/dette.md`).
- **Grafana et Kuma publics derrière mot de passe** : pratique, mais exposés
à la force brute pour aucun besoin actuel.
- **certbot en snap** (recommandation du projet certbot) : une deuxième
chaîne de mises à jour à surveiller ; le paquet Debian suit
unattended-upgrades comme le reste.
## Conséquences
- Toute nouvelle page de l'application qui ajoute un script doit lui donner
`nonce="{{ Vite::cspNonce() }}"`, sinon le navigateur le bloque (et le
signale dans le journal).
- `Permissions-Policy` interdit caméra et micro : à ouvrir pour la visio
(étape 15).
- Les tests et scripts doivent passer par `https://syllabuslab.fr` : le
cookie de session est `secure`.

21
docs/dette.md Normal file
View file

@ -0,0 +1,21 @@
# Dette technique
Ce qui est connu, accepté pour l'instant, et à traiter. Une ligne par sujet ;
retirer la ligne (et le noter dans le journal) quand c'est fait.
| Sujet | Pourquoi c'est une dette | Quand | Origine |
|---|---|---|---|
| **Image Uptime Kuma non figée** (`louislam/uptime-kuma:1`) | Une version différente peut arriver à n'importe quel redéploiement, sans test. Figer un tag précis (voire le digest) dans une variable du rôle `supervision`, et documenter la mise à jour par Ansible (changer la variable, relancer le rôle, vérifier) — jamais par le bouton de l'interface. | Avant le pilote | Demande d'Adam, 2026-10-09 |
| Larastan (PHPStan niveau 6) absent | Exigé par le CLAUDE.md depuis l'étape 8, jamais installé. | Bloc B | Étape 8 |
| Temps d'ouverture d'un lab : p95 non mesuré | Deux mesures seulement (4,06 et 4,35 s, critère < 5 s). Il faut une série. | Étape 13 | Étape 11 |
| Capacité : 15 VM au pire cas | Une classe de 30 sur le lab 08 demande 90 VM. Mesurer en charge, décider du surengagement mémoire (KSM). | Étape 13 | Étape 11 |
| Vitrine : Tailwind par CDN d'exécution (`cdn.tailwindcss.com`, 19 pages) | Non destiné à la production selon Tailwind, lent, et oblige la vitrine à tolérer les scripts en ligne dans sa CSP. Compiler ces styles avec Vite. | Avant ouverture publique | Étape 12 (design) |
| Vitrine : 52 scripts en ligne, Google Fonts, jsDelivr (démos xterm, CodeMirror) | CSP vitrine avec `'unsafe-inline'` ; CDN parfois filtrés par les réseaux scolaires. Auto-héberger polices et bibliothèques, passer les scripts en fichiers. | Avant ouverture publique | Étape 12 |
| xterm.js 5.5.0 (6.0.0 disponible) | Version majeure à évaluer séparément. | Bloc B | Étape 12 |
| NodeSource installé par `curl … \| bash` | Script distant exécuté en root au premier déploiement. Déclarer le dépôt (deb822) avec une clé vérifiée. | Bloc B | Étape 8 |
| Aucune vérification dans un vrai navigateur | Extension Chrome non connectée (étapes 8 et 12). La CSP n'est contrôlée que par ses rapports. | Dès que possible | Étapes 8 et 12 |
| Pas de sonde hors du serveur | Si le serveur tombe, personne n'est prévenu. | À décider avec Adam | Étape 7 |
| Kolla installé depuis une branche, pas un tag | Reproductibilité. | Montée de version | ADR 0001 |
| Correction séquentielle (~5 s pour le lab 08) | Acceptable ; à paralléliser si la charge l'exige. | Étape 13 | Étape 11 |
| Tâches Ansible toujours « modifiées » (composer, npm, build, cache) | Brouille la lecture de l'idempotence. | Bloc B | Étape 8 |
| `Permissions-Policy` ferme caméra et micro | À ouvrir pour la visio. | Étape 15 | Étape 12 |

View file

@ -462,3 +462,58 @@ Le stock chaud transforme une attente de 30 s en une attente de 4 s en faisant l
**Vérifié** : `ss -tlnp` → `LISTEN 127.0.0.1:3001 users:(("node",...))` et rien d'autre ; `curl http://127.0.0.1:3001/` → 302 (redirection vers la page d'accueil de Kuma) ; port 3001 fermé sur l'IP publique depuis le Mac ; tables nftables inchangées ; second passage du playbook `changed=0`.
**Comprendre** : « healthy » veut dire que le conteneur répond *à lui-même*, pas qu'il est joignable de l'extérieur. Le seul test qui prouve qu'un service est accessible, c'est de s'y connecter par le chemin réel (ici : écoute sur l'hôte, puis tunnel). En réseau hôte, le conteneur partage la pile réseau du serveur : l'adresse d'écoute devient la seule barrière, d'où la vérification automatique ajoutée au rôle.
## 2026-10-09 — Étape 12 : domaine, HTTPS et sécurité
**Fait**
- DNS vérifié : `syllabuslab.fr` et `www` → `163.172.4.195`, pas d'AAAA ni de CAA.
- **Certificat Let's Encrypt** (certbot 2.1 du dépôt Debian, `certonly` avec l'authentificateur nginx, sans e-mail) : `syllabuslab.fr` + `www`, valable jusqu'au 2027-01-07. Le rôle `portail` sert d'abord le site en HTTP avec les bons noms, obtient le certificat, puis passe le vhost en HTTPS ; aux passages suivants, certbot ne fait rien tant que le certificat est valide. Renouvellement par `certbot.timer`, rechargement de Nginx en crochet.
- **Alerte d'expiration** : sans e-mail Let's Encrypt, c'est Prometheus qui prévient (métrique publiée chaque jour par le collecteur textfile, règles `CertificatExpireBientot` à moins de 14 jours et `CertificatNonMesure`).
- **TLS** : recommandations Mozilla v6.0 « intermediate », lues à la source (TLS 1.2/1.3, suites ECDHE), sans `X25519MLKEM768` (OpenSSL 3.0) ni OCSP stapling (abandonné par Let's Encrypt). HTTP/2.
- **Redirections** : HTTP → HTTPS, `www` → domaine principal, IP → domaine.
- **En-têtes** (Nginx) : HSTS 2 ans `includeSubDomains`, `nosniff`, `X-Frame-Options DENY`, `Referrer-Policy`, `Permissions-Policy`, `Cross-Origin-Opener-Policy`, `server_tokens off`. Cookie de session `secure; httponly; samesite=lax`, `APP_URL` en HTTPS.
- **CSP en deux niveaux** (Laravel, ADR 0004) : stricte avec nonce sur l'application, adaptée sur la vitrine. xterm.js est maintenant servi par le portail (Vite) au lieu de jsDelivr. Point de collecte `/csp-rapport`.
- **Exposition** (choix d'Adam) : Grafana et Uptime Kuma restent accessibles par tunnel SSH seulement. Page de statut publique prévue sur `status.syllabuslab.fr`, filtrée par Nginx (seuls la page de statut et son API en lecture passent). Elle s'activera toute seule au passage du rôle quand le DNS existera.
- `docs/dette.md` créé : la dette éparpillée dans le journal est regroupée, avec la demande d'Adam (figer l'image Uptime Kuma et la mettre à jour par Ansible).
- 5 nouveaux tests Pest (23 au total) : CSP stricte et adaptée, nonce sur chaque script, rapport sans CSRF, toutes les pages publiques en 200.
**Problèmes rencontrés et solution**
1. **Faille XSS dans la page de classe** (étape 8) : `onsubmit="return confirm('… {{ $eleve->prenom }} …')"`. Blade échappe l'apostrophe en `&#039;`, que le navigateur décode avant d'exécuter le JavaScript : un prénom importé par CSV comme `x');alert(1);//` s'exécutait chez le prof. Corrigé (attribut `data-confirmer` + écouteur), et la CSP stricte bloquerait désormais ce genre de script.
2. **Page `/cours` en erreur 500 depuis l'étape 8** : la maquette `design/` elle-même a perdu la boucle et les données des cursus lors de sa refonte (3 `@foreach` pour 4 `@endforeach`), et `config/cours.php` n'avait pas été copié. Rétabli d'après `design/_backup-ludique` (sans toucher à `design/`). Le journal de l'étape 8 affirmait que « toutes les pages vitrine fonctionnent » : faux pour celle-ci. Un test ouvre maintenant toutes les pages publiques ; vérifié qu'il échoue sur l'ancienne version.
3. **NodeSource relancé en root à chaque déploiement** : la condition `creates` visait `nodesource.list`, alors que le script crée `nodesource.sources`. Corrigé ; remplacement par un dépôt déclaratif noté en dette.
4. Le scan depuis le Mac montrait 21, 554 et 1723 ouverts : c'est la box d'Adam (même résultat vers `192.0.2.1`, adresse inexistante). Côté serveur, rien n'écoute et nftables n'accepte que 22/80/443.
5. Vérification dans un vrai navigateur impossible (extension Chrome non connectée) : la CSP est surveillée par ses rapports ; parcours manuel demandé à Adam.
6. Ma commande de contrôle des en-têtes a affiché la valeur d'un cookie de session anonyme (ouvert par `curl`, sans connexion, donc sans valeur) ; le filtre de masquage visait un mauvais nom de cookie.
**Critère vérifié**
```
subject=CN = syllabuslab.fr ; issuer=Let's Encrypt (YE2) ; notAfter=Jan 7 07:21:22 2027 GMT
https://syllabuslab.fr -> HTTP 200, TLS vérifié: 0 (0 = OK), HTTP/2
http://163.172.4.195/ -> 301 https://syllabuslab.fr/ ; https://www.syllabuslab.fr/cours -> 301 https://syllabuslab.fr/cours
TLS 1.1 : refusé ; TLSv1.3, TLS_AES_256_GCM_SHA384
certbot renew --dry-run : all simulated renewals succeeded
Prometheus : CertificatExpireBientot:ok, CertificatNonMesure:ok ; jours restants : 90.0
```
Parcours complet par `https://syllabuslab.fr`, consoles en `wss://` :
```
1. Admission → invite de connexion : 4.35 s (OK, critère < 5 s)
2. Sans routage activé : « Pas encore réussi » 6/10, avec les messages du lab
3. Routage activé : « Réussi » 10/10
```
- Ports : depuis Internet, seuls 22, 80 et 443 répondent pour le serveur ; les ports internes (Grafana, Prometheus, Kuma, API OpenStack, proxy console, MariaDB, Redis) sont tous filtrés.
- VM d'élèves : aucune IP flottante, aucun routeur Neutron dans le projet ; depuis `pc-a`, Internet et l'hôte sont injoignables (« Network is unreachable »).
**Comprendre**
HTTPS ne se résume pas au cadenas : le certificat prouve l'identité du site, HSTS interdit au navigateur de revenir en HTTP, et la CSP décide quels scripts ont le droit de s'exécuter. Cette dernière est la vraie protection contre l'injection de code : même si un prénom piégé arrivait dans une page, le navigateur refuserait de l'exécuter faute du nonce, ce jeton aléatoire que seul le serveur connaît pour chaque page. La faille trouvée dans la page de classe montre pourquoi : l'échappement de Blade protège le HTML, mais pas du JavaScript placé dans un attribut — deux langages imbriqués, deux règles d'échappement. Enfin, le renouvellement automatique n'est fiable que s'il est surveillé : d'où l'alerte Prometheus, puisque plus personne n'enverra d'e-mail avant l'expiration.
**Suite**
- Adam : enregistrement DNS `status`, page de statut `syllabus` dans Kuma, sonde du portail passée en `https://syllabuslab.fr`, parcours manuel dans un navigateur (pour la CSP).
- Étape 13 : tests de capacité et de résistance.

60
docs/runbooks/https.md Normal file
View file

@ -0,0 +1,60 @@
# Runbook — HTTPS, en-têtes de sécurité, page de statut (étape 12)
## Où sont les choses
| Élément | Emplacement |
|---|---|
| Certificat | `/etc/letsencrypt/live/syllabuslab.fr/` (un seul certificat : `syllabuslab.fr`, `www`, et `status` quand son DNS existe) |
| Renouvellement | `certbot.timer` (deux fois par jour), authentificateur nginx, puis `systemctl reload nginx` |
| Alerte d'expiration | métrique `syllabus_certificat_expiration_timestamp` (script `/usr/local/sbin/syllabus-expiration-certificat`, cron quotidien), règles `CertificatExpireBientot` et `CertificatNonMesure` (`kolla/config/prometheus/alertes.rules`) |
| Vhost | `ansible/roles/portail/templates/nginx-portail.conf.j2` → `/etc/nginx/sites-available/portail.conf` |
| TLS et en-têtes | `/etc/nginx/snippets/syllabus-tls.conf`, `syllabus-entetes.conf` (rôle `portail`) |
| CSP | `portail/app/Http/Middleware/PolitiqueSecuriteContenu.php` ; violations dans `storage/logs/laravel.log` (« Violation CSP ») |
| Décisions | [ADR 0004](../adr/0004-https-csp-et-exposition.md) |
Tout est posé par `ansible-playbook portail.yml`.
## Vérifier
```
curl -sSI https://syllabuslab.fr/connexion | grep -iE "strict-transport|content-security|x-frame"
echo | openssl s_client -connect syllabuslab.fr:443 -servername syllabuslab.fr 2>/dev/null | openssl x509 -noout -dates -ext subjectAltName
ssh paillasse "sudo certbot renew --dry-run" # renouvellement simulé (environnement de test Let's Encrypt)
ssh paillasse "sudo grep -c 'Violation CSP' /var/www/portail/storage/logs/laravel.log"
```
## Activer la page de statut
1. Ajouter chez OVH : `status.syllabuslab.fr` A `163.172.4.195`.
2. Vérifier qu'il est publié : `dig +short A status.syllabuslab.fr @ns111.ovh.net`.
3. Dans Uptime Kuma (par tunnel), créer une page de statut avec l'identifiant (slug) `syllabus` et y mettre les sondes à publier.
4. `cd ansible && script -q /dev/null ansible-playbook portail.yml -i inventory.ini` : le rôle détecte le nom, l'ajoute au certificat et active le site.
5. Vérifier : `https://status.syllabuslab.fr` affiche la page ; `https://status.syllabuslab.fr/dashboard` et `/metrics` répondent 404.
## Si le certificat va expirer (alerte)
```
ssh paillasse "systemctl status certbot.timer; sudo journalctl -u certbot --since -3d | tail -30"
ssh paillasse "sudo certbot renew"
```
Causes probables : port 80 inaccessible depuis Internet, DNS modifié,
configuration Nginx invalide (`sudo nginx -t`). Limite Let's Encrypt :
5 échecs de validation par heure et par nom — corriger avant de réessayer.
## Ajouter un script à une page de l'application
La CSP stricte bloque tout script sans le nonce de la requête :
```
<script nonce="{{ Vite::cspNonce() }}">…</script>
```
Pas de `onclick="…"` : utiliser `addEventListener` dans un script avec nonce.
Pas de CDN : passer par Vite (`resources/js/`).
## Pièges (évités ou rencontrés)
- Évité par conception : une redirection `return 301` au niveau `server` s'exécute avant toute `location`, elle risquerait de court-circuiter le défi ACME que certbot insère au renouvellement. Les redirections sont dans `location /` ; `certbot renew --dry-run` réussit.
- `X25519MLKEM768` (recommandé par Mozilla v6.0) demande OpenSSL 3.5 ; Debian 12 a OpenSSL 3.0 : retiré par précaution (non essayé). À réintroduire au passage à Debian 13.
- Depuis Internet, un scan peut montrer 21, 554 et 1723 ouverts : c'est la box du réseau qui scanne (même résultat vers `192.0.2.1`, adresse inexistante). Côté serveur : `sudo ss -tlnp` et `sudo nft list chain inet filter input`.