Sécurité

Blocage IP et filtrage par pays

Avec des serveurs exposés sur internet, un flux constant de bots malveillants, de scrapers agressifs et d'attaquants tente d'accéder aux ressources du site. QuietCMS intègre un système de contrôle d'accès directement dans le back-office : blocage d'IPs individuelles, de sous-réseaux /24 entiers et de pays entiers, avec une liste blanche pour les adresses IP administratrices.

Architecture du système

Le filtrage repose entièrement sur la classe AccessLogger, qui étend ses fonctions de journalisation avec des méthodes de contrôle d'accès. Les données sont stockées dans deux fichiers JSON dans le répertoire content/logs/ :

  • blocked.json — IPs et pays bloqués, avec horodatage et motif optionnel
  • admin_ips.json — liste blanche des adresses IP administratrices

Le format de blocked.json utilise le préfixe de sous-réseau comme clé pour les IPs :

{
  "ips": {
    "185.220.101": {
      "reason": "scan agressif",
      "ts": 1748513847
    }
  },
  "countries": {
    "RU": { "reason": "trafic non pertinent", "ts": 1748513900 },
    "CN": { "reason": "", "ts": 1748513950 }
  }
}

Blocage au niveau du routeur

Le filtrage s'applique dès l'entrée dans index.php, avant même que le routeur ne traite la requête. Cette approche garantit qu'une IP ou un pays bloqué ne consomme aucune ressource applicative :

(function () {
    $ip = $_SERVER['REMOTE_ADDR'] ?? '';
    if ($ip !== '' && AccessLogger::isBlocked($ip)) {
        http_response_code(403);
        exit('<!DOCTYPE html>...<h1>403 — Accès refusé</h1>...');
    }
    $cc = strtoupper($_SERVER['HTTP_CF_IPCOUNTRY']
        ?? $_SERVER['GEOIP_COUNTRY_CODE']
        ?? $_SERVER['HTTP_X_COUNTRY_CODE'] ?? '');
    if ($cc !== '' && $cc !== 'XX' && AccessLogger::isCountryBlocked($cc)) {
        http_response_code(403);
        exit('...');
    }
})();

Logique de blocage par sous-réseau /24

Lorsqu'une IP est bloquée, c'est son préfixe /24 (les trois premiers octets) qui est enregistré dans blocked.json, pas l'adresse complète. La méthode isBlocked() vérifie d'abord la correspondance exacte, puis le sous-réseau :

public static function isBlocked(string $ip): bool
{
    $data   = self::getBlocked();
    $subnet = self::getSubnet($ip); // ex : "185.220.101"
    return isset($data['ips'][$ip]) || isset($data['ips'][$subnet]);
}

Cette granularité est volontaire : les bots et attaquants opèrent souvent depuis des plages IP contiguës. Bloquer le /24 stoppe l'ensemble de la plage sans avoir à saisir des dizaines d'entrées individuelles.

Détection du pays

Le code pays est lu depuis les en-têtes HTTP dans l'ordre de priorité suivant :

  1. HTTP_CF_IPCOUNTRY — fourni par Cloudflare en tant que proxy
  2. GEOIP_COUNTRY_CODE — fourni par le module Apache mod_geoip
  3. HTTP_X_COUNTRY_CODE — en-tête personnalisé (Nginx, reverse proxy)

Le code XX (adresse inconnue ou privée dans la nomenclature Cloudflare) est explicitement exclu du filtrage pour éviter de bloquer les accès depuis un réseau local ou un VPN.

Liste blanche des IPs administratrices

Pour éviter qu'un administrateur ne se bloque lui-même par erreur, les IPs enregistrées dans admin_ips.json bénéficient de deux protections :

  • Exclues du journal d'accès — leurs requêtes ne sont pas enregistrées dans access.log, ce qui évite de polluer les statistiques avec du trafic administrateur
  • Jamais bloquées — même si leur sous-réseau est dans la liste de blocage
// Dans AccessLogger::log()
if ($ip !== '' && self::isAdminIp($ip)) return; // exclut totalement l'IP admin

Interface back-office

Le back-office expose les trois outils depuis l'onglet Sécurité du journal d'accès (/admin/access-log.php?tab=securite) :

  • IPs bloquées — liste des sous-réseaux bloqués avec leur motif et la date d'ajout, bouton de déblocage individuel
  • Pays bloqués — sélecteur ISO 3166-1 alpha-2 pour ajouter un pays, liste des pays actuellement bloqués
  • IPs admin — gestion de la liste blanche ; les IPs admin peuvent être des adresses complètes ou des préfixes de sous-réseau

Dans le journal (onglet Journal), chaque entrée affiche l'IP masquée (185.220.101.xxx) et un bouton Bloquer qui pré-remplit le formulaire de blocage avec le sous-réseau correspondant, en un seul clic.

Écriture atomique et cache en mémoire

Les deux fichiers JSON sont écrits de manière atomique via un fichier temporaire suivi d'un rename(), ce qui évite tout état corrompu en cas d'écriture concurrente. Les lectures sont mises en cache dans des propriétés statiques $_blockedCache et $_adminIpsCache pour éviter de relire les fichiers à chaque requête au sein d'une même exécution PHP. Le cache est invalidé lors de chaque modification.

Articles similaires