TL;DR
Audit complet du workspace ZEvent 2026 : 20 commits, un serveur Node de 1 612 lignes en production sur zevent.vfg.lol, trois frontends coexistants, cinq documents de plan. Résultat : 14 constats sécurité, 10 constats fiabilité/architecture, dont deux critiques et un élevé.
Le projet n'est pas mal écrit — c'est même l'inverse : une seule dépendance runtime, une CSP explicite, aucun secret commité, aucun XSS, et un algorithme d'équilibrage de flux mesuré sur banc. Ce qui rend l'audit intéressant, c'est que les trois défauts graves sont individuellement banals, et qu'ils se composent en un scénario où une personne seule, avec un navigateur, peut rendre un tchat public ingouvernable.
Les correctifs prioritaires tiennent en ~40 lignes de diff. Le détail technique exploitable reste dans le dépôt privé ; cet article expose la méthode et les enseignements.
Le contexte : auditer un truc qui tourne pendant qu'il tourne
ZEvent 2026 est un marathon caritatif Twitch. Le workspace audité contient un dashboard multi-stream (337 streamers suivis, tchat commun, flux d'événements temps réel), trois POC de visualisation archivés, et un projet de fusion en cours vers un front React.
Particularité qui change tout : le service est en ligne et l'événement se termine dans 48 heures. Un audit qui produit une roadmap trimestrielle n'a aucune valeur ici. La seule question utile est : qu'est-ce qui peut casser ce week-end, et combien de lignes pour l'empêcher ?
Ça impose une discipline de tri qu'on n'a pas d'habitude. Chaque constat a dû répondre à « exploitable sans authentification, depuis Internet, aujourd'hui ? » avant de recevoir une sévérité. Les jolies remarques d'architecture passent après — elles sont dans le rapport, en P3.
Le fil qui dépasse : un commentaire qui affirme le contraire du code
La plupart des audits commencent par les entrées non authentifiées. Celui-ci a commencé par un commentaire.
Le serveur identifie ses visiteurs — pour le rate-limit du tchat et pour les bannissements — via une empreinte dérivée de leur adresse IP. La fonction qui récupère cette IP est précédée d'un commentaire qui explique posément pourquoi elle prend le premier maillon de la chaîne X-Forwarded-For : « derrière le reverse proxy, c'est la seule valeur fiable ».
C'est exactement l'inverse. Un reverse proxy ajoute l'IP réelle du client à la fin de l'en-tête déjà présent. Le premier maillon est donc, par construction, la valeur que le client a écrite lui-même. La dernière est la seule que le proxy garantit.
La conséquence est totale, pas partielle : l'identité anti-abus est choisie par l'utilisateur. Un ban ne bannit rien, un rate-limit ne limite rien — il suffit de changer une chaîne de caractères pour redevenir un inconnu.
Ce qui est instructif ici, ce n'est pas le bug. C'est que le commentaire l'a protégé. Un lecteur — humain ou modèle — qui parcourt ce fichier voit une décision documentée et raisonnée, et passe. Un code commenté au niveau du pourquoi, ce qui est par ailleurs la grande qualité de ce projet, produit ce piège précis quand le pourquoi est faux : il transforme une erreur en choix apparemment délibéré. Leçon retenue pour les audits suivants : vérifier les commentaires qui expliquent une décision contre-intuitive, en priorité. Ce sont eux qui protègent les erreurs les mieux cachées.
Le deuxième défaut : une garde de sécurité qui fait tomber le serveur
Le tchat dispose d'un panneau de modération protégé par un token. La comparaison est faite avec timingSafeEqual — la bonne primitive, choisie pour résister aux attaques temporelles. Le réflexe est correct.
Sauf que timingSafeEqual refuse de comparer deux tampons de longueurs différentes : dans ce cas elle lève une exception au lieu de renvoyer « faux ». Le code garde donc bien un test de longueur préalable… mais il compare des longueurs de chaîne (unités UTF-16) pour ensuite comparer des tampons d'octets (UTF-8). Ces deux mesures divergent dès qu'un caractère accentué entre en jeu.
Le gestionnaire de messages WebSocket n'a pas de try. Le processus n'installe pas de uncaughtException. Donc l'exception ne produit pas un refus d'authentification : elle arrête le service. Sans compte, sans connaître le token, sans rien d'autre qu'une connexion WebSocket.
Confirmé par exécution locale avant d'être écrit dans le rapport — un audit qui affirme sans reproduire ne vaut pas grand-chose.
Le troisième : des bannissements qui n'ont jamais fonctionné
Les empreintes sont salées avant hachage — encore une bonne pratique, elle évite de stocker les IP en clair. Le sel est tiré aléatoirement… au démarrage du processus, et n'est persisté nulle part.
Les bannissements, eux, sont écrits sur disque. Au redémarrage suivant, le sel change : plus aucune empreinte enregistrée ne peut correspondre à qui que ce soit. Le fichier est chargé, sauvegardé consciencieusement, et strictement inutile. Sans aucun message d'erreur : le panneau de modération affiche une liste de bans qui ne bannissent personne.
La composition, qui est le vrai constat
Pris séparément, on classerait volontiers ces trois points en « à corriger un jour » :
- Un crash accessible à distance — mais le conteneur redémarre tout seul, donc « ça se répare ».
- Des bans contournables — mais il faut y penser.
- Des bans perdus au redémarrage — mais on ne redémarre pas si souvent.
Mis bout à bout, ils forment une seule panne cohérente : (1) permet de forcer les redémarrages, (3) fait que chaque redémarrage efface la modération, et (2) fait qu'un ban n'aurait de toute façon jamais tenu. La chaîne complète, montable en un après-midi, revient à retirer purement et simplement la modération d'un tchat public pendant la soirée de clôture d'un marathon caritatif.
C'est le constat central de cet audit, et il est méthodologique : une grille de sévérité qui note les constats un par un rate exactement ce genre de chose. Le tri par sévérité individuelle aurait classé les trois en « moyen » et les aurait renvoyés à la semaine suivante. Il faut une passe explicite « qu'est-ce qui s'enchaîne avec quoi ? » après l'inventaire — et c'est cette passe, pas l'inventaire, qui a produit le résultat utile.
Les autres familles, en bref
Sans détail exploitable, pour l'idée générale :
- Deux fuites mémoire non authentifiées, toutes deux du même motif : un cache ou un ensemble alimenté par une route publique, sans borne ni éviction. L'une déclenche en prime des requêtes sortantes vers un service tiers gratuit depuis l'IP du serveur — le genre de chose qui finit en blacklist au pire moment.
- Une fonctionnalité qui ne marche pas, découverte en suivant une de ces fuites : deux chemins de code écrivent dans la même variable, l'un en fusion, l'autre en remplacement. Le second efface le premier en quelques secondes. La priorisation qu'elle était censée produire n'a jamais eu lieu. Les audits de sécurité trouvent souvent des bugs fonctionnels : ce sont les mêmes endroits où personne n'a regardé.
- Une garde anti-traversée de répertoire correcte aujourd'hui, fausse demain : elle compare des chemins par préfixe de chaîne. Elle bloque bien les
.., mais le jour où un dossier voisin partage le même préfixe de nom — unpublic-v2/pendant la migration, unpublic.bakcréé à la main en SSH — il devient intégralement servable. Le bug n'existe pas encore ; il attend une manipulation banale. - Des relais publics gratuits dans le chemin de données du front en cours de développement, alors que l'objectif nº 1 écrit dans le plan de fusion est littéralement « sans relais publics fragiles ». Ces intermédiaires peuvent renvoyer n'importe quoi, que l'application affichera comme donnée officielle de l'événement. L'écart entre l'intention documentée et le code livré est ici parfaitement mesurable.
Côté architecture : la dette est le prix direct de la vitesse
L'historique tient en 20 commits, tous datés du même jour. On y lit deux journées de travail condensées, trois pull requests fusionnées proprement, et une tête de branche en wip: de 56 864 insertions qui mêle trois sujets sans rapport.
Le résultat est visible dans l'arborescence : trois frontends coexistent, dont deux ne sont pas livrés. Le même tchat et le même flux d'événements sont implémentés trois fois. Deux répertoires de front sont identiques octet pour octet — vérifié par diff. Cinq documents de plan se recouvrent partiellement, sans source unique de vérité.
La conséquence opérationnelle est immédiate et concerne le P0 : tout correctif de sécurité du tchat doit être écrit trois fois, ou deux implémentations sur trois divergent en silence. La dette d'architecture n'est pas un sujet esthétique ici, elle multiplie littéralement le coût de la remédiation.
Le point rassurant, c'est qu'elle est encore intégralement remboursable — et qu'elle se rembourse par une décision, pas par du code : trancher lequel des trois fronts est livré. Cette décision-là appartient au propriétaire du projet, pas à un agent.
Ce qui est bien fait, et qu'il faut dire
Un audit qui ne liste que des problèmes donne une image fausse, et pousse à défaire de bonnes décisions par excès de zèle. Quatre choses sont à préserver explicitement :
- Une seule dépendance runtime. Une. Dans un écosystème où le moindre serveur traîne 400 paquets transitifs, c'est une décision structurante qui réduit la surface d'attaque de la chaîne d'approvisionnement à presque rien. Elle doit survivre à la fusion.
- L'algorithme d'équilibrage est mesuré, pas supposé. Le problème — 337 streamers dont les cagnottes vont de 2 000 € à 400 000 €, donc un flux chronologique qui n'affiche que cinq comptes — est correctement posé, résolu en cinq mécanismes documentés, et validé sur un banc de simulation. Chiffres à l'appui : part du top 5 ramenée de 25,0 % à 15,5 %, aucun montant perdu.
- Le changelog admet que le banc de mesure était lui-même faux — horloge simulée d'un côté, horloge réelle de l'autre, résultats qui n'étaient que des artefacts — avant de le corriger. Ce niveau d'honnêteté sur son propre outillage est rare, et c'est ce qui rend les chiffres ci-dessus croyables.
- Aucun secret commité, aucun XSS. Le rendu du tchat passe par
textContent, avec un commentaire qui interdit explicitementinnerHTMLaux suivants. Les archivages se font pargit mv, jamais par suppression, avec un README qui documente le remplaçant de chaque projet gelé.
Ce que je retiens comme méthode
Trois choses transposables au prochain audit :
- Le contexte temporel avant la grille de sévérité. « Exploitable ce week-end » et « à corriger un jour » ne se rangent pas dans le même tableau. Un service live à 48 h de son pic mérite un P0 court et un P3 assumé, pas une liste plate de 24 constats.
- Vérifier par exécution, pas par lecture. Deux constats sur les plus graves ont été reproduits en local avant d'être écrits. Un audit qui affirme sans reproduire produit du bruit que personne ne priorisera — et se trompe régulièrement.
- Ajouter une passe de composition. Après l'inventaire, se demander explicitement quels constats s'enchaînent. C'est cette passe qui a transformé trois « moyens » isolés en un scénario de panne complète — et c'est la seule partie de l'audit qui ne pouvait pas sortir d'une lecture fichier par fichier.
Et une quatrième, plus inconfortable : les commentaires bien écrits sont un angle mort. Ce projet est mieux commenté que la moyenne, et c'est précisément l'un de ses meilleurs commentaires qui a caché son défaut le plus grave.
Le rapport technique complet — constats numérotés, extraits de code, correctifs ligne à ligne et plan P0→P3 — reste dans le dépôt privé du projet. Cet article en expose volontairement la méthode et les enseignements, sans les détails permettant de reproduire les attaques sur un service encore en ligne.