Nous avons attaqué notre Boîte à outils : 30 défauts en 24 h
La Boîte à outils est sortie avec vingt-cinq utilitaires et une jolie série de voyants verts. Vingt-quatre heures plus tard, un auto-audit structuré y avait confirmé trente défauts — dont un qui rendait environ 93 % du hub incliquable. Voici la liste complète, et pourquoi la publier est tout l'intérêt.
Sur cette page
- Huit sceptiques et une règle
- Pièce numéro un : le bloc conteneur qui a mangé la page
- Pièce numéro deux : le bleu qui revenait faux
- Pièces trois à sept : les entrées hostiles, et partir en cours de route
- Les défauts discrets : ARIA, presse-papiers, cinq langues
- Quand l'interface ment
- Le correctif qui avait besoin d'un correctif
Un seul commit, 161 fichiers, 39 246 lignes : la Boîte à outils est sortie d'un bloc, vingt-cinq utilitaires qui font tout leur travail dans votre navigateur. TypeScript compilait. ESLint se taisait. Le build de production passait, treize tests de bout en bout et neuf tests de fumée étaient au vert, et la chose avait l'air, sur mon écran, terminée.
Le lendemain, j'ai lâché huit sceptiques dessus en leur demandant de la casser. Ils ont confirmé trente défauts. Le pire trônait en façade de la fonctionnalité depuis sa mise en ligne : trois déclarations CSS égarées avaient réduit la zone cliquable de chaque carte du hub à une bande de 331 × 24 pixels — environ 7 % de la carte. À peu près 93 % de la surface qu'un visiteur viserait réellement ne faisait rien.
- 8
- passes de relecture adverse
- 30
- défauts confirmés
- 37
- fichiers corrigés, +1 853 / −309
- 116
- vecteurs XSS dans la suite
L'auto-audit de /tools du 26 juillet, un jour après le lancement. Chacun de ces chiffres provient du commit de correction.
Huit sceptiques et une règle
Une relecture adverse n'est pas un second avis. Un second avis lit votre code comme vous l'avez écrit — du début à la fin, avec bienveillance, pour vérifier que vous avez fait ce que vous vouliez faire. Une attaque part du principe que vous avez fait exactement ce que vous vouliez faire et que c'est quand même faux : que la promesse affichée n'est pas tenue, que l'entrée que personne ne taperait a été tapée, que le composant s'est démonté en pleine tâche, que la chaîne est parfaite en anglais et incompréhensible en allemand.
La surface /tools livrée a donc reçu huit passes distinctes, chacune traquant une classe de défaillance différente plutôt que de ratisser les mêmes fichiers. Mais ce qui a rendu l'exercice utile, ce n'était pas l'attaque.
Aucune trouvaille ne comptait tant qu'une seconde exécution indépendante ne l'avait pas reproduite. Le soupçon est gratuit ; la reproduction est la monnaie.
Cette règle a fait deux choses. Elle a tué les trouvailles au son plausible qui s'évaporent sous un chronomètre ou un débogueur — celles qui m'auraient coûté une journée de réécriture défensive pour rien. Et elle a transformé les survivantes en quelque chose sur quoi agir sans discuter : l'effondrement de la zone cliquable n'était pas une théorie sur les blocs conteneurs, c'était un rectangle mesuré dans une fenêtre Chromium en fonctionnement. Trente trouvailles ont passé cette barre. Le commit de correction a touché 37 fichiers, +1 853 lignes et −309.
Pièce numéro un : le bloc conteneur qui a mangé la page
Chaque carte du hub de la Boîte à outils utilise un lien étiré : une ancre autour du nom de l'outil, plus un pseudo-élément ::after épinglé aux quatre bords pour que toute la carte soit la cible. C'est un motif standard, et il repose sur une seule chose — que le pseudo-élément calcule sa position par rapport à la carte, et non par rapport à quelque chose de plus petit intercalé entre les deux.
Les dioramas du hub sont en pure 3D CSS, si bien que transform-style: preserve-3d est une déclaration légitime et fréquente dans cette feuille de style. Trois d'entre elles avaient atterri sur les mauvais éléments, et preserve-3d établit un bloc conteneur pour les descendants positionnés en absolu. La plus interne portait sur le <h3> du nom de l'outil : la surcouche de la taille d'une carte est donc discrètement devenue une surcouche de la taille d'un titre, 331 pixels de large sur 24 de haut.
Rien n'avait l'air faux. C'est la partie sur laquelle il vaut la peine de s'arrêter. La carte s'affichait au pixel près, l'état de survol se déclenchait, le curseur devenait bien une main au-dessus du titre, et tous les tests automatisés passaient — parce qu'un test clique un lien par son nom accessible, et que le lien, lui, se portait à merveille. La défaillance n'existait que pour quelqu'un visant le milieu d'une carte à la souris, c'est-à-dire : pour tous les visiteurs réels, et pour aucun de mes instruments.
Tout avait l'air juste. Regarder est le plus faible instrument que je possède, et c'est celui que j'attrape en premier.
Le correctif a consisté à supprimer trois déclarations. Ce qui est arrivé avec elles, c'est un commentaire d'invariant de seize lignes expliquant précisément pourquoi rien dans ce sous-arbre ne doit créer de bloc conteneur — parce que la prochaine personne à y ajouter une fioriture 3D, ce sera moi, dans six mois, sans aucun souvenir de tout ceci.
Pièce numéro deux : le bleu qui revenait faux
Le convertisseur de couleurs prend du HEX, du RGB, du HSL et de l'OKLCH et vous montre les quatre. Donnez-lui du bleu pur, #0000ff, et il renvoyait #0031e5 — un écart de 48 sur 255 dans un seul canal, ce qui n'est pas un artefact d'arrondi mais une autre couleur. L'OKLCH décrit un espace perceptuel plus grand que le sRGB : une couleur convertie tombe donc régulièrement hors de la boîte que l'écran sait afficher, et il faut bien l'y ramener. Le nôtre la ramenait mal.
La réparation évidente consiste à écrêter chaque canal dans la plage, ou à choisir la couleur affichable la plus proche et passer à autre chose. Nous n'avons fait ni l'un ni l'autre, parce qu'un balayage a montré que le raccourci était pire qu'il n'en avait l'air : un choix du plus proche sans garde-fou aurait transformé 76 % des résultats hors gamut en écrêtage de canal visible. Le convertisseur implémente désormais le mappage de gamut du §13 de CSS Color 4 — réduction de la chroma par recherche dichotomique, mais arrêt anticipé dès que le résultat écrêté est à moins d'une différence juste perceptible du candidat (clipDelta ≤ 0.02), c'est-à-dire là où l'honnêteté perceptuelle et les pixels affichables tombent d'accord.
Un balayage de 140 000 couleurs avant et après vous dit si cela valait la peine d'être écrit : la pire erreur d'aller-retour est passée de 47,99/255 à 0,77/255. Sous une unité dans un canal d'un octet, c'est une couleur dont personne ne peut voir le mouvement.
Pièces trois à sept : les entrées hostiles, et partir en cours de route
L'aperçu markdown est l'outil le plus exposé à des entrées qu'il n'a pas demandées, et il a pris le plus gros de cette session. Un tableau de 19 Ko collé dedans produisait une chaîne HTML de 275 Mo — construction quadratique rencontrant un document qui n'est que délimiteurs. Un [ non apparié pathologique déclenchait un rebalayage en O(n²) : quatorze secondes d'onglet figé, aujourd'hui dix millisecondes. Les citations imbriquées récursaient sans plancher, si bien qu'un document assez profond pouvait épuiser la pile purement et simplement.
Aucun de ces problèmes n'est une faille de sécurité en soi — c'est un moteur de rendu sans accès réseau — mais ils ont la même forme, et la même passe a lancé une suite de 116 vecteurs XSS contre l'assainisseur. Dix-huit vecteurs passaient avant le correctif. Zéro après. L'outil tourne désormais sur des budgets explicites : un budget de délimiteurs, un budget de balayage de liens débité seulement en cas d'échec, 20 000 cellules de tableau, 32 niveaux de profondeur de bloc.
| Défaut | Avant | Après |
|---|---|---|
| Zone cliquable des cartes du hub | 331 × 24 px (~7 % de la carte) | toute la carte |
| Aller-retour OKLCH, pire cas sur 140 000 couleurs | 47,99 / 255 | 0,77 / 255 |
| Tableau de 19 Ko collé dans l'aperçu markdown | chaîne HTML de 275 Mo | plafonné à 20 000 cellules |
| Rebalayage sur crochet non apparié | 14 s | 10 ms |
| Suite XSS, 116 vecteurs | 18 échecs | 0 |
| Jauge de mot de passe à 7 caractères (44,1 bits) | « Moyen », au-dessus d'un temps de cassage de 15 minutes | Faible, d'après un plafond dérivé de 46,03 bits |
Six des trente, chiffres à l'appui. Chacun a été reproduit indépendamment avant d'être accepté comme défaut.
L'autre moitié des entrées hostiles, c'est le timing hostile — l'utilisateur qui s'en va. Trois outils (gzip, ZIP, regex) laissaient fuir des Web Workers au démontage et continuaient allègrement à mouliner pour une page que plus personne ne regardait. La cause est un idiome React que j'ai déjà écrit de travers et que je réécrirai probablement de travers : l'effet de nettoyage capturait les objets ref au lieu de lire .current au moment du démontage, si bien qu'il démontait une poignée périmée et laissait tourner le worker vivant.
Dans la même famille : le constructeur de ZIP appliquait son plafond DEFLATE de 4 Gio après avoir tout compressé, si bien qu'un lot trop gros faisait tout le travail coûteux avant d'abandonner — la vérification doit venir avant le travail, ce qui est évident après coup et était invisible en relecture, parce que le garde-fou existait et était même correct, simplement tardif.
Les défauts discrets : ARIA, presse-papiers, cinq langues
Quatre outils avaient un contrôle « aller à l'erreur » écrit <button role="alert">. Ce seul attribut fait deux mauvaises choses à la fois : il écrase le rôle de bouton, si bien que les technologies d'assistance n'annoncent plus l'élément comme actionnable, et comme le nom de l'élément venait du contexte plutôt que d'un texte, le contrôle ne s'annonçait pas du tout. Un bouton sans étiquette et sans rôle de bouton est pire que pas de contrôle du tout. Le rôle est passé sur le conteneur, là où l'annonce a sa place, et le bouton est redevenu un bouton.
La copie vers le presse-papiers échouait en silence dès que le navigateur refusait. Elle teinte désormais le contrôle et annonce via une région live — maintenue montée et vide dès le départ, parce qu'une région live insérée au moment même où elle reçoit du contenu est annoncée de façon inconstante d'un lecteur d'écran à l'autre. Elle se vide à la tentative suivante plutôt que sur une minuterie, pour qu'un lecteur lent ne perde pas le message en pleine phrase.
Puis la localisation, là où un auto-audit gagne son salaire, puisque je ne peux pas relire cinq de nos six langues. Le bandeau des outils de la page d'accueil affichait les noms d'outils en anglais dans toutes les langues — un repli que personne n'avait remarqué parce que tous ceux qui l'ont regardé lisaient l'anglais. L'analyseur de cron, qui explique un planning en mots simples, était agrammatical en allemand, en espagnol et en portugais. Le français demandait un vrai changement de code et a été délibérément renvoyé à son propre commit plutôt que glissé dans un tas de correctifs : les plages passent maintenant par des fonctions de clause dédiées pour les jours de la semaine et les mois, si bien que l'allemand lit von Montag bis Freitag au lieu du franchement faux am Montag bis Freitag. La sortie anglaise et russe est ensuite revenue identique à l'octet près, ce qui m'a confirmé que le remaniement n'avait touché que ce qu'il prétendait toucher.
Quand l'interface ment
Trois défauts vont ensemble, parce que dans chacun le produit affirmait à l'utilisateur quelque chose de faux avec une confiance totale.
Le générateur de mots de passe notait un mot de passe de sept caractères — 44,1 bits d'entropie — comme « Moyen », juste au-dessus de sa propre estimation annonçant qu'il tomberait en quinze minutes. Les deux lignes étaient calculées correctement. Elles venaient simplement d'endroits différents : le temps de cassage d'une vraie constante, l'étiquette du palier d'un seuil tapé à la main. Le seuil a donc cessé d'être tapé.
// The Weak ceiling is derived from the same constant the crack-time estimate
// uses, so the two lines can no longer contradict each other: anything a rig
// breaks inside an hour is Weak, by definition.
const WEAK_MAX_BITS = 1 + Math.log2(3600 * GUESSES_PER_SECOND); // 46.0344,1 bits passent désormais sous le plafond : le badge affiche donc Faible — et il se déplacera tout seul si l'hypothèse matérielle change un jour.
Le convertisseur d'horodatages avait deux mensonges d'arrondi à lui. Une durée de 59,999 secondes s'affichait « dans 60 secondes », une phrase qui ne devrait jamais exister. Et un epoch complété de zéros comme 0001753372800 était promu en millisecondes parce que l'unité était déduite du nombre de chiffres plutôt que de la magnitude — treize caractères, donc des millisecondes, donc une date en 1970. La détection d'unité compare désormais la valeur à 1e11. Pendant ce temps, l'outil UUID levait carrément une exception sur une origine non sécurisée, parce que crypto.randomUUID est [SecureContext] et qu'un appel nu n'est pas une dégradation gracieuse : c'est une trace d'appels.
Le troisième mensonge est celui qui a piqué. Les liens de partage du générateur de dégradés étaient cassés depuis la mise en ligne de la fonctionnalité. Une instance de l'éditeur antérieure à l'hydratation s'initialisait avec l'état par défaut et déclenchait son replaceState temporisé avant que l'instance vivante n'ait lu le hash — si bien que chaque dégradé partagé revenait au dégradé par défaut environ 400 ms après son chargement. Le lien fonctionnait. Il s'effaçait simplement sous vos yeux. Le test de non-régression correspondant est à contrôle négatif : il a d'abord été lancé contre l'ancien code et confirmé en échec, parce qu'un test vert qui serait resté vert n'est pas un test.
Le correctif qui avait besoin d'un correctif
Ma trouvaille préférée de tout l'exercice est venue d'une seconde passe sur la première salve de réparations, et c'est la raison pour laquelle je ne considère plus « corrigé » comme un état.
Le budget de délimiteurs du markdown plafonnait le travail à 5 000 — de nœuds. Mais le tokeniseur regroupe les séries de caractères d'emphase en un seul nœud : 20 000 astérisques ne font donc qu'une poignée de nœuds, franchissent le plafond sans encombre et font exploser la pile exactement comme avant. Le garde-fou était réel, le nombre était raisonnable, et il comptait le mauvais substantif. Il compte désormais des caractères, avec environ 1,7× de marge mesurée sur le pire cas que nous ayons su construire.
Deux autres trouvailles de cette passe. L'utilitaire de téléchargement multi-fichiers s'effondrait quand gzip lui tendait un blob indéfini — un chemin d'erreur que personne n'emprunte jusqu'au jour où tout le monde l'emprunte. Et le mode Wi-Fi du générateur de QR a vu sa promesse de confidentialité reprouvée de zéro : 900 combinaisons de réglages, en vérifiant que la clé pré-partagée ne peut fuiter dans une URL partageable sous aucune d'entre elles. Elle ne le pouvait pas. Je voulais la preuve quand même, parce que « rien de ce que vous tapez ici ne quitte votre appareil » est la promesse sur laquelle repose toute la Boîte à outils, et qu'une promesse testée une seule fois est une promesse sur laquelle on parie.
La conclusion honnête est celle-ci : trente est le nombre que nous avons confirmé, pas le nombre qui existait. Un auto-audit trouve les défauts que vos attaquants ont pensé à chercher, et j'ai désormais une carte assez précise de ce que huit passes savent faire — géométrie CSS, allers-retours numériques, collage hostile, cycle de vie, sémantique ARIA, grammaire non anglaise — et aucune carte de ce qu'elles ont raté. La qualité livrée n'est pas un état qu'on atteint le jour du lancement. C'est la vitesse à laquelle vous trouvez vos propres erreurs avant que quelqu'un d'autre ait à le faire.
C'est aussi pourquoi ce texte existe. Publier une liste de défauts est une décision marketing étrange et une décision d'ingénierie évidente : c'est le même argument que dans l'artisanat à l'ère du code généré — quand écrire du code devient bon marché, le relire devient tout le métier. Si vous voulez l'architecture dans laquelle ces trente défauts ont été trouvés — le RPC des workers, le streaming, la garantie zéro téléversement — c'est vingt-cinq outils, zéro téléversement.