Contenu

Post-Mortem — OpenResty 500 : module Lua CrowdSec

Ce qui s'est passé
  • Date : 16–17 août 2026
  • Sévérité : P1
  • Statut : Résolu
  • Impact : HTTP 500 sur l’origine, y compris sur du trafic légitime et le endpoint de monitoring /ping

En bref

Deux rafales de scan Internet ont révélé un défaut latent dans la chaîne OpenResty/CrowdSec. Les scanners n’ont pas « fait tomber » OpenResty par leur volume : la cadence restait faible pour nginx. Ils ont surtout déclenché un chemin d’exécution qui appelait un module Lua custom impossible à retrouver sur des workers OpenResty fraîchement chargés.

La cause racine était ce chargement dans access_by_lua :

require("crowdsec.access")

alors que le lua_package_path actif ne contenait pas le répertoire où vivait le module custom crowdsec/access.lua.

Le résultat : un worker qui devait charger ce module à froid renvoyait HTTP 500 avant même d’arriver à l’authentification ou au contenu Hugo.

Ce qui a déclenché l’incident

Deux sources ont été observées pendant l’enquête :

  • 34.75.198.72 — première vague vers 08:27 UTC, puis nouvelle série de probes vers 08:35 ;
  • 34.169.50.247 — nouvelle rafale vers 09:14 UTC, avec environ 95 requêtes en 12 secondes dont 94 en HTTP 500.

Les scans visaient notamment des chemins de configuration, de secrets et d’outils IA/MCP :

/.env
/.claude.json
/.claude/settings.json
/.mcp.json
/.codex/config.toml
/.cursor/mcp.json
/config/secrets.yml
/config/master.key
/application.yml
/appsettings.json
/google-credentials.json
/service-account.json
/debug/pprof/
/metrics
/@fs/proc/self/environ?import&raw??

Une partie du trafic utilisait le User-Agent :

Mozilla/5.0 (compatible; GrokBot/1.0; +https://x.ai/grokbot)

Ce User-Agent est trivialement falsifiable : cela ne constitue pas une attribution à xAI.

Timeline condensée

Heure UTC Événement
~08:27 Première rafale depuis 34.75.198.72 : configs IA/MCP, secrets et fichiers sensibles
~08:28 Des requêtes malveillantes et légitimes commencent à recevoir HTTP 500
~08:35 Deuxième vague de probes PHP/webshell depuis la même source
09:08 CrowdSec bannit finalement 34.75.198.72 via crowdsecurity/http-crawl-non_statics
09:14:30–09:14:42 34.169.50.247 envoie ~95 requêtes ; 94 retournent 500
09:16 Les sondes BetterStack sur /ping reçoivent elles aussi 500 depuis plusieurs régions
17 août, investigation Les logs OpenResty révèlent module 'crowdsec.access' not found
13:20 locale Premier correctif du lua_package_path, reload et disparition des 500
13:23 locale Correctif durable remplacé par un symlink hors du fichier réécrit par le paquet
11:31 UTC BetterStack confirme /ping de nouveau UP

Signature exacte de la panne

Dans /var/log/nginx/www.arleo.eu.error.log :

lua entry thread aborted: runtime error:
access_by_lua(snippets/crowdsec_access.conf:26):3:
module 'crowdsec.access' not found:
    no field package.preload['crowdsec.access']
    no file '/usr/local/openresty/nginx//../lualib/plugins/crowdsec/crowdsec/access.lua'
    ...

Le bloc concerné était simple :

access_by_lua_block {
    if ngx.var.lan == "1" then return end
    require("crowdsec.access")
}

Le require() n’était pas encapsulé et son échec faisait donc échouer la requête entière.

Le lua_package_path chargé par le bouncer officiel était :

lua_package_path '$prefix/../lualib/plugins/crowdsec/?.lua;;';

alors que les modules custom vivaient sous :

/usr/local/openresty/nginx/conf/lua/crowdsec/

access.lua, mitigation.lua, sync.lua, heuristics.lua, tarpit.lua et waf_refs.lua existaient bien : c’est leur chemin de recherche qui manquait.

Pourquoi le bug semblait intermittent

require() met en cache un module uniquement lorsqu’il a été chargé avec succès. Avec lua_code_cache on, un worker qui avait déjà chargé crowdsec.access pouvait continuer à fonctionner normalement.

En revanche, tout worker neuf ou recyclé qui devait refaire le require() utilisait le mauvais lua_package_path et tombait en 500.

Cela explique pourquoi :

  • des pages pouvaient sembler normales derrière le cache Cloudflare ;
  • /ping, en no-store, continuait à atteindre l’origine et exposait le bug ;
  • une rafale de scan pouvait faire apparaître brutalement plusieurs workers défectueux ;
  • le problème ressemblait à une surcharge alors que le volume lui-même n’était pas anormal pour OpenResty.

Faux diagnostic initial

Au début, plusieurs pistes semblaient crédibles :

  • contenu Hugo récemment modifié ;
  • vhost apex arleo.eu cassé alors que www.arleo.eu fonctionnait ;
  • surcharge OpenResty/CrowdSec sous scan ;
  • problème d’authentification du endpoint /ping.

Le MCP Hugo a permis d’éliminer immédiatement le contenu : build sain, publication cohérente, pages publiques présentes.

Le point décisif a été de lire le log OpenResty local complet, pas seulement les métriques HTTP agrégées : le même traceback Lua apparaissait pour chaque 500.

Correctif appliqué

Premier correctif : ajouter le chemin manquant

Le correctif immédiat a été :

lua_package_path '$prefix/../lualib/plugins/crowdsec/?.lua;$prefix/lua/?.lua;;';

Puis :

sudo openresty -t
sudo openresty -s reload

Après reload : nouveaux workers, plus de module 'crowdsec.access' not found, plus de 500.

Correctif durable : ne pas modifier le fichier réécrit par le paquet

Le fichier crowdsec_openresty.conf est réécrit par les mises à jour du bouncer CrowdSec et n’est pas protégé comme un conffile dpkg. Modifier directement son lua_package_path ferait donc revenir le bug à une future mise à jour.

Le fichier a été remis dans son état stock et un lien symbolique a été créé :

/usr/local/openresty/lualib/plugins/crowdsec/crowdsec
    -> /usr/local/openresty/nginx/conf/lua/crowdsec

Ainsi le chemin officiel :

.../plugins/crowdsec/?.lua

résout naturellement :

require("crowdsec.access")
→ .../plugins/crowdsec/crowdsec/access.lua

sans modifier le fichier géré par le paquet.

Validation après correction

Les contrôles effectués après reload :

  • openresty -t : OK ;
  • nouveaux workers OpenResty démarrés proprement ;
  • 15 requêtes séquentielles sur /ping : aucune 500 ;
  • 30 requêtes concurrentes sur /ping : aucune 500 ;
  • aucune nouvelle occurrence de module 'crowdsec.access' not found ;
  • aucune 500 dans l’access log après le correctif ;
  • / et les pages Hugo : 200 ;
  • apex arleo.eu : redirection 301 vers www ;
  • BetterStack : monitor /ping repassé UP après le correctif.

Le 401 observé sans credentials sur /ping est normal : ce endpoint est protégé par Basic Auth. BetterStack possédait déjà la bonne configuration d’authentification ; auparavant le crash access_by_lua se produisait avant auth_basic.

Si cela se reproduit

Ne pas commencer par rollbacker Hugo.

1. Vérifier immédiatement la signature Lua

sudo grep -n "crowdsec.access\|lua entry thread aborted" \
  /var/log/nginx/www.arleo.eu.error.log | tail -50

Si on voit :

module 'crowdsec.access' not found

la cause est probablement la même.

2. Vérifier que le module est résolvable

ls -la /usr/local/openresty/lualib/plugins/crowdsec/crowdsec
ls -la /usr/local/openresty/nginx/conf/lua/crowdsec/access.lua

Le premier chemin doit être un symlink vers le dossier custom.

3. Vérifier la configuration réellement chargée

sudo openresty -T 2>&1 | grep -n "lua_package_path\|crowdsec_access"

4. Tester avant reload

sudo openresty -t

Attention : openresty -t vérifie la syntaxe mais n’exécute pas le require() de la phase request. Un test HTTP réel est donc obligatoire.

5. Tester le chemin d’exécution réel

curl -sk -o /dev/null -w '%{http_code}\n' https://www.arleo.eu/ping

Sans auth, un 401 propre est préférable à un 500. Avec les credentials du monitor, le résultat attendu est 200.

6. Vérifier l’absence de nouvelles 500

sudo tail -f /var/log/nginx/www.arleo.eu.error.log

puis lancer plusieurs requêtes concurrentes.

Conclusion

Les scanners 34.75.198.72 et 34.169.50.247 ont été les déclencheurs observés, mais pas la cause racine. Une centaine de requêtes en quelques secondes ne devrait pas mettre OpenResty à terre.

La véritable défaillance était un module Lua custom CrowdSec présent sur disque mais absent du chemin de recherche des workers OpenResty. La sécurité était elle-même devenue un point de panne global : une erreur de require() dans access_by_lua transformait n’importe quelle requête en HTTP 500 avant le reste de la chaîne nginx.

La leçon principale est simple : quand une panne semble corrélée à une attaque, vérifier que l’attaque ne fait pas seulement apparaître un défaut latent de notre propre couche de défense.


/images/logos/crowdsec-openresty-appsec.webp

Liens internes utiles