Le certificat a expiré en silence, et les scans n'ont jamais cessé
Un cron de renouvellement a tourné chaque nuit pendant des mois sans jamais rien renouveler, à cause d'une seule lettre. Pendant ce temps, le serveur encaissait des milliers de scans et de tentatives de brute-force par jour. Voici ce que CrowdSec a réellement bloqué, les règles nginx qui ont tenu, les ports que je n'aurais jamais dû exposer, et pourquoi Cloudflare est l'étage suivant.
Le navigateur disait que la connexion n’était pas privée. Le certificat avait expiré à 2 h du matin. Le cron de renouvellement avait tourné à 3 h, comme chaque nuit depuis des mois, et avait rapporté un succès, comme chaque nuit depuis des mois. Il n’avait jamais rien renouvelé.
C’était l’incident bruyant. L’incident silencieux était permanent : les logs d’auth et les logs nginx du serveur montraient des scans, du credential stuffing et du sondage de chemins toute la journée, tous les jours, depuis l’instant où le domaine avait eu un enregistrement DNS. Rien là-dedans n’est inhabituel. Ce qui compte, c’est ce qui se trouvait entre ça et la base de données.
Ceci est la partie 8 d’une série sur le durcissement d’un SaaS construit en solo en production. Le contexte et la liste complète des points faibles sont dans l’article pilier. Celui-ci parle du périmètre : ce qui protégeait réellement la machine, ce qui ne la protégeait pas, et le certificat qui a expiré pendant qu’un script disait que tout allait bien.
Le cron qui a menti pendant des mois
Voici le script de renouvellement, élagué et anonymisé, tel qu’il était sur le serveur :
#!/bin/bash
# Remove set -e, handle errors manually so nginx always restarts
echo "Stopping Nginx container..."
sudo docker stop nginx
echo "Renewing certificates with Certbot..."
sudo certbot renew --standalone --non-interactive --cert-name api.exampel.com
sudo certbot renew --standalone --non-interactive --cert-name app.exampel.com
sudo certbot renew --standalone --non-interactive --cert-name storage.exampel.com
echo "Regenerating default self-signed certificate..."
bash ./generate-default-ssl.sh
echo "Starting Nginx container..."
sudo docker start nginx
echo "SSL renewal process completed."
Comptez les défaillances. Il y en a quatre, et chacune seule aurait suffi.
Une lettre fausse dans le domaine. Regardez les arguments --cert-name. Les certificats sur le disque avaient été émis pour example.com. Le script demandait à certbot de renouveler exampel.com. Certbot, à raison, ne trouvait aucun certificat de ce nom et le disait. Rien n’a été renouvelé, jamais, depuis le jour où le script a été écrit.
set -e avait été retiré volontairement. Le commentaire explique pourquoi : pour que, si le renouvellement échouait, le script redémarre quand même nginx. Intention raisonnable. La conséquence, c’est que le code de sortie « aucun certificat trouvé » de certbot était avalé, le script continuait, et la dernière ligne imprimait SSL renewal process completed. dans le log chaque nuit. Un log qui dit « terminé » n’est pas un log qui dit « réussi ».
Un tiret là où le système de fichiers avait un underscore. Le script appelle generate-default-ssl.sh. Le fichier dans le dépôt s’appelle generate_default_ssl.sh. Cette étape échouait aussi, en silence, pour la même raison que la précédente.
L’entrée cron elle-même. Le README la documentait comme 0 3 * * * sudo PATH/app/nginx/ssl_renewal.sh, avec PATH comme placeholder littéral à remplir. Savoir si le placeholder a jamais été remplacé est une question à laquelle le certificat expiré a répondu.
Quatre bugs indépendants, et le système rapportait un succès. Le seul signal externe a été un avertissement de navigateur, le jour de l’expiration. La partie 6 a maintenant un moniteur qui prévient quatorze jours avant l’expiration, et ce moniteur existe à cause de ce matin-là.
Il y a un cinquième problème qui n’est pas un bug : le script arrête nginx pour renouveler. certbot --standalone a besoin du port 80, nginx a le port 80, donc le site tombait pendant la durée de chaque tentative de renouvellement. Chaque nuit à 3 h, quelques secondes de panne planifiée, pour lancer un script qui ne faisait rien.
Ce qui tenait vraiment la ligne
Maintenant les bonnes nouvelles, parce qu’il y en avait. Le serveur avait deux vraies couches de défense qui ont fait leur travail tout du long.
CrowdSec sur l’hôte
CrowdSec lit les logs nginx et SSH, les compare à des scénarios d’attaque maintenus par la communauté, et bannit l’IP source au pare-feu, avec un bouncer nginx côté HTTP. Tirer la liste des décisions après un mois donnait une image claire de ce qu’attire un petit SaaS sans profil public :
$ sudo cscli decisions list --all | wc -l
# environ 2 300 bannissements actifs un jour ordinaire
$ sudo cscli metrics
# scénarios les plus déclenchés, dans l'ordre :
# crowdsecurity/http-probing scanners de chemins : /.env, /wp-login.php, /.git/config
# crowdsecurity/http-bad-user-agent signatures de scanners connus
# crowdsecurity/ssh-bf brute-force SSH
# crowdsecurity/http-crawl-non_statics
La blocklist communautaire est la partie qui vaut son prix : la plupart de ces bannissements étaient appliqués avant que l’IP ait envoyé une seule requête à ce serveur, parce qu’elle avait déjà été prise à attaquer quelqu’un d’autre. Pour zéro coût et l’installation d’un paquet, c’est un échange extraordinaire.
Les règles nginx
Devant l’application, nginx avait un ensemble de règles ajoutées un incident à la fois. Élagué et anonymisé :
# rate limits : un seau pour les pages, un plus serré pour l'API
limit_req_zone $binary_remote_addr zone=one:10m rate=10r/s;
limit_req_zone $binary_remote_addr zone=api:10m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=perip:10m;
# les user agents de scanners n'obtiennent aucune réponse
map $http_user_agent $badagent {
default 0;
~*(masscan|zgrab|nikto|sqlmap|nmap) 1;
~*python-requests 1;
"~*^$" 1; # UA vide
}
server {
listen 443 ssl;
server_name api.example.com;
ssl_protocols TLSv1.2 TLSv1.3;
if ($badagent) { return 444; }
# rien qui commence par un point n'est jamais une requête légitime
location ~ /\. { deny all; return 444; }
location / {
limit_req zone=api burst=20 nodelay;
limit_conn perip 30;
proxy_pass http://api:8000;
}
}
# tout nom d'hôte non configuré est coupé, pas servi
server {
listen 443 ssl default_server;
return 444;
}
return 444 est le « fermer la connexion sans répondre » de nginx, et c’est la bonne réponse à un scanner : pas de code de statut, pas d’en-têtes, pas de bannière serveur, rien à empreinter. Le bloc default_server compte plus qu’il n’y paraît. Sans lui, une requête vers l’IP nue ou un nom d’hôte au hasard se voit servir le premier site configuré, ce qui dit à un attaquant exactement ce qu’il y a derrière l’adresse.
Aucune des deux couches n’est exotique. Toutes deux faisaient discrètement leur travail pendant que je m’inquiétais d’autre chose, ce qui est le plus beau compliment qu’une infrastructure puisse recevoir.
Ce qui n’a pas tenu : les ports
Puis la partie qui défait une partie de ce qui précède. Le fichier compose, à l’époque :
# docker-compose.yml, élagué et anonymisé
storage:
command: ['server', '/data', '--console-address', ':9001']
# TODO change to expose in production (only for 9000) /!\
ports:
- '9000:9000'
- '9001:9001'
api:
ports:
- '8000:8000'
db:
ports:
- '127.0.0.1:5432:5432'
La ligne de la base de données est correcte, et elle l’est grâce à la partie 2, où j’ai appris que les ports publiés par Docker contournent entièrement le pare-feu de l’hôte. Lier à 127.0.0.1 est la seule chose qui garde de façon fiable un port publié hors d’Internet.
Les deux autres lignes sont fausses, et le TODO prouve que je le savais. La console d’administration MinIO sur 9001 et l’API sur 8000 étaient toutes deux publiées sur toutes les interfaces, ce qui veut dire que les deux étaient joignables directement depuis Internet, en passant devant nginx, devant les rate limits, devant le filtre de bad agents, devant le bouncer nginx de CrowdSec. La console d’administration est une page de connexion au stockage de tous les fichiers du produit. Elle était à un mot de passe deviné du monde entier, et les règles nginx dont j’étais fier n’étaient pas sur le chemin.
La correction est celle que la partie 2 avait déjà énoncée et que je n’avais pas fini d’appliquer : les services internes n’ont besoin d’aucun port publié, parce que nginx les atteint par nom de conteneur sur le réseau Docker.
storage:
command: ['server', '/data', '--console-address', ':9001']
# pas de ports : nginx proxifie storage:9000, la console n'est exposée nulle part
api:
# pas de ports : nginx proxifie api:8000
Deux suppressions. Après elles, nmap depuis l’extérieur montre 22, 80 et 443, et rien d’autre. Le TODO était dans le fichier depuis onze mois.
Pendant que j’y étais : le README contenait l’IP publique du serveur et le nom du fichier de clé SSH. Aucun des deux n’est un secret au sens cryptographique, et les deux sont exactement ce qu’on ne veut pas dans un dépôt qui pourrait un jour avoir un second contributeur. Les deux ont disparu, remplacés par des placeholders.
Cloudflare : l’étage qui supprime le cron
Le problème de certificat a une petite correction et une correction structurelle. La petite, c’est remplacer --standalone par --webroot pour que le renouvellement n’arrête jamais nginx, corriger les domaines, restaurer set -e, et mettre le moniteur d’expiration de la partie 6 devant. C’est un bon après-midi de travail et je l’ai fait d’abord, parce que le certificat était expiré maintenant.
La correction structurelle, c’est d’arrêter de terminer le TLS public sur la machine tout court. Cloudflare en mode proxy se place devant le serveur, détient le certificat public, et le renouvelle dans le cadre normal d’un CDN, c’est-à-dire jamais comme une chose à laquelle je pense. Entre Cloudflare et nginx, un certificat d’origine émis par Cloudflare, valable quinze ans, fait le chiffrement. Le cron de renouvellement est supprimé, pas corrigé.
Ce que ça ajoute en plus de supprimer le cron :
- L’IP du serveur disparaît. Le DNS pointe vers Cloudflare. Les scanners qui frappent le domaine frappent la bordure de Cloudflare. Les attaques directes sur l’IP cessent d’atteindre la machine, à condition que le pare-feu n’accepte le 443 que depuis les plages publiées de Cloudflare, ce qui est un court script avec
ufwet leur liste d’IP. - Le WAF et les règles anti-bots tournent avant que nginx voie la requête. La map
badagentest toujours là, et elle n’attrape presque plus rien, parce que la bordure l’a attrapé d’abord. - Du rate limiting en bordure, pour qu’une rafale ne consomme pas la bande passante du VPS à décider de la rejeter.
Rien de tout cela ne remplace CrowdSec, qui surveille toujours SSH et applique toujours la blocklist communautaire. Ça remplace le cron de certificat, et ça met la machine derrière une adresse que personne ne peut scanner.
Je suis conscient de l’arbitrage. Cloudflare termine le TLS, ce qui veut dire qu’il peut lire le trafic, et un produit qui manipule des données personnelles doit en être à l’aise dans son accord de traitement des données. Celui-ci l’est, et les autres propriétés de l’agence sont déjà derrière. C’est une décision que chaque produit prend une fois, délibérément, et l’alternative de faire tourner ma propre bordure n’est pas une vraie option pour une personne seule.
Ce qui n’est toujours pas corrigé
La migration Cloudflare est faite pour les noms d’hôte publics. SSH est toujours exposé sur le 22 au monde entier, protégé par une auth par clé uniquement et par CrowdSec, ce qui est adéquat et pas idéal ; le passer derrière un VPN ou un Cloudflare Tunnel est la prochaine étape.
La console d’administration MinIO est maintenant inaccessible de l’extérieur, ce qui veut aussi dire inaccessible pour moi. J’utilise mc à travers un tunnel SSH quand j’en ai besoin, ce qui est rare. C’est une fonctionnalité.
Et le moniteur de certificat à quatorze jours surveille maintenant un certificat de bordure Cloudflare que Cloudflare renouvelle automatiquement. Il ne se déclenchera presque certainement jamais. Je le garde quand même, parce que « presque certainement » est exactement ce que j’aurais dit du cron.
La leçon
Le danger du cron n’était pas qu’il soit cassé. Les choses cassées se réparent. Il rapportait un succès, chaque nuit, avec quatre raisons indépendantes de ne pas pouvoir réussir, et j’ai fait confiance au mot « terminé » pendant des mois parce que rien d’autre ne regardait.
Tout ce qui a tenu a tenu parce que ça ne dépendait pas de ma vérification : CrowdSec qui bannit des IP qu’il n’a jamais rencontrées, nginx qui coupe des connexions sans répondre. Tout ce qui a échoué a échoué parce que ça en dépendait. Les ports sont restés ouverts parce qu’un TODO est une promesse à un futur soi qui n’est jamais venu. Le certificat a expiré parce qu’un log disait que non.
La sécurité d’un produit géré en solo, c’est surtout la discipline de ne pas être dans la boucle.