Postmortem CrowdSec : faux positif sur Sonarr/Radarr

- Date : 25 mai 2026
- Sévérité : P3
- Statut : Résolu
- Impact : Sonarr et Radarr inaccessibles (403) pendant ~15-20 minutes — faux positif heuristique CrowdSec AppSec, aucune perte de données
En bref
Le 25 mai 2026 vers 22h02 (heure locale), Sonarr et Radarr sont devenus totalement inaccessibles depuis l’IP maison (82.XX.XX.XX), retournant 403 sur toutes les URLs y compris /login. Le service était pourtant opérationnel. Le diagnostic initial suspectait les récents déploiements du refactor crowdsec-cf-sync — la cause réelle est un faux positif heuristique CrowdSec AppSec.
Timeline
| Heure (locale) | Événement |
|---|---|
| ~22h00 | Cookie de session Sonarr expiré côté navigateur |
| 22h02:31 | Le navigateur charge la bibliothèque Sonarr → tente de charger 20+ /MediaCover/*.jpg simultanément |
| 22h02:31 | Sonarr retourne 302 → /login pour chaque image (session invalide) |
| 22h02:34 | La connexion SignalR WebSocket s’établit (101) via access_token dans l’URL |
| ~22h05 | CrowdSec AppSec déclenche la règle heuristique http-probing : rafale de requêtes échouées depuis la même IP |
| 22h11:37 | Toutes les requêtes de 82.XX.XX.XX retournent 403 — cs_reason=heuristic dans les logs nginx |
| 22h13:34 | Même /login bloqué — l’IP ne peut plus s’authentifier |
Cause racine
CrowdSec AppSec maintient un état heuristique en mémoire, distinct des décisions LAPI. Lorsque le navigateur tente de charger simultanément de nombreuses ressources et reçoit des 302/403 de l’application upstream (Sonarr), AppSec interprète cette rafale d’échecs comme du sondage agressif (http-probing) et bloque l’IP source.
Le problème : l’IP maison est dans l’allowlist LAPI (my_allowlist) et dans le bloc geo $lan nginx, mais l’heuristique AppSec ne consulte pas l’allowlist LAPI — elle opère indépendamment sur des signaux comportementaux.
cscli decisions list --ip 82.XX.XX.XX
→ No active decisions ← l'allowlist LAPI est respectée
nginx access log :
82.XX.XX.XX ... "GET /" 403 ... cs_reason=heuristic ← AppSec en mémoire bloqueCe qui n’était PAS la cause
Plusieurs pistes ont été écartées dans l’ordre :
| Piste | Résultat |
|---|---|
Refactor crowdsec-cf-sync (redéploiement du jour) | ❌ Ne touche pas nginx, AppSec, ni Sonarr |
Ban LAPI CrowdSec (cscli decisions list) | ❌ Aucune décision active pour l’IP |
bans.json Lua IPC | ❌ 3 IPs tierces uniquement, pas l’IP maison |
Bloc nginx geo $lan | ❌ IP présente dans le bloc ($lan = 1) |
| API key Sonarr invalide | ❌ /api/v3/system/status retourne 200 avec la clé |
| Problème réseau / VM Sonarr | ❌ VM répond (302 attendu pour session expirée) |
Impact
- Durée : ~15–20 minutes (détection manuelle)
- Périmètre : Sonarr et Radarr sur
vm-mediauniquement - Utilisateurs : 1 (IP maison)
- Données : aucune perte
Fix immédiat
Redémarrage de CrowdSec pour vider l’état heuristique en mémoire :
sudo systemctl restart crowdsecFix permanent
Ajout d’un bypass Lua dans /etc/openresty/snippets/crowdsec_access.conf pour exempter les IPs LAN de tout check AppSec avant même la vérification LAPI :
access_by_lua_block {
if ngx.var.lan == "1" then return end
require("crowdsec.access").check()
}La variable $lan est définie par le bloc geo dans conf.d/lan.conf et couvre les plages RFC 1918 (192.168.0.0/16, 10.0.0.0/8, 172.16.0.0/12) ainsi que l’IP maison fixe.
Les IPs externes continuent à passer par le check complet (LAPI + AppSec).
Leçons
L’allowlist LAPI ne protège pas contre l’heuristique AppSec. Les deux systèmes sont indépendants — une IP peut être dans
my_allowlistet quand même être bloquée en mémoire par AppSec.Les rafales légitimes déclenchent les règles heuristiques. Sonarr et Radarr chargent des dizaines d’images couverture au premier affichage — pattern indiscernable d’un scan d’assets.
cs_nginx_ban: falsene signifie pas “non bloqué”. Ce champ reflète uniquement le bouncer LAPI. Un bloc AppSec heuristique retourne 403 aveccs_reason=heuristicdans le log nginx — champ absent du pipeline Vector/BetterStack.Le diagnostic via BetterStack a ses limites. La source
nginx_statusn’inclut pascs_reason— la cause réelle n’était visible que dans les logs nginx bruts (/var/log/nginx/vm-media.access.log).