Ajouter des commentaires à un blog PHP sans base de données

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 :

  1. Méthode GET → redirect /blog/ (pas d'accès direct)
  2. Honeypot non vide → redirect silencieuse (fausse confirmation)
  3. Token CSRF — comparaison timing-safe avec hash_equals()
  4. Slug validé par regex [a-z0-9-]+ (path traversal)
  5. Fichier article correspondant existe
  6. Auteur : 2–50 caractères, contenu : 10–2000 caractères
  7. Rate limit : < 3 commentaires du même préfixe IP dans les 10 dernières minutes
  8. Écriture JSON avec LOCK_EX
  9. 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.

Commentaires (0)