J'ai changé de moteur il y a deux jours et j'ai expliqué le quoi en trois lignes : du Markdown, un script en ligne de commande, du HTML servi tel quel. Le quoi est facile. Le pourquoi mérite mieux qu'un argument de goût, parce que ce n'en est pas un : c'est un calcul de surface d'attaque, et ce calcul n'a plus le même résultat qu'il y a dix ans.
Ce que je maintenais réellement
Dotclear d'abord. Un bon moteur, sérieux, francophone, que j'ai quitté sans rien lui reprocher — et c'est justement le point. On ne quitte pas un outil comme celui-là parce qu'il est mauvais : on le quitte parce que les autres l'ont quitté. L'écosystème se vide, les extensions cessent d'être suivies, les réponses aux questions datent de sept ans. Le code, lui, va très bien. C'est l'entourage qui a disparu. Une popularité qui reflue est un risque d'exploitation, pas une question d'ego.
WordPress ensuite, et je vais lui rendre ce qui lui appartient : il a parfaitement réussi ce qu'il avait à faire. Il a mis en capacité des milliers de graphistes qui n'avaient jamais écrit une ligne de PHP ou d'ASP, et qui ont pu livrer à leurs clients un site qui tourne, qui se met à jour, que le client peut modifier seul. C'est une réussite d'ingénierie sociale considérable, et je n'ai aucune ironie à y mettre.
Simplement, ce n'était pas mon standard. Je ne m'en suis jamais servi pour construire un site — uniquement pour bloguer, c'est-à-dire pour la seule chose où il m'apportait quelque chose. Et même dans cet usage minimal, avec presque aucune extension installée et le maximum de fonctions désactivées, il fallait mettre à jour. Encore. Toujours.
Le vrai coût n'est pas la mise à jour. Elle prend trois minutes. Le coût, c'est l'astreinte : savoir qu'un correctif est sorti, savoir s'il me concerne, et être disponible pour l'appliquer — un dimanche soir comme un jour de semaine. Un moteur dynamique exposé au web, c'est un engagement permanent qu'on prend sans le formuler.
Ce qui a changé dehors
Le calcul qui précède aurait déjà tenu en 2012. Ce qui l'a rendu tranchant, c'est le contexte.
La reconnaissance ne coûte plus rien. Énumérer cinq mille chemins d'administration connus sur un domaine, c'était un balayage bruyant et lent quand la bande passante se comptait en kilobits ; c'est aujourd'hui l'affaire de quelques secondes, pour un attaquant qui balaie des plages entières d'adresses sans jamais viser personne en particulier. La question n'est plus « suis-je une cible » — elle ne l'a jamais été, en réalité. La question est : que trouve un automate qui ne me connaît pas.
L'écart entre la publication d'un correctif et son exploitation s'est resserré, lui aussi. Un correctif est un diff public : il dit exactement ce qui était cassé. Transformer cette description en preuve d'exploitation demandait un savoir-faire ; l'outillage actuel, modèles de langage compris, ramène ce travail à quelque chose de beaucoup plus banal. Je ne prétends pas mesurer ce raccourcissement — je constate que la fenêtre pendant laquelle « je mettrai à jour demain » était une position tenable s'est refermée.
Et le bruit de fond a changé de nature. Ce qui relevait autrefois du vandalisme d'opportunité relève désormais aussi d'intérêts qui nous dépassent largement, avec les moyens correspondants. Un petit site personnel n'intéresse personne en tant que tel : il intéresse comme relais, comme hébergement de page de collecte, comme machine qui a une adresse IP propre et une bonne réputation.
Rien de dramatique là-dedans. C'est simplement le contexte dans lequel se pose la question, et le contexte a déplacé la réponse.
Le générateur, en chiffres
La chaîne complète de publication de ce site, c'est 1 526 lignes de PHP — 1 373 pour le générateur, 135 pour le script qui crée un brouillon, 18 pour la fonction qui fabrique les slugs — plus 70 lignes de bash et 81 de JavaScript pour les filtres de l'index.
Dépendances externes : aucune. Pas de composer.json, pas de vendor/, pas de package.json, pas de node_modules/. Un seul require_once dans tout le projet, littéral, préfixé __DIR__. Les extensions PHP réellement sollicitées tiennent sur une main : pcre, mbstring, json, hash, et random uniquement à la création d'un brouillon.
L'entrée, ce sont les fichiers Markdown de _posts/. La sortie, plus de soixante-dix pages HTML, un flux RSS, un sitemap et un petit JSON pour l'aperçu de l'accueil. Entre les deux :
docker exec -u 1000:1000 shared-php php /var/www/fafchamps.be/_cron/build-blog.php
Le tout s'exécute en moins d'une seconde — les fichiers de sortie de la dernière génération sont horodatés à huit millisecondes d'écart. Et surtout, cette commande, c'est moi qui la lance, depuis un shell, quand j'ai fini d'écrire. Aucun cron, aucune chaîne d'intégration, aucun déclencheur distant. Le seul moment où du PHP tourne sur cette machine pour ce site, c'est un moment que j'ai choisi.
La différence est de nature, pas de degré. Un CMS exécute du code à chaque visite d'un inconnu. Ici, le code s'exécute une fois, sous mon compte, sur une entrée que j'ai écrite moi-même, et ce qui atteint le visiteur est un fichier déjà figé. Ce n'est pas « moins de code exposé ». C'est zéro code exposé.
Zéro dépendance a un revers, et il est pour moi
Le parseur Markdown est maison : deux cent dix lignes. Ce qui veut dire qu'il n'y a pas de bibliothèque tierce à mettre à jour le jour où elle a une faille — et aussi que si l'échappement est faux, c'est ma faute, pas celle d'un mainteneur que je pourrais attendre. J'ai donc écrit ce morceau-là en considérant mon propre texte comme une entrée non fiable, ce qu'il est.
Concrètement : un seul point d'échappement, htmlspecialchars avec ENT_QUOTES et le jeu de caractères explicite, appliqué une fois et une seule — les sorties suivantes ne ré-échappent rien, pour éviter le double encodage autant que le trou. Les blocs de code sont mis en sanctuaire sous des marqueurs avant tout traitement, et la source est nettoyée des octets qui permettraient de se faire passer pour un de ces marqueurs. Les liens passent par une liste blanche de schémas : tout ce qui n'est pas http, https, mailto ou un chemin relatif est réécrit en #, ce qui neutralise javascript: et data: sans avoir à les énumérer.
Les slugs sont réduits à [a-z0-9-] avant de toucher au moindre chemin de fichier — une liste blanche, pas un filtre de ../ — et onze noms sont réservés à la navigation. Un article qui viserait /blog/archives/ ne produit pas une page cassée : la génération s'arrête, code de sortie 1.
Le résultat le plus parlant tient dans un grep. Sur les trois fichiers de la chaîne, la recherche de $_GET, $_POST, $_REQUEST, $_COOKIE, $_SESSION, $_FILES, eval(, exec(, system(, shell_exec, passthru, unserialize, assert(, extract(, mysql, PDO ne renvoie rien. Pas une occurrence. La seule superglobale de tout le projet est $_SERVER['argv'] : des arguments de ligne de commande, jamais une entrée réseau. En tête de chaque script, par principe et non par nécessité :
if (PHP_SAPI !== 'cli') { http_response_code(403); exit; }
Ce que voit un scanner
C'est la partie que je trouve la plus convaincante, parce qu'elle se mesure au lieu de se raconter. Trois requêtes, depuis n'importe où :
| Demandé | Réponse |
|---|---|
/wp-login.php | 404, content-length: 153 |
/index.php | 404, content-length: 153 |
/une-url-au-hasard-12345 | 404, content-length: 153 |
Les trois réponses sont identiques au octet près. Même code, même longueur, même corps. Un automate qui déroule sa liste de chemins WordPress, Laravel et phpMyAdmin obtient la même page vide cinq mille fois : aucune page d'erreur applicative, aucun en-tête X-Powered-By, aucun cookie de session, aucun signal différentiel dont il puisse inférer quoi que ce soit. Les chemins sensibles — /.git/config, /.env, les sources, les scripts — répondent 403 sans plus de détail.
La seule chose qui fuit encore, c'est un numéro de version de serveur. Il se trouve qu'il est faux : c'est celui du proxy en frontal, pas celui du serveur qui sert réellement les fichiers derrière. Les deux ne sont pas à la même version, et le corps de la page d'erreur ne dit pas la même chose que l'en-tête. Ce n'est pas une protection, c'est une anecdote — mais elle illustre bien ce que vaut la prise d'empreinte à distance.
Au-dessus de ça, les en-têtes font le reste du travail : HSTS sur un an, nosniff, Referrer-Policy stricte, Permissions-Policy qui refuse en bloc géolocalisation, micro, caméra, paiement et USB. Et une politique de contenu dont script-src ne contient pas 'unsafe-inline' : deux empreintes SHA-256, exactement deux, correspondant aux deux seuls scripts en ligne du site. Tout le reste est refusé, injecté ou pas. X-Frame-Options n'y est plus, volontairement : frame-ancestors 'none' fait mieux, dans la politique, et il n'y a pas de raison de traîner un en-tête d'avant.
Ce que je vérifie après coup.
curl -sIsur une page d'article doit renvoyer la date de l'article, pas celle du jour : le générateur réaligne lemtimedu HTML, de son dossier et du Markdown source sur la date déclarée. Un site dont les dates affichées et les en-têtes se contredisent raconte deux histoires différentes. Je préfère qu'il n'en raconte qu'une, et qu'elle soit vérifiable.
Ce que ça ne règle pas
Il resterait malhonnête de s'arrêter sur le tableau précédent.
style-src contient toujours 'unsafe-inline', parce que tout le CSS est encore dans les pages. L'injection de script est fermée ; l'injection de style, non. Ce n'est pas anodin : on exfiltre très bien avec des sélecteurs d'attributs. C'est le prochain chantier.
Il subsiste une dépendance réseau vers un tiers, pour les polices. Une seule, explicitement autorisée et documentée comme l'exception. Elle disparaîtra le jour où je rapatrierai les fichiers.
Et le plus intéressant : la surface dynamique n'est pas débranchée, elle est vidée. Le vhost conserve un bloc qui passe les .php à PHP-FPM, hérité d'une pile mutualisée avec d'autres sites. Il n'a plus rien à exécuter — aucun .php dans la racine, le dossier des scripts refusé par une règle déclarée avant lui, et l'exigence que le fichier existe sur disque. Mais il est armé. Si un fichier PHP atterrissait un jour dans la racine web, il s'exécuterait. La vraie réduction de surface serait de supprimer ce bloc et la ligne index index.php qui ne pointe plus sur rien.
C'est le genre de détail qui dit quelque chose de général : une architecture ne se dégrade presque jamais par sa configuration de sécurité, qu'on relit. Elle se dégrade par ce qu'on dépose à côté, un soir, en se disant qu'on le rangera plus tard.
Ce que je retiens
Retirer le dynamique n'est pas un renoncement à la modernité, et ce n'est pas non plus une position de principe contre les CMS — WordPress reste le bon outil pour quelqu'un qui doit livrer et confier un site. C'est un arbitrage, avec ses deux colonnes. Ce que je perds : l'écriture depuis un navigateur, les commentaires, l'installation en trois clics. Ce que je gagne : plus rien à mettre à jour en urgence, plus de base de données à sauvegarder, plus d'interface d'administration à défendre, et des pages que je peux régénérer intégralement depuis des fichiers texte en moins d'une seconde.
Dernier détail, et c'est celui qui m'a demandé le plus de soin. Un script refusé par la politique de contenu ne produit aucun message visible : la page se charge normalement, le bouton devient simplement inerte. Le générateur recalcule donc l'empreinte des deux scripts en ligne à chaque publication, la compare à celle déclarée dans la configuration nginx, et affiche la valeur à coller si elle a bougé. C'est la toute dernière instruction qu'il exécute, ligne 1 373 — parce qu'une protection qui échoue en silence est une protection qu'on découvre trois semaines plus tard.