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,31 @@
<?php
namespace App\Http\Controllers;
use Illuminate\Http\Request;
use Illuminate\Http\Response;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Str;
/**
* Reçoit les violations de CSP envoyées par les navigateurs : c'est le seul
* moyen de savoir qu'une page casse chez un élève. Seuls quelques champs
* sont gardés, tronqués, sans la chaîne de requête des URL.
*/
class RapportCspController extends Controller
{
public function __invoke(Request $request): Response
{
$rapport = json_decode($request->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();
}
}

View file

@ -0,0 +1,87 @@
<?php
namespace App\Http\Middleware;
use Closure;
use Illuminate\Http\Request;
use Illuminate\Support\Facades\Vite;
use Symfony\Component\HttpFoundation\Response;
/**
* Content-Security-Policy, en deux niveaux (étape 12) :
*
* - application (connexion, inscription, toute page authentifiée) : seuls
* nos scripts et ceux qui portent le nonce de la requête s'exécutent. Une
* injection de script dans un nom d'élève, par exemple, serait bloquée
* par le navigateur ;
* - vitrine (pages reprises du design) : scripts en ligne et CDN autorisés,
* faute de pouvoir les réécrire ici. Ces pages n'affichent aucune donnée
* d'utilisateur.
*
* Les violations sont envoyées à /csp-rapport (journal Laravel).
* Les autres en-têtes (HSTS, nosniff…) sont posés par Nginx.
*/
class PolitiqueSecuriteContenu
{
public function handle(Request $request, Closure $next): Response
{
Vite::useCspNonce();
$reponse = $next($request);
if (! $reponse->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:'));
}
}

View file

@ -1,5 +1,6 @@
<?php
use App\Http\Middleware\PolitiqueSecuriteContenu;
use App\Http\Middleware\VerifieSessionUnique;
use Illuminate\Foundation\Application;
use Illuminate\Foundation\Configuration\Exceptions;
@ -16,6 +17,7 @@ return Application::configure(basePath: dirname(__DIR__))
$middleware->alias([
'session.unique' => VerifieSessionUnique::class,
]);
$middleware->web(append: [PolitiqueSecuriteContenu::class]);
})
->withExceptions(function (Exceptions $exceptions): void {
$exceptions->shouldRenderJsonWhen(

264
portail/config/cours.php Normal file
View file

@ -0,0 +1,264 @@
<?php
// Catalogue des cours gratuits, inspiré des référentiels de l'Éducation nationale.
// types : CM (cours magistral), TD (travaux dirigés), TP (travaux pratiques sur VM), QCM.
return [
[
'id' => '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],
],
],
],
],
];

View file

@ -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",

View file

@ -14,5 +14,9 @@
},
"optionalDependencies": {
"@laravel/multiplex": "^0.4.1"
},
"dependencies": {
"@xterm/addon-fit": "^0.10.0",
"@xterm/xterm": "^5.5.0"
}
}

View file

@ -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 };

View file

@ -55,7 +55,7 @@
</form>
</div>
</section>
<script>
<script nonce="{{ Vite::cspNonce() }}">
document.querySelectorAll('[data-etab]').forEach(function (s) {
var box = s.parentNode.querySelector('[data-etab-autre]');
function maj() {

View file

@ -4,6 +4,13 @@
@section('contenu')
<section style="padding:112px 20px 96px;background:#fff"><div style="max-width:900px;margin:0 auto"><p style="margin:0;font-size:15px;font-weight:700;color:#0b57d0;text-align:center">Gratuit, sans compte</p><h1 style="margin:20px 0 0;font-size:clamp(40px,6vw,72px);line-height:1.04;font-weight:800;letter-spacing:-0.03em;color:#0b1220;text-align:center;text-wrap:balance">99 cours gratuits, calés sur vos référentiels.</h1><p style="margin:24px auto 0;max-width:660px;font-size:20px;line-height:1.6;font-weight:500;color:#3c4043;text-align:center;text-wrap:pretty">Chaque cours suit le découpage de l’Éducation nationale : un CM pour comprendre, un TD pour s’exercer sur papier, un TP sur une vraie VM, un QCM corrigé et expliqué.</p><p style="margin:28px 0 0;text-align:center;font-size:15px;font-weight:700;color:#5f6368">99 cours · 45 TD · 59 TP sur VM · 98 QCM corrigés</p><div style="margin-top:44px;display:flex;flex-direction:column;align-items:center;gap:14px"><a href="#nsi" style="display:inline-flex;align-items:center;height:56px;padding:0 32px;border-radius:999px;background:#0b57d0;color:#fff;font-size:17px;font-weight:700;text-decoration:none">Choisir mon cursus</a></div><nav aria-label="Cursus" style="margin-top:28px;display:flex;flex-wrap:wrap;justify-content:center;gap:8px 24px"><a href="#nsi" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Lycée NSI</a><a href="#sio" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BTS SIO SISR</a><a href="#ciel" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BTS CIEL</a><a href="#but" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">BUT R&amp;T</a><a href="#tssr" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Titre pro TSSR</a><a href="#prog" style="font-size:15px;font-weight:700;color:#0b57d0;text-decoration:none">Programmation</a></nav></div></section>
{{-- Boucle et données absentes de la maquette design/ (perdues lors de sa
refonte) : rétablies d'après design/_backup-ludique, étape 12. --}}
@php
$cursus = config('cours');
$fond = ['CM' => 'bg-white', 'TD' => 'bg-[#eef0fd]', 'TP' => 'bg-[#e6f4ea]', 'QCM' => 'bg-[#fef7e0]', 'IDE' => 'bg-[#13233A] text-white'];
@endphp
@foreach ($cursus as $c)
<section id="{{ $c['id'] }}" class="scroll-mt-24" style="border-top:1px solid #e3e5e8" aria-labelledby="t-{{ $c['id'] }}">
<div class="mx-auto max-w-7xl px-5 py-16 lg:px-10">
<div class="flex flex-wrap items-end gap-x-8 gap-y-3">

View file

@ -27,7 +27,7 @@
<span class="font-mono text-sm font-semibold">({{ session('nombre_importes') }} élève(s) importé(s))</span>
@endif
</h2>
<button type="button" onclick="window.print()" class="border border-[#dadce0] rounded-lg bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
<button type="button" data-imprimer class="border border-[#dadce0] rounded-lg bg-black px-4 py-2 font-extrabold text-white">Imprimer</button>
</div>
<p class="mt-2 font-semibold print:hidden">Le code secret ne sera plus jamais affiché après cette page : imprime ou note-le maintenant.</p>
<div class="mt-6 grid gap-4 sm:grid-cols-2 print:grid-cols-1">
@ -83,7 +83,7 @@
<td class="px-4 py-3 font-mono text-sm">{{ $eleve->identifiant }}</td>
<td class="px-4 py-3 font-semibold">{{ $eleve->derniere_connexion_a?->diffForHumans() ?? 'jamais' }}</td>
<td class="px-4 py-3">
<form action="{{ route('mes-classes.eleves.regenererCode', [$classe, $eleve]) }}" method="post" onsubmit="return confirm('Régénérer le code de {{ $eleve->prenom }} ? L’ancien code ne fonctionnera plus.');">
<form action="{{ route('mes-classes.eleves.regenererCode', [$classe, $eleve]) }}" method="post" data-confirmer="Régénérer le code de {{ $eleve->prenom }} ? L’ancien code ne fonctionnera plus.">
@csrf
<button type="submit" class="font-bold underline underline-offset-4">Régénérer le code</button>
</form>
@ -133,4 +133,10 @@
@endif
</section>
</div>
<script nonce="{{ Vite::cspNonce() }}">
document.querySelectorAll('[data-imprimer]').forEach(function (b) { b.addEventListener('click', function () { window.print(); }); });
document.querySelectorAll('form[data-confirmer]').forEach(function (f) {
f.addEventListener('submit', function (e) { if (!window.confirm(f.dataset.confirmer)) e.preventDefault(); });
});
</script>
@endsection

View file

@ -66,11 +66,11 @@
@endif
</section>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="{{ asset('js/console-reelle.js') }}"></script>
<script>
@vite('resources/js/console.js')
<script nonce="{{ Vite::cspNonce() }}" src="{{ asset('js/console-reelle.js') }}"></script>
<script nonce="{{ Vite::cspNonce() }}">
// Après les modules Vite (différés) : window.Terminal est défini.
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
bouton.addEventListener('click', function () {
var id = bouton.dataset.onglet;
@ -91,6 +91,7 @@
window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: false, reveil: true });
});
@endforeach
});
</script>
@else
<div class="py-20 text-center">
@ -101,7 +102,7 @@
</div>
@if (in_array($labSession->etat, [$etats::EN_ATTENTE, $etats::ADMIS, $etats::ECHEC], true))
<script>
<script nonce="{{ Vite::cspNonce() }}">
(function () {
var etatAffiche = @json($labSession->etat);
setInterval(function () {

View file

@ -20,11 +20,11 @@
<div id="terminal-{{ $machine->id }}" class="h-full"></div>
</div>
@endforeach
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="{{ asset('js/console-reelle.js') }}"></script>
<script>
@vite('resources/js/console.js')
<script nonce="{{ Vite::cspNonce() }}" src="{{ asset('js/console-reelle.js') }}"></script>
<script nonce="{{ Vite::cspNonce() }}">
// Après les modules Vite (différés) : window.Terminal est défini.
document.addEventListener('DOMContentLoaded', function () {
document.querySelectorAll('.onglet-machine').forEach(function (bouton) {
bouton.addEventListener('click', function () {
var id = bouton.dataset.onglet;
@ -40,6 +40,7 @@
window.SyllabusConsoleReelle.mount(document.getElementById('terminal-{{ $machine->id }}'), { jeton: d.jeton, lectureSeule: true });
});
@endforeach
});
</script>
@endif
</div>

View file

@ -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');

View file

@ -0,0 +1,22 @@
<?php
use Illuminate\Support\Facades\Route;
// Toutes les pages publiques sans paramètre doivent s'afficher : la page
// /cours a été en erreur 500 de l'étape 8 à l'étape 12 sans que personne
// ne le voie.
test('chaque page publique s’affiche sans erreur', function () {
$pages = collect(Route::getRoutes())
->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([]);
});

View file

@ -0,0 +1,48 @@
<?php
use App\Models\Eleve;
function politique($reponse): array
{
return collect(explode(';', $reponse->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('/<script\b[^>]*>/', $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();
});

View file

@ -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):

View file

@ -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', {