From 618cd7efbe94f0fe6a58fac133199b1b5c7af0d2 Mon Sep 17 00:00:00 2001 From: Adam Belkacemi Date: Fri, 9 Oct 2026 10:39:50 +0200 Subject: [PATCH] =?UTF-8?q?passe=20le=20portail=20en=20HTTPS=20sur=20sylla?= =?UTF-8?q?buslab.fr,=20avec=20en-t=C3=AAtes=20de=20s=C3=A9curit=C3=A9=20e?= =?UTF-8?q?t=20CSP?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- ansible/roles/portail/defaults/main.yml | 10 + ansible/roles/portail/tasks/01-systeme.yml | 4 +- ansible/roles/portail/tasks/03-code.yml | 3 + ansible/roles/portail/tasks/05-nginx.yml | 97 ++++++- .../portail/templates/nginx-portail.conf.j2 | 98 ++++++- .../templates/syllabus-entetes.conf.j2 | 10 + .../syllabus-expiration-certificat.sh.j2 | 14 + .../portail/templates/syllabus-tls.conf.j2 | 14 + docs/adr/0004-https-csp-et-exposition.md | 61 ++++ docs/dette.md | 21 ++ docs/journal.md | 55 ++++ docs/runbooks/https.md | 60 ++++ kolla/config/prometheus/alertes.rules | 18 ++ .../Http/Controllers/RapportCspController.php | 31 ++ .../Middleware/PolitiqueSecuriteContenu.php | 87 ++++++ portail/bootstrap/app.php | 2 + portail/config/cours.php | 264 ++++++++++++++++++ portail/package-lock.json | 19 ++ portail/package.json | 4 + portail/resources/js/console.js | 9 + .../views/auth/inscription.blade.php | 2 +- portail/resources/views/cours/index.blade.php | 7 + .../views/mes-classes/show.blade.php | 10 +- .../views/seances/attente-eleve.blade.php | 13 +- .../views/seances/console-prof.blade.php | 11 +- portail/routes/web.php | 7 + portail/tests/Feature/PagesPubliquesTest.php | 22 ++ .../Feature/PolitiqueSecuriteContenuTest.php | 48 ++++ portail/tests/Reels/parcours-lab.py | 8 +- portail/vite.config.js | 2 +- 30 files changed, 987 insertions(+), 24 deletions(-) create mode 100644 ansible/roles/portail/templates/syllabus-entetes.conf.j2 create mode 100644 ansible/roles/portail/templates/syllabus-expiration-certificat.sh.j2 create mode 100644 ansible/roles/portail/templates/syllabus-tls.conf.j2 create mode 100644 docs/adr/0004-https-csp-et-exposition.md create mode 100644 docs/dette.md create mode 100644 docs/runbooks/https.md create mode 100644 portail/app/Http/Controllers/RapportCspController.php create mode 100644 portail/app/Http/Middleware/PolitiqueSecuriteContenu.php create mode 100644 portail/config/cours.php create mode 100644 portail/resources/js/console.js create mode 100644 portail/tests/Feature/PagesPubliquesTest.php create mode 100644 portail/tests/Feature/PolitiqueSecuriteContenuTest.php diff --git a/ansible/roles/portail/defaults/main.yml b/ansible/roles/portail/defaults/main.yml index 74de857..9f65e6b 100644 --- a/ansible/roles/portail/defaults/main.yml +++ b/ansible/roles/portail/defaults/main.yml @@ -6,3 +6,13 @@ portail_mysql_user: portail portail_dossier_docker: /etc/portail portail_dossier_app: /var/www/portail portail_php_version: "8.3" + +# Étape 12 : HTTPS +portail_domaine: syllabuslab.fr +portail_domaines_alias: [www.syllabuslab.fr] +# Page de statut publique d'Uptime Kuma : activée seulement quand ce nom +# résout vers le serveur (le rôle le vérifie à chaque passage). +portail_domaine_statut: status.syllabuslab.fr +portail_statut_slug: syllabus +portail_ip_publique: 163.172.4.195 +portail_port_uptime_kuma: 3001 diff --git a/ansible/roles/portail/tasks/01-systeme.yml b/ansible/roles/portail/tasks/01-systeme.yml index cafe982..fb53b0d 100644 --- a/ansible/roles/portail/tasks/01-systeme.yml +++ b/ansible/roles/portail/tasks/01-systeme.yml @@ -82,7 +82,9 @@ - name: ajouter le dépôt NodeSource ansible.builtin.shell: cmd: curl -fsSL https://deb.nodesource.com/setup_22.x | bash - - creates: /etc/apt/sources.list.d/nodesource.list + # Le script crée un fichier deb822 (.sources) : avec l'ancien nom + # (.list), il était relancé en root à chaque déploiement. + creates: /etc/apt/sources.list.d/nodesource.sources - name: installer nodejs ansible.builtin.apt: diff --git a/ansible/roles/portail/tasks/03-code.yml b/ansible/roles/portail/tasks/03-code.yml index c48bb14..1f06192 100644 --- a/ansible/roles/portail/tasks/03-code.yml +++ b/ansible/roles/portail/tasks/03-code.yml @@ -122,6 +122,9 @@ - { cle: "LABS_DOSSIER", valeur: "/opt/labs" } # > timeout des superviseurs Horizon (300 s, config/horizon.php) - { cle: "REDIS_QUEUE_RETRY_AFTER", valeur: "330" } + # Étape 12 : HTTPS. Cookies de session envoyés en HTTPS seulement. + - { cle: "APP_URL", valeur: "https://{{ portail_domaine }}" } + - { cle: "SESSION_SECURE_COOKIE", valeur: "true" } no_log: true register: portail_env_mis_a_jour # Horizon charge la configuration une seule fois au démarrage : sans diff --git a/ansible/roles/portail/tasks/05-nginx.yml b/ansible/roles/portail/tasks/05-nginx.yml index 531a8c2..ad1618e 100644 --- a/ansible/roles/portail/tasks/05-nginx.yml +++ b/ansible/roles/portail/tasks/05-nginx.yml @@ -1,9 +1,50 @@ --- -- name: déployer le vhost Nginx du portail +# Étape 12 : HTTPS par Let's Encrypt. certbot du dépôt Debian (2.1, mis à jour +# avec le reste du système par unattended-upgrades), authentificateur nginx +# en mode « certonly » : il ne modifie jamais nos fichiers de configuration, +# Ansible reste seul maître du vhost. +- name: installer Nginx et certbot + ansible.builtin.apt: + name: [nginx, certbot, python3-certbot-nginx] + state: present + +- name: déployer les extraits TLS et en-têtes de sécurité + ansible.builtin.template: + src: "{{ item }}.j2" + dest: "/etc/nginx/snippets/{{ item }}" + mode: "0644" + loop: + - syllabus-tls.conf + - syllabus-entetes.conf + notify: recharger nginx + +# La page de statut n'est servie que si son nom pointe déjà vers ce serveur : +# sinon Let's Encrypt refuserait tout le certificat. +- name: vérifier le DNS du sous-domaine de statut + ansible.builtin.command: getent ahostsv4 {{ portail_domaine_statut }} + register: portail_dns_statut + changed_when: false + failed_when: false + +- name: noter si la page de statut peut être activée + ansible.builtin.set_fact: + portail_statut_actif: "{{ portail_ip_publique in portail_dns_statut.stdout }}" + portail_noms_certificat: >- + {{ [portail_domaine] + portail_domaines_alias + + ([portail_domaine_statut] if portail_ip_publique in portail_dns_statut.stdout else []) }} + +- name: vérifier si le certificat existe déjà + ansible.builtin.stat: + path: "/etc/letsencrypt/live/{{ portail_domaine }}/fullchain.pem" + register: portail_certificat_avant + +- name: déployer le vhost (HTTP seul tant qu'aucun certificat n'existe) ansible.builtin.template: src: nginx-portail.conf.j2 dest: /etc/nginx/sites-available/portail.conf mode: "0644" + vars: + portail_https_actif: "{{ portail_certificat_avant.stat.exists }}" notify: recharger nginx - name: activer le vhost @@ -27,3 +68,57 @@ loop: - "php{{ portail_php_version }}-fpm" - nginx + +- name: appliquer la configuration Nginx avant le défi Let's Encrypt + ansible.builtin.meta: flush_handlers + +# --keep-until-expiring : ne fait rien si le certificat est valide et couvre +# déjà ces noms. --expand : ajoute un nom (ex. statut) au même certificat. +# Pas d'e-mail : Let's Encrypt n'envoie plus d'avis d'expiration depuis 2025, +# et le renouvellement est surveillé par Prometheus (voir supervision). +- name: obtenir ou étendre le certificat Let's Encrypt + ansible.builtin.command: + cmd: >- + certbot certonly --nginx --non-interactive --agree-tos + --register-unsafely-without-email + --cert-name {{ portail_domaine }} + {% for nom in portail_noms_certificat %}-d {{ nom }} {% endfor %} + --expand --keep-until-expiring + --deploy-hook "systemctl reload nginx" + register: portail_certbot + changed_when: "'Successfully received certificate' in portail_certbot.stdout" + +- name: passer le vhost en HTTPS + ansible.builtin.template: + src: nginx-portail.conf.j2 + dest: /etc/nginx/sites-available/portail.conf + mode: "0644" + vars: + portail_https_actif: true + notify: recharger nginx + +- name: vérifier que le renouvellement automatique est planifié + ansible.builtin.systemd: + name: certbot.timer + enabled: true + state: started + +# Pas d'e-mail Let's Encrypt : c'est Prometheus qui prévient si le +# renouvellement ne se fait pas (règle CertificatExpireBientot). +- name: déployer le script de mesure de l'expiration du certificat + ansible.builtin.template: + src: syllabus-expiration-certificat.sh.j2 + dest: /usr/local/sbin/syllabus-expiration-certificat + mode: "0755" + +- name: mesurer l'expiration chaque jour + ansible.builtin.cron: + name: "expiration du certificat HTTPS (métrique Prometheus)" + user: root + hour: "6" + minute: "17" + job: /usr/local/sbin/syllabus-expiration-certificat + +- name: mesurer l'expiration maintenant + ansible.builtin.command: /usr/local/sbin/syllabus-expiration-certificat + changed_when: false diff --git a/ansible/roles/portail/templates/nginx-portail.conf.j2 b/ansible/roles/portail/templates/nginx-portail.conf.j2 index 28a78b9..449d616 100644 --- a/ansible/roles/portail/templates/nginx-portail.conf.j2 +++ b/ansible/roles/portail/templates/nginx-portail.conf.j2 @@ -1,8 +1,5 @@ -server { - listen 80; - listen [::]:80; - server_name _; - +# Géré par Ansible (rôle portail) — ne pas modifier à la main. +{% macro bloc_portail() %} root {{ portail_dossier_app }}/public; index index.php; @@ -36,4 +33,95 @@ server { access_log /var/log/nginx/portail-access.log; error_log /var/log/nginx/portail-error.log; +{% endmacro %} +{% if portail_https_actif %} +# HTTP : tout part en HTTPS. Les noms connus gardent leur nom ; une requête +# sur l'IP ou un nom inconnu est envoyée vers le domaine principal. +server { + listen 80; + listen [::]:80; + server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }}{% if portail_statut_actif %} {{ portail_domaine_statut }}{% endif %}; + server_tokens off; + # Dans une location (pas au niveau server) : la location exacte que + # certbot ajoute pour le défi ACME au renouvellement reste prioritaire. + location / { + return 301 https://$host$request_uri; + } } + +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name _; + server_tokens off; + location / { + return 301 https://{{ portail_domaine }}$request_uri; + } +} + +# www → domaine principal (une seule adresse canonique). +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name {{ portail_domaines_alias | join(' ') }}; + server_tokens off; + include snippets/syllabus-tls.conf; + include snippets/syllabus-entetes.conf; + return 301 https://{{ portail_domaine }}$request_uri; +} + +server { + listen 443 ssl http2 default_server; + listen [::]:443 ssl http2 default_server; + server_name {{ portail_domaine }}; + server_tokens off; + include snippets/syllabus-tls.conf; + include snippets/syllabus-entetes.conf; +{{ bloc_portail() }} +} +{% if portail_statut_actif %} + +# Page de statut publique d'Uptime Kuma, et RIEN d'autre : l'administration +# (tableau de bord, /socket.io, /metrics…) reste accessible par tunnel SSH +# uniquement. Chemins relevés dans server/routers/status-page-router.js. +server { + listen 443 ssl http2; + listen [::]:443 ssl http2; + server_name {{ portail_domaine_statut }}; + server_tokens off; + include snippets/syllabus-tls.conf; + include snippets/syllabus-entetes.conf; + + location = / { + return 302 /status/{{ portail_statut_slug }}; + } + + location ~ ^/(status/[a-z0-9-]+$|api/status-page/|assets/|upload/|icon\.svg$|favicon\.ico$|apple-touch-icon[a-z0-9-]*\.png$|robots\.txt$) { + limit_except GET HEAD { + deny all; + } + proxy_pass http://127.0.0.1:{{ portail_port_uptime_kuma }}; + proxy_set_header Host $host; + proxy_set_header X-Forwarded-For $remote_addr; + proxy_set_header X-Forwarded-Proto https; + } + + location / { + return 404; + } + + access_log /var/log/nginx/statut-access.log; + error_log /var/log/nginx/statut-error.log; +} +{% endif %} +{% else %} +# Avant le premier certificat : HTTP seul, avec les noms du domaine pour que +# certbot (authentificateur nginx) trouve le bon bloc. +server { + listen 80 default_server; + listen [::]:80 default_server; + server_name {{ portail_domaine }} {{ portail_domaines_alias | join(' ') }} _; + server_tokens off; +{{ bloc_portail() }} +} +{% endif %} diff --git a/ansible/roles/portail/templates/syllabus-entetes.conf.j2 b/ansible/roles/portail/templates/syllabus-entetes.conf.j2 new file mode 100644 index 0000000..ff5730b --- /dev/null +++ b/ansible/roles/portail/templates/syllabus-entetes.conf.j2 @@ -0,0 +1,10 @@ +# Géré par Ansible (rôle portail). En-têtes communs à tous les sites HTTPS. +# La Content-Security-Policy du portail est posée par Laravel +# (App\Http\Middleware\PolitiqueSecuriteContenu) : elle dépend de la page. +# Attention : un add_header dans un bloc location masque ceux du server. +add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; +add_header X-Content-Type-Options "nosniff" always; +add_header X-Frame-Options "DENY" always; +add_header Referrer-Policy "strict-origin-when-cross-origin" always; +add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always; +add_header Cross-Origin-Opener-Policy "same-origin" always; diff --git a/ansible/roles/portail/templates/syllabus-expiration-certificat.sh.j2 b/ansible/roles/portail/templates/syllabus-expiration-certificat.sh.j2 new file mode 100644 index 0000000..ece6f85 --- /dev/null +++ b/ansible/roles/portail/templates/syllabus-expiration-certificat.sh.j2 @@ -0,0 +1,14 @@ +#!/bin/sh +# Géré par Ansible (rôle portail). Publie la date d'expiration du certificat +# HTTPS pour Prometheus (collecteur textfile de node_exporter) : alerte +# CertificatExpireBientot si le renouvellement automatique échoue. +set -eu +CERT=/etc/letsencrypt/live/{{ portail_domaine }}/fullchain.pem +DOSSIER=/var/lib/node_exporter/textfile +FIN=$(date -d "$(openssl x509 -enddate -noout -in "$CERT" | cut -d= -f2)" +%s) +cat > "$DOSSIER/certificat.prom.tmp" < 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. diff --git a/docs/runbooks/https.md b/docs/runbooks/https.md new file mode 100644 index 0000000..fc3c968 --- /dev/null +++ b/docs/runbooks/https.md @@ -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 : + +``` + +``` + +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`. diff --git a/kolla/config/prometheus/alertes.rules b/kolla/config/prometheus/alertes.rules index 0617ca0..4c4745d 100644 --- a/kolla/config/prometheus/alertes.rules +++ b/kolla/config/prometheus/alertes.rules @@ -28,6 +28,24 @@ groups: summary: "Un agent OpenStack est arrete" description: "Un agent Nova ou Neutron signale un etat different de UP depuis plus de 2 minutes." + - alert: CertificatExpireBientot + expr: (syllabus_certificat_expiration_timestamp - time()) < 14 * 86400 + for: 1h + labels: + severite: critique + annotations: + summary: "Le certificat HTTPS de {{ $labels.domaine }} expire dans moins de 14 jours" + description: "Le renouvellement automatique (certbot.timer) a echoue : voir docs/runbooks/https.md." + + - alert: CertificatNonMesure + expr: absent(syllabus_certificat_expiration_timestamp) + for: 2d + labels: + severite: avertissement + annotations: + summary: "L'expiration du certificat HTTPS n'est plus mesuree" + description: "Le fichier certificat.prom du collecteur textfile n'est plus produit : verifier le cron syllabus-expiration-certificat." + - alert: SauvegardeAncienne expr: (time() - syllabus_derniere_sauvegarde_reussie_timestamp) > 26 * 3600 for: 10m diff --git a/portail/app/Http/Controllers/RapportCspController.php b/portail/app/Http/Controllers/RapportCspController.php new file mode 100644 index 0000000..7cf4479 --- /dev/null +++ b/portail/app/Http/Controllers/RapportCspController.php @@ -0,0 +1,31 @@ +getContent(), true)['csp-report'] ?? null; + + if (is_array($rapport)) { + Log::warning('Violation CSP', [ + 'page' => Str::limit(strtok((string) ($rapport['document-uri'] ?? ''), '?'), 200), + 'directive' => Str::limit((string) ($rapport['violated-directive'] ?? $rapport['effective-directive'] ?? ''), 100), + 'bloque' => Str::limit(strtok((string) ($rapport['blocked-uri'] ?? ''), '?'), 200), + ]); + } + + return response()->noContent(); + } +} diff --git a/portail/app/Http/Middleware/PolitiqueSecuriteContenu.php b/portail/app/Http/Middleware/PolitiqueSecuriteContenu.php new file mode 100644 index 0000000..8dccd2d --- /dev/null +++ b/portail/app/Http/Middleware/PolitiqueSecuriteContenu.php @@ -0,0 +1,87 @@ +headers->has('Content-Security-Policy')) { + $reponse->headers->set('Content-Security-Policy', $this->politique($request)); + } + + return $reponse; + } + + private function politique(Request $request): string + { + $communes = [ + 'default-src' => "'self'", + 'img-src' => "'self' data:", + 'font-src' => "'self' https://fonts.gstatic.com", + 'object-src' => "'none'", + 'base-uri' => "'self'", + 'form-action' => "'self'", + 'frame-ancestors' => "'none'", + 'report-uri' => '/csp-rapport', + ]; + + $directives = $this->estApplication($request) + ? [ + 'script-src' => "'self' 'nonce-".Vite::cspNonce()."'", + // Attributs style= présents dans le gabarit commun : sans + // risque d'exécution de code, tolérés. + 'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com", + 'connect-src' => "'self' wss://{$request->getHost()}", + ] + : [ + 'script-src' => "'self' 'unsafe-inline' https://cdn.jsdelivr.net https://cdn.tailwindcss.com", + 'style-src' => "'self' 'unsafe-inline' https://fonts.googleapis.com https://cdn.jsdelivr.net", + 'connect-src' => "'self'", + 'frame-src' => 'https://www.youtube-nocookie.com', + ]; + + return collect($communes + $directives) + ->map(fn (string $valeur, string $directive) => "{$directive} {$valeur}") + ->implode('; '); + } + + private function estApplication(Request $request): bool + { + $route = $request->route(); + + if ($route === null) { + return false; + } + + if (in_array($route->getName(), ['login', 'register'], true)) { + return true; + } + + return collect($route->gatherMiddleware()) + ->contains(fn ($middleware) => is_string($middleware) && str_starts_with($middleware, 'auth:')); + } +} diff --git a/portail/bootstrap/app.php b/portail/bootstrap/app.php index f65967d..2fc66ac 100644 --- a/portail/bootstrap/app.php +++ b/portail/bootstrap/app.php @@ -1,5 +1,6 @@ alias([ 'session.unique' => VerifieSessionUnique::class, ]); + $middleware->web(append: [PolitiqueSecuriteContenu::class]); }) ->withExceptions(function (Exceptions $exceptions): void { $exceptions->shouldRenderJsonWhen( diff --git a/portail/config/cours.php b/portail/config/cours.php new file mode 100644 index 0000000..3b5805b --- /dev/null +++ b/portail/config/cours.php @@ -0,0 +1,264 @@ + 'nsi', + 'nom' => 'Lycée NSI', + 'sous_titre' => 'Première et Terminale · spécialité NSI', + 'couleur' => 'bg-[#FFD23F]', + 'blocs' => [ + [ + 'titre' => 'Première · Représentation des données', + 'cours' => [ + ['titre' => 'Entiers naturels : binaire et hexadécimal', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Entiers relatifs et complément à deux', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Nombres flottants et leurs pièges', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Codage des caractères : ASCII, Unicode, UTF-8', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 2], + ], + ], + [ + 'titre' => 'Première · Architectures et systèmes', + 'cours' => [ + ['titre' => 'Modèle de von Neumann', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Système d’exploitation et commandes Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Droits et permissions des fichiers', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 2], + ['titre' => 'Réseau : TCP/IP et protocole du bit alterné', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'Première · Web et données', + 'cours' => [ + ['titre' => 'HTTP, formulaires GET et POST', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Traitement de données en tables CSV', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'Terminale · Architectures, réseaux, sécurité', + 'cours' => [ + ['titre' => 'Processus et ordonnancement', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Routage : protocoles RIP et OSPF', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Chiffrement symétrique, asymétrique et HTTPS', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'Terminale · Données et structures', + 'cours' => [ + ['titre' => 'Bases de données relationnelles et SQL', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Piles, files et listes chaînées', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Arbres binaires et graphes', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 4], + ], + ], + ], + ], + [ + 'id' => 'sio', + 'nom' => 'BTS SIO SISR', + 'sous_titre' => 'Services informatiques aux organisations · option SISR', + 'couleur' => 'bg-[#7CF2C0]', + 'blocs' => [ + [ + 'titre' => 'Bloc 1 · Support et mise à disposition de services', + 'cours' => [ + ['titre' => 'Gérer le parc avec GLPI', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Traiter un incident : ticket, diagnostic, résolution', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Installer et configurer un poste Debian', 'types' => ['TP', 'QCM'], 'heures' => 2], + ['titre' => 'Présence en ligne : nom de domaine et DNS public', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ], + ], + [ + 'titre' => 'Bloc 2 · Administration des systèmes et des réseaux', + 'cours' => [ + ['titre' => 'Adressage IPv4 et sous-réseaux', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'VLAN et commutation', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Routage inter-VLAN', 'types' => ['TD', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Serveur DHCP sous Debian', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 2], + ['titre' => 'Serveur DNS avec BIND 9', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Annuaire Samba AD et stratégies de groupe', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Supervision avec Zabbix', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Sauvegarde et restauration', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Virtualisation et conteneurs', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'Bloc 3 · Cybersécurité des services', + 'cours' => [ + ['titre' => 'RGPD et protection des données', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Authentification et mots de passe', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Pare-feu avec nftables', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'VPN avec WireGuard', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Journalisation et détection d’intrusion', 'types' => ['TP', 'QCM'], 'heures' => 3], + ], + ], + ], + ], + [ + 'id' => 'ciel', + 'nom' => 'BTS CIEL', + 'sous_titre' => 'Cybersécurité, informatique et réseaux, électronique · option IR', + 'couleur' => 'bg-[#C4B5FD]', + 'blocs' => [ + [ + 'titre' => 'Réseaux', + 'cours' => [ + ['titre' => 'Numération et codage de l’information', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Modèle OSI et encapsulation', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Ethernet, câblage et commutation', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Adressage IPv4 et IPv6', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Services réseau : SSH, HTTP, FTP', 'types' => ['TP', 'QCM'], 'heures' => 3], + ['titre' => 'Capturer et analyser des trames avec tcpdump', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'Informatique', + 'cours' => [ + ['titre' => 'Scripts Bash', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Python pour l’administration système', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Objets connectés et protocole MQTT', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'Cybersécurité', + 'cours' => [ + ['titre' => 'Analyse de risques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 2], + ['titre' => 'Durcir un serveur Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Détecter une attaque dans les journaux', 'types' => ['TP', 'QCM'], 'heures' => 2], + ], + ], + ], + ], + [ + 'id' => 'but', + 'nom' => 'BUT R&T', + 'sous_titre' => 'Réseaux et télécommunications · parcours Cybersécurité et DevCloud', + 'couleur' => 'bg-[#FF6FB5]', + 'blocs' => [ + [ + 'titre' => 'Semestres 1 et 2', + 'cours' => [ + ['titre' => 'Initiation aux réseaux informatiques', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Fondamentaux des systèmes Linux', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Architecture des systèmes numériques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3], + ['titre' => 'Transmission et supports physiques', 'types' => ['CM', 'TD', 'QCM'], 'heures' => 3], + ['titre' => 'Bases de la programmation', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ], + ], + [ + 'titre' => 'Semestres 3 et 4', + 'cours' => [ + ['titre' => 'Routage dynamique OSPF', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Services réseaux : DNS, mail, web', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Réseaux d’opérateurs : BGP et MPLS', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Supervision et métrologie', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Automatiser avec Ansible', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ], + ], + [ + 'titre' => 'Parcours Cybersécurité et DevCloud', + 'cours' => [ + ['titre' => 'Cryptographie appliquée : PKI et certificats', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Conteneurs Docker et orchestration', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Auditer la sécurité d’un réseau', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ], + ], + ], + ], + [ + 'id' => 'tssr', + 'nom' => 'Titre pro TSSR', + 'sous_titre' => 'Technicien supérieur systèmes et réseaux · niveau 5', + 'couleur' => 'bg-white', + 'blocs' => [ + [ + 'titre' => 'CCP 1 · Assister les utilisateurs', + 'cours' => [ + ['titre' => 'Mettre en service un poste client', 'types' => ['TP', 'QCM'], 'heures' => 2], + ['titre' => 'Diagnostiquer une panne réseau', 'types' => ['TD', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Rédiger une procédure utilisateur', 'types' => ['CM', 'TD'], 'heures' => 2], + ], + ], + [ + 'titre' => 'CCP 2 · Infrastructure centralisée', + 'cours' => [ + ['titre' => 'Déployer un domaine Samba AD', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ['titre' => 'Partages de fichiers et ACL', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Sauvegardes avec restic', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 2], + ['titre' => 'Superviser une infrastructure', 'types' => ['TP', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'CCP 3 · Infrastructure distribuée', + 'cours' => [ + ['titre' => 'Déployer des services dans le cloud', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Docker Compose en production', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Automatiser l’administration en Bash', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'VPN site à site', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ], + ], + ], + ], + [ + 'id' => 'prog', + 'nom' => 'Programmation', + 'sous_titre' => 'Python, web, SQL, Bash, C · exercices dans l’IDE en ligne', + 'couleur' => 'bg-[#FF6FB5]', + 'blocs' => [ + [ + 'titre' => 'Python · les bases', + 'cours' => [ + ['titre' => 'Variables et types', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'], + ['titre' => 'Conditions : if, elif, else', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'], + ['titre' => 'Boucles for et while', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'], + ['titre' => 'Fonctions et paramètres', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-pair'], + ['titre' => 'Listes et tuples', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'], + ['titre' => 'Chaînes de caractères', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-voyelles'], + ['titre' => 'Dictionnaires', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-voyelles'], + ['titre' => 'Lire et écrire des fichiers CSV', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-somme'], + ], + ], + [ + 'titre' => 'Python · aller plus loin', + 'cours' => [ + ['titre' => 'Récursivité', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'], + ['titre' => 'Programmation orientée objet', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 4, 'exercice' => 'py-pair'], + ['titre' => 'Algorithmes de tri', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'py-somme'], + ['titre' => 'Recherche dichotomique', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-somme'], + ['titre' => 'Tester son code avec assert', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'py-pair'], + ['titre' => 'Sockets : un client réseau en Python', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ], + ], + [ + 'titre' => 'Web', + 'cours' => [ + ['titre' => 'HTML : structurer une page', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'web-bouton'], + ['titre' => 'CSS : flexbox et grid', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'], + ['titre' => 'JavaScript et le DOM', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'], + ['titre' => 'Formulaires et validation', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'web-bouton'], + ['titre' => 'API REST et JSON avec fetch', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'web-bouton'], + ['titre' => 'PHP côté serveur', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 4], + ], + ], + [ + 'titre' => 'Bases de données', + 'cours' => [ + ['titre' => 'SELECT, WHERE, ORDER BY', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'], + ['titre' => 'Agrégats et GROUP BY', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-group'], + ['titre' => 'Jointures', 'types' => ['CM', 'TD', 'IDE', 'QCM'], 'heures' => 3, 'exercice' => 'sql-select'], + ['titre' => 'Créer des tables et des contraintes', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'], + ['titre' => 'Injection SQL : comprendre et s’en protéger', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'sql-select'], + ], + ], + [ + 'titre' => 'Système et outils', + 'cours' => [ + ['titre' => 'Scripts Bash avancés', 'types' => ['CM', 'IDE', 'TP', 'QCM'], 'heures' => 3, 'exercice' => 'bash-tp'], + ['titre' => 'Expressions régulières', 'types' => ['CM', 'IDE', 'QCM'], 'heures' => 2, 'exercice' => 'bash-tp'], + ['titre' => 'Git : versionner son code', 'types' => ['CM', 'TP', 'QCM'], 'heures' => 3], + ['titre' => 'Langage C : pointeurs et mémoire', 'types' => ['CM', 'TD', 'TP', 'QCM'], 'heures' => 4], + ], + ], + ], + ], +]; diff --git a/portail/package-lock.json b/portail/package-lock.json index 86450e3..926b233 100644 --- a/portail/package-lock.json +++ b/portail/package-lock.json @@ -4,6 +4,10 @@ "requires": true, "packages": { "": { + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0" + }, "devDependencies": { "@tailwindcss/vite": "^4.0.0", "laravel-vite-plugin": "^3.1", @@ -660,6 +664,21 @@ "vite": "^5.2.0 || ^6 || ^7 || ^8" } }, + "node_modules/@xterm/addon-fit": { + "version": "0.10.0", + "resolved": "https://registry.npmjs.org/@xterm/addon-fit/-/addon-fit-0.10.0.tgz", + "integrity": "sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==", + "license": "MIT", + "peerDependencies": { + "@xterm/xterm": "^5.0.0" + } + }, + "node_modules/@xterm/xterm": { + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/@xterm/xterm/-/xterm-5.5.0.tgz", + "integrity": "sha512-hqJHYaQb5OptNunnyAnkHyM8aCjZ1MEIDTQu1iIbbTD/xops91NB5yq1ZK/dC2JDbVWtF23zUtl9JE2NqwT87A==", + "license": "MIT" + }, "node_modules/ansi-escapes": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/ansi-escapes/-/ansi-escapes-7.3.0.tgz", diff --git a/portail/package.json b/portail/package.json index f20dc02..17f32ed 100644 --- a/portail/package.json +++ b/portail/package.json @@ -14,5 +14,9 @@ }, "optionalDependencies": { "@laravel/multiplex": "^0.4.1" + }, + "dependencies": { + "@xterm/addon-fit": "^0.10.0", + "@xterm/xterm": "^5.5.0" } } diff --git a/portail/resources/js/console.js b/portail/resources/js/console.js new file mode 100644 index 0000000..92f9761 --- /dev/null +++ b/portail/resources/js/console.js @@ -0,0 +1,9 @@ +// xterm.js servi par le portail lui-même (étape 12) plutôt que par un CDN : +// la CSP stricte des pages de l'application n'autorise que nos propres +// scripts, et des réseaux scolaires filtrent les CDN. +import { Terminal } from '@xterm/xterm'; +import { FitAddon } from '@xterm/addon-fit'; +import '@xterm/xterm/css/xterm.css'; + +window.Terminal = Terminal; +window.FitAddon = { FitAddon }; diff --git a/portail/resources/views/auth/inscription.blade.php b/portail/resources/views/auth/inscription.blade.php index 1fbb85c..6738e95 100644 --- a/portail/resources/views/auth/inscription.blade.php +++ b/portail/resources/views/auth/inscription.blade.php @@ -55,7 +55,7 @@ - @endsection diff --git a/portail/resources/views/seances/attente-eleve.blade.php b/portail/resources/views/seances/attente-eleve.blade.php index 4dfa1d2..a4e4551 100644 --- a/portail/resources/views/seances/attente-eleve.blade.php +++ b/portail/resources/views/seances/attente-eleve.blade.php @@ -66,11 +66,11 @@ @endif - - - - - + @else
@@ -101,7 +102,7 @@
@if (in_array($labSession->etat, [$etats::EN_ATTENTE, $etats::ADMIS, $etats::ECHEC], true)) - - - - + @endif diff --git a/portail/routes/web.php b/portail/routes/web.php index 36c1091..77b8702 100644 --- a/portail/routes/web.php +++ b/portail/routes/web.php @@ -6,7 +6,9 @@ use App\Http\Controllers\ConsoleController; use App\Http\Controllers\CorrectionController; use App\Http\Controllers\EleveTableauDeBordController; use App\Http\Controllers\InscriptionController; +use App\Http\Controllers\RapportCspController; use App\Http\Controllers\SeanceController; +use Illuminate\Foundation\Http\Middleware\ValidateCsrfToken; use Illuminate\Support\Facades\Route; Route::view('/', 'accueil')->name('accueil'); @@ -22,6 +24,11 @@ Route::view('/inscription', 'auth.inscription')->name('register'); Route::view('/classes/suivi', 'classes.suivi')->name('suivi'); Route::view('/examen', 'examen')->name('examen'); // À brancher sur l'envoi d'e-mail (Mail::to('adam@belkacemi.me')->send(...)). +// Envoyé par les navigateurs eux-mêmes (pas de jeton CSRF possible). +Route::post('/csp-rapport', RapportCspController::class) + ->withoutMiddleware(ValidateCsrfToken::class) + ->middleware('throttle:30,1') + ->name('csp.rapport'); Route::post('/contact', fn () => back()->with('contact_ok', true))->name('contact'); Route::view('/fonctionnalites', 'fonctionnalites')->name('fonctionnalites'); Route::view('/legal', 'legal')->name('legal'); diff --git a/portail/tests/Feature/PagesPubliquesTest.php b/portail/tests/Feature/PagesPubliquesTest.php new file mode 100644 index 0000000..ceb223b --- /dev/null +++ b/portail/tests/Feature/PagesPubliquesTest.php @@ -0,0 +1,22 @@ +filter(fn ($r) => in_array('GET', $r->methods(), true) + && $r->getName() !== null + && ! str_contains($r->uri(), '{') + && ! collect($r->gatherMiddleware())->contains(fn ($m) => is_string($m) && str_starts_with($m, 'auth'))) + ->reject(fn ($r) => str_starts_with($r->uri(), 'horizon') || str_starts_with($r->uri(), '_') || $r->uri() === 'up'); + + expect($pages)->not->toBeEmpty(); + + $echecs = $pages->mapWithKeys(fn ($r) => [$r->uri() => $this->get('/'.ltrim($r->uri(), '/'))->getStatusCode()]) + ->reject(fn ($code) => $code === 200); + + expect($echecs->all())->toBe([]); +}); diff --git a/portail/tests/Feature/PolitiqueSecuriteContenuTest.php b/portail/tests/Feature/PolitiqueSecuriteContenuTest.php new file mode 100644 index 0000000..a21cc60 --- /dev/null +++ b/portail/tests/Feature/PolitiqueSecuriteContenuTest.php @@ -0,0 +1,48 @@ +headers->get('Content-Security-Policy'))) + ->mapWithKeys(function (string $d) { + $morceaux = preg_split('/\s+/', trim($d), 2); + + return [$morceaux[0] => $morceaux[1] ?? '']; + })->all(); +} + +test("les pages de l'application ont une CSP stricte : pas de script en ligne sans le nonce", function () { + $eleve = Eleve::factory()->create(); + $jeton = $eleve->demarreNouvelleSession(); + + $reponse = $this->withSession(['jeton_session_eleve' => $jeton])->actingAs($eleve, 'eleve')->get(route('eleve')); + $csp = politique($reponse); + + expect($csp['script-src'])->toMatch("/^'self' 'nonce-[A-Za-z0-9+\\/=]+'$/") + ->and($csp['script-src'])->not->toContain('unsafe-inline') + ->and($csp['object-src'])->toBe("'none'") + ->and($csp['frame-ancestors'])->toBe("'none'"); +}); + +test('la page de connexion est stricte aussi, la vitrine tolère les scripts en ligne', function () { + expect(politique($this->get(route('login')))['script-src'])->not->toContain('unsafe-inline') + ->and(politique($this->get(route('accueil')))['script-src'])->toContain("'unsafe-inline'"); +}); + +test("chaque script de la page d'inscription porte le nonce de l'en-tête", function () { + $reponse = $this->get(route('register')); + preg_match("/'nonce-([^']+)'/", $reponse->headers->get('Content-Security-Policy'), $m); + + preg_match_all('/]*>/', $reponse->getContent(), $scripts); + expect($scripts[0])->not->toBeEmpty(); + foreach ($scripts[0] as $balise) { + expect($balise)->toContain("nonce=\"{$m[1]}\""); + } +}); + +test('le rapport de violation est accepté sans jeton CSRF', function () { + $this->call('POST', route('csp.rapport'), [], [], [], ['CONTENT_TYPE' => 'application/csp-report'], + json_encode(['csp-report' => ['document-uri' => 'https://syllabuslab.fr/eleve?x=1', 'violated-directive' => 'script-src', 'blocked-uri' => 'inline']])) + ->assertNoContent(); +}); diff --git a/portail/tests/Reels/parcours-lab.py b/portail/tests/Reels/parcours-lab.py index 592da55..2fa70c0 100644 --- a/portail/tests/Reels/parcours-lab.py +++ b/portail/tests/Reels/parcours-lab.py @@ -9,8 +9,10 @@ Usage (sur le serveur, après preparer-parcours.php) : sudo -u www-data python3 tests/Reels/parcours-lab.py + (PARCOURS_BASE=… pour viser une autre adresse que https://syllabuslab.fr) """ import json +import os import re import threading import time @@ -18,7 +20,9 @@ import time import requests import websocket -BASE = "http://127.0.0.1" +# Depuis l'étape 12 : par le domaine, en HTTPS (cookies « secure »). +BASE = os.environ.get("PARCOURS_BASE", "https://syllabuslab.fr") +BASE_WS = re.sub(r"^http", "ws", BASE) ids = json.load(open("/tmp/parcours-identifiants.json")) seance = ids["seance_id"] @@ -70,7 +74,7 @@ consoles = {} for url, nom in zip(re.findall(r"fetch\('(http[^']+/console)'", page), re.findall(r'data-onglet="\d+"[^>]*>([^<]+)<', page)): url = re.sub(r"^https?://[^/]+", BASE, url) jeton = eleve.post(url, headers={"X-CSRF-TOKEN": csrf}).json()["jeton"] - consoles[nom] = websocket.create_connection(f"ws://127.0.0.1/console/{jeton}", timeout=10) + consoles[nom] = websocket.create_connection(f"{BASE_WS}/console/{jeton}", timeout=10) def lire_jusqua(ws, motif, delai=15): diff --git a/portail/vite.config.js b/portail/vite.config.js index 1fd66d5..72c6295 100644 --- a/portail/vite.config.js +++ b/portail/vite.config.js @@ -6,7 +6,7 @@ import tailwindcss from '@tailwindcss/vite'; export default defineConfig({ plugins: [ laravel({ - input: ['resources/css/app.css', 'resources/js/app.js'], + input: ['resources/css/app.css', 'resources/js/app.js', 'resources/js/console.js'], refresh: true, fonts: [ bunny('Instrument Sans', {