Le premier article de ce blog expliquait comment il avait été construit en 30 minutes avec Claude Code. Logiquement, un blog a besoin de commentaires. Mêmes contraintes : pas de base de données, pas de dépendances externes, pas de Disqus qui traque les visiteurs. Juste PHP + fichiers JSON. Construit en une session avec Claude Code — la partie intéressante n'était pas le code, c'était l'audit de sécurité qui a suivi.
Un système de commentaires sans BDD, ça paraît trivial. Ça l'est presque. Mais "presque" cache quelques pièges classiques — dont certains introduits directement par la vitesse d'exécution des agents IA. Le résultat final tient en ~300 lignes au total. Ce qui suit c'est le chemin, pas juste la destination.
Le cahier des charges
- Commentaires stockés en fichiers JSON (1 par article)
- Publication directe — pas de file de modération pour un blog personnel
- Anti-spam sans captcha (zéro friction pour les humains)
- RGPD : pas d'email, pas d'IP complète stockée
- Design intégré au blog existant (même système CSS)
- Zéro dépendance externe
L'architecture
Trois endroits dans le codebase :
blog/
├── comments/
│ └── {slug}.json # 1 fichier par article, créé au 1er commentaire
├── posts/
│ └── comment-handler.php # Endpoint POST unique
└── template.php # blog_footer() modifié : commentaires + formulaire
Le format de stockage JSON par article ressemble à ça :
[
{
"id": "a3f2b1c4",
"author": "Jean Dupont",
"date": "2026-02-22T14:32:10+01:00",
"content": "Très bon article, merci.",
"ip_hash": "9f86d081884c7d65"
}
]
Chaque champ a sa raison d'être. id : un sha256(uniqid()) tronqué
à 8 caractères — assez unique pour un blog personnel, pas besoin d'UUID v4 complet.
ip_hash : hash des deux premiers octets de l'IP seulement (voir section suivante).
content : stocké brut, échappé à l'affichage avec htmlspecialchars()
— jamais stocker du HTML.
L'anti-spam : honeypot + rate limiting
Pourquoi honeypot plutôt que captcha : zéro friction, aucun service externe, arrête l'essentiel des bots. Un champ caché dans le formulaire que les humains ne voient pas et ne remplissent pas. Les bots le remplissent systématiquement :
<!-- Invisible pour les humains, irrésistible pour les bots -->
<div class="hp-field">
<input type="text" name="website" tabindex="-1" autocomplete="off">
</div>
.hp-field { display: none !important; }
Détail important : le rejet retourne une redirection normale vers la page de l'article,
avec l'ancre #comments. Pas de 403, pas de message d'erreur — rien qui confirme
que le filtre existe. Le bot reçoit un 302, comme s'il avait réussi.
Le rate limiting : max 3 commentaires par préfixe IP par tranche de 10 minutes, vérifié en scannant le JSON existant. Le calcul du hash IP gère IPv4 et IPv6 :
if (str_contains($ip, ':')) {
// IPv6 : on prend les 3 premiers groupes (réseau /48)
$groups = explode(':', $ip, 4);
$ipPrefix = implode(':', array_slice($groups, 0, 3));
} else {
// IPv4 : on prend les 2 premiers octets
$octets = explode('.', $ip, 3);
$ipPrefix = ($octets[0] ?? '0') . '.' . ($octets[1] ?? '0');
}
$ipHash = substr(hash('sha256', $ipPrefix), 0, 16);
Seul le préfixe réseau est hashé — pas l'IP complète. Impossible de retrouver l'adresse individuelle, mais possible de détecter les abus depuis le même réseau. C'est le bon équilibre pour du RGPD : protection sans sur-collecte.
Le endpoint POST
comment-handler.php est l'unique endpoint qui reçoit les soumissions.
La chaîne de validation dans l'ordre :
- Méthode GET → redirect
/blog/(pas d'accès direct) - Honeypot non vide → redirect silencieuse (fausse confirmation)
- Token CSRF — comparaison timing-safe avec
hash_equals() - Slug validé par regex
[a-z0-9-]+(path traversal) - Fichier article correspondant existe
- Auteur : 2–50 caractères, contenu : 10–2000 caractères
- Rate limit : < 3 commentaires du même préfixe IP dans les 10 dernières minutes
- Écriture JSON avec
LOCK_EX - POST-Redirect-GET
Le pattern PRG (Post-Redirect-Get) en dernière étape : après l'écriture, redirect vers
/blog/{slug}?comment_ok=1#comments. L'utilisateur atterrit sur la page
avec une ancre sur la section commentaires et un message de confirmation. Et surtout :
F5 ne reposte pas le formulaire.
Ce que l'audit de sécurité a trouvé
L'implémentation initiale fonctionnait. L'audit de sécurité, lui, cherchait à la faire planter. Cinq problèmes.
CSRF absent
Le plus sérieux. Sans token CSRF, n'importe quel site peut embarquer un formulaire
caché qui poste vers /blog/comment-handler.php. Un visiteur clique sur
un lien piégé, son navigateur envoie la requête avec ses cookies de session — et un
commentaire est créé en son nom.
Correction : générer un token à la création du formulaire, le stocker en session,
vérifier avec hash_equals() à la soumission. Le détail qui compte :
// ❌ Vulnérable aux timing attacks
if ($_POST['csrf_token'] === $_SESSION['csrf_token']) { ... }
// ✅ Comparaison en temps constant
if (!hash_equals($_SESSION['csrf_token'], $_POST['csrf_token'] ?? '')) {
// Rejet
}
Open redirect
Le rejet honeypot utilisait $slug dans la redirection avant que le slug
soit validé. Un slug forgé comme //evil.com pouvait rediriger vers un
domaine externe. Correction : preg_replace('/[^a-z0-9-]/', '', $slug)
appliqué immédiatement après lecture de $_POST, avant tout usage.
Session démarrée sur toutes les pages
session_start() était en haut de template.php. Résultat :
la page listing du blog (qui n'a pas de formulaire) posait un cookie de session sur
chaque visiteur. Problème RGPD — un cookie de session est un traceur.
Correction : déplacer session_start() dans le bloc
if ($slug !== null) de blog_footer() — uniquement les pages
qui affichent un formulaire de commentaire démarrent une session.
IPv6 cassé
explode('.', $ip) sur une adresse IPv6 comme 2001:db8::1
retourne un tableau d'un seul élément. Tous les visiteurs IPv6 partageaient donc un
seul bucket de rate limiting. Le bug ne produisait pas d'erreur — juste un rate limiting
complètement inefficace sur la moitié du web. Correction : la détection str_contains($ip, ':')
montrée plus haut.
Dates en anglais
date('j M Y') sort "22 Feb 2026" sur un blog en français. On pourrait
utiliser setlocale(), mais c'est une fonction dont le comportement dépend
des locales installées sur le serveur — peu fiable en production partagée. Solution
plus robuste : un tableau de lookup statique.
$months = [
1 => 'janvier', 2 => 'février', 3 => 'mars',
4 => 'avril', 5 => 'mai', 6 => 'juin',
7 => 'juillet', 8 => 'août', 9 => 'septembre',
10 => 'octobre', 11 => 'novembre', 12 => 'décembre'
];
$date = new DateTime($comment['date']);
$formatted = $date->format('j') . ' ' . $months[(int)$date->format('n')] . ' ' . $date->format('Y');
Note éditoriale : 3 de ces 5 problèmes ont été introduits par l'implémentation initiale en parallèle (agents Sonnet travaillant simultanément sur des parties différentes du code). L'audit les a tous trouvés. La conclusion : du code assisté par IA demande la même rigueur de review que du code humain. Peut-être plus, parce qu'il arrive vite et a l'air correct.
Conclusion
Le système complet fait ~120 lignes de PHP pour le handler, ~60 lignes d'additions dans
le template, ~130 lignes de CSS. Pas de base de données, pas de npm install,
pas d'étape de build. Déployé en copiant des fichiers.
La vraie valeur n'était pas dans l'écriture du code — n'importe quel dev PHP peut écrire ça en une après-midi. C'était dans la revue de sécurité itérative : implémenter vite, puis auditer méthodiquement en cherchant à casser. Avec Claude Code, les deux tiennent dans la même session — écrire, puis passer immédiatement en mode adversarial.
Pour un blog personnel, c'est le bon niveau d'ingénierie. Pas plus.