Skip to content

Vaynerov Technologies

Wir entwickeln nicht einfach — wir zaubern jede Codezeile und jeden Pixel.

Alle ArtikelBaugeschichten

Angriff auf die eigene Toolbox: 30 Defekte in 24 Stunden

Die Toolbox startete mit fünfundzwanzig Werkzeugen und lauter grünen Gates. Vierundzwanzig Stunden später hatte ein strukturiertes Selbst-Audit dreißig Defekte darin bestätigt – darunter einen, der rund 93 % des Hubs unklickbar machte. Hier ist die ganze Liste, und warum sie zu veröffentlichen der Punkt ist.

Edward AmirainGründer, Vaynerov Technologies
Veröffentlicht 11 Min. Lesezeit
Auf dieser Seite
  1. Acht Skeptiker und eine Regel
  2. Exponat eins: der Containing Block, der die Seite gefressen hat
  3. Exponat zwei: das Blau, das falsch zurückkam
  4. Exponate drei bis sieben: feindliche Eingaben und frühes Gehen
  5. Die leisen Defekte: ARIA, Zwischenablage, fünf Sprachen
  6. Wenn die Oberfläche lügt
  7. Der Fix, der einen Fix brauchte

Ein Commit, 161 Dateien, 39,246 Zeilen: die Toolbox ging am Stück raus, fünfundzwanzig Werkzeuge, die ihre gesamte Arbeit in Ihrem Browser erledigen. TypeScript kompilierte. ESLint schwieg. Der Production-Build lief durch, dreizehn End-to-End-Tests und neun Smoke-Tests waren grün, und das Ding sah auf meinem Bildschirm fertig aus.

Am nächsten Tag habe ich acht Skeptiker darauf angesetzt und ihnen gesagt, sie sollen es kaputt machen. Sie haben dreißig Defekte bestätigt. Der schlimmste saß die ganze Zeit, in der das Feature live war, vorn drauf: Drei versprengte CSS-Deklarationen hatten die klickbare Fläche jeder Karte im Hub auf einen Streifen von 331 × 24 Pixeln zusammenfallen lassen – rund 7 % der Karte. Ungefähr 93 % der Fläche, auf die ein Besucher tatsächlich zielt, taten nichts.

8
adversariale Review-Durchgänge
30
bestätigte Defekte
37
gepatchte Dateien, +1,853 / −309
116
XSS-Vektoren in der Suite

Das Selbst-Audit von /tools am 26. Juli, einen Tag nach dem Start. Jede Zahl hier stammt aus dem Fix-Commit.

Acht Skeptiker und eine Regel

Ein adversariales Code-Review ist keine zweite Meinung. Eine zweite Meinung liest den Code so, wie man ihn geschrieben hat – von vorn nach hinten, wohlwollend, auf der Suche danach, ob man getan hat, was man vorhatte. Ein Angriff geht von der Annahme aus, dass man genau das getan hat, was man vorhatte, und dass es trotzdem falsch ist: dass das Versprechen auf der Seite nicht eingelöst wird, dass die Eingabe, die niemand tippen würde, getippt wurde, dass die Komponente mitten im Job unmountet, dass der String auf Englisch in Ordnung und auf Deutsch Kauderwelsch ist.

Die ausgelieferte /tools-Oberfläche bekam also acht getrennte Durchgänge, jeder auf eine andere Fehlerklasse angesetzt, statt dieselben Dateien noch einmal abzufegen. Aber der Mechanismus, der die Übung überhaupt wertvoll machte, war nicht das Angreifen.

Kein Befund zählte, bis ein zweiter, unabhängiger Durchgang ihn reproduziert hatte. Verdacht ist gratis; Reproduktion ist die Währung.

Diese Regel hat zwei Dinge bewirkt. Sie hat die plausibel klingenden Befunde erledigt, die sich unter Stoppuhr oder Debugger in Luft auflösen – jene, die mich einen Tag defensiven Umschreibens für nichts gekostet hätten. Und sie hat die Überlebenden in etwas verwandelt, mit dem ich ohne Diskussion arbeiten konnte: Der Kollaps der Trefferfläche war keine Theorie über Containing Blocks, sondern ein gemessenes Rechteck in einem laufenden Chromium-Fenster. Dreißig Befunde haben diese Latte überstanden. Der Fix-Commit fasste 37 Dateien an, +1,853 Zeilen und −309.

Dreißig bestätigte Defekte, je eine Marke – die Beschriftungen benennen die Arten. Fast keiner davon war ein Tippfehler; nahezu alle waren korrekter Code mit einer falschen Annahme darunter.

Exponat eins: der Containing Block, der die Seite gefressen hat

Jede Karte im Toolbox-Hub arbeitet mit einem gedehnten Link: ein Anker um den Namen des Werkzeugs plus ein ::after-Pseudoelement, das an alle vier Kanten geheftet ist, damit die ganze Karte das Ziel ist. Ein Standardmuster, und es hängt an genau einer Sache – dass das Pseudoelement seine Position gegen die Karte auflöst und nicht gegen etwas Kleineres dazwischen.

Die Dioramen des Hubs sind reines CSS-3D, transform-style: preserve-3d ist in diesem Stylesheet also eine legitime, häufige Deklaration. Drei davon waren auf den falschen Elementen gelandet, und preserve-3d etabliert einen Containing Block für absolut positionierte Nachfahren. Das innerste war das <h3> mit dem Werkzeugnamen, also wurde aus dem kartengroßen Overlay still ein titelgroßes: 331 Pixel breit, 24 hoch.

Nichts sah falsch aus. Das ist der Teil, bei dem man kurz sitzen bleiben sollte. Die Karte renderte pixelgenau, der Hover-Zustand feuerte, der Cursor wurde über dem Titel weiterhin zum Zeiger, und jeder automatisierte Test lief durch – denn ein Test klickt einen Link über seinen zugänglichen Namen an, und dem Link selbst ging es blendend. Der Fehler existierte nur für jemanden, der mit der Maus auf die Mitte einer Karte zielt, also: für jeden echten Besucher und für keines meiner Instrumente.

Alles sah richtig aus. Hinsehen ist das schwächste Instrument, das ich besitze, und es ist das, zu dem ich zuerst greife.

Der Fix bestand darin, drei Deklarationen zu löschen. Was daneben hineinkam, war ein sechzehnzeiliger Invariantenkommentar, der genau erklärt, warum in diesem Teilbaum nichts einen Containing Block erzeugen darf – denn die nächste Person, die dort eine 3D-Verzierung einbaut, bin ich, in sechs Monaten, ohne jede Erinnerung daran.

Exponat zwei: das Blau, das falsch zurückkam

Der Farbkonverter nimmt HEX, RGB, HSL und OKLCH und zeigt alle vier. Füttert man ihn mit reinem Blau, #0000ff, gab er #0031e5 zurück – um 48 von 255 in einem einzigen Kanal daneben, und das ist kein Rundungsartefakt, das ist eine andere Farbe. OKLCH beschreibt einen Wahrnehmungsraum, der größer ist als sRGB, eine konvertierte Farbe landet also regelmäßig außerhalb der Kiste, die der Bildschirm zeigen kann, und irgendetwas muss sie zurückholen. Unseres holte sie schlecht zurück.

Der naheliegende Fix wäre, jeden Kanal in den Bereich zu klemmen oder die nächstgelegene darstellbare Farbe zu nehmen und weiterzumachen. Wir haben weder das eine noch das andere gemacht, denn ein Durchlauf zeigte, dass die Abkürzung schlimmer ist, als sie aussieht: Ungeschütztes Nächstbeste hätte 76 % der Ergebnisse außerhalb des Gamuts in sichtbares Kanal-Clipping verwandelt. Stattdessen implementiert der Konverter jetzt das Gamut-Mapping aus CSS Color 4 §13 – Chroma per binärer Suche reduzieren, aber früh abbrechen, wenn das geklippte Ergebnis innerhalb eines gerade noch wahrnehmbaren Unterschieds zum Kandidaten liegt (clipDelta ≤ 0.02), also dort, wo perzeptuelle Ehrlichkeit und darstellbare Pixel übereinkommen.

Ein Durchlauf über 140,000 Farben vorher und nachher sagt, ob sich das Schreiben gelohnt hat: Der schlimmste Rundlauffehler fiel von 47.99/255 auf 0.77/255. Weniger als eine Einheit in einem Byte-Kanal ist eine Farbe, die niemand sich bewegen sieht.

Exponate drei bis sieben: feindliche Eingaben und frühes Gehen

Die Markdown-Vorschau ist das Werkzeug, das Eingaben am stärksten ausgesetzt ist, um die es nicht gebeten hat, und es hat in dieser Runde am meisten abbekommen. Eine 19-KB-Tabelle, dort eingefügt, erzeugte einen 275 MB großen HTML-String – quadratische Konstruktion trifft auf ein Dokument, das aus nichts als Trennzeichen besteht. Eine pathologische unpaarige [ löste einen Rescan in O(n²) aus: vierzehn Sekunden eingefrorener Tab, jetzt zehn Millisekunden. Verschachtelte Blockzitate rekursierten ohne Boden, ein tief genug geschachteltes Dokument konnte den Stack also schlicht aufbrauchen.

Nichts davon ist für sich genommen ein Sicherheitsloch – das ist ein Renderer ohne Netzwerkzugriff –, aber es hat dieselbe Form wie eines, und derselbe Durchgang fuhr eine XSS-Suite mit 116 Vektoren gegen den Sanitizer. Achtzehn Vektoren kamen vor dem Fix durch. Danach null. Das Werkzeug läuft jetzt auf expliziten Budgets: ein Trennzeichenbudget, ein Link-Scan-Budget, das nur im Fehlerfall belastet wird, 20,000 Tabellenzellen, 32 Ebenen Blocktiefe.

DefektVorherNachher
Klickziel der Hub-Karte331 × 24 px (~7 % der Karte)die ganze Karte
OKLCH-Rundlauf, schlechtester von 140k Farben47.99 / 2550.77 / 255
19-KB-Tabelle in der Markdown-Vorschau275 MB HTML-Stringgedeckelt bei 20,000 Zellen
Rescan bei unpaariger Klammer14 s10 ms
XSS-Suite, 116 Vektoren18 Durchbrüche0
Passwortanzeige bei 7 Zeichen (44.1 Bit)„Fair“, über einer Knackzeit von 15 MinutenSchwach, aus einer abgeleiteten Decke von 46.03 Bit

Sechs der dreißig, mit Zahlen. Jeder wurde unabhängig reproduziert, bevor er als Defekt akzeptiert wurde.

Die andere Hälfte feindlicher Eingaben ist feindliches Timing – der Nutzer, der geht. Drei Werkzeuge (gzip, ZIP, Regex) haben beim Unmount Web Worker geleakt und fröhlich weitergerechnet für eine Seite, die niemand mehr ansah. Die Ursache ist ein React-Idiom, das ich schon falsch geschrieben habe und wahrscheinlich wieder falsch schreiben werde: Der Cleanup-Effekt hat die Ref-Objekte eingefangen, statt beim Abbau .current zu lesen, und damit ein veraltetes Handle abgebaut und den lebenden Worker laufen lassen.

Aus derselben Familie: Der ZIP-Builder setzte seine 4-GiB-DEFLATE-Grenze nach dem Komprimieren durch, ein zu großer Stapel erledigte also die gesamte teure Arbeit und brach dann ab – die Prüfung muss vor die Arbeit, was im Nachhinein offensichtlich ist und im Code-Review unsichtbar war, weil die Absicherung existierte und sogar korrekt war, nur zu spät.

Die leisen Defekte: ARIA, Zwischenablage, fünf Sprachen

Vier Werkzeuge hatten ihre Sprung-zum-Fehler-Bedienung als <button role="alert"> geschrieben. Dieses eine Attribut tut zwei schlechte Dinge auf einmal: Es überschreibt die Button-Rolle, sodass assistive Technik das Ding nicht mehr als drückbar ansagt, und weil der Name des Elements aus dem Kontext statt aus Text kam, wurde die Bedienung als gar nichts angesagt. Ein unbeschrifteter, entbuttonter Button ist schlimmer als gar keine Bedienung. Die Rolle ist an den Wrapper gewandert, wo die Ansage hingehört, und der Button ist wieder ein Button.

Kopieren in die Zwischenablage scheiterte still, sobald der Browser Nein sagte. Es färbt die Bedienung jetzt ein und meldet über eine Live-Region – eine, die von Anfang an gemountet und leer gehalten wird, denn eine Live-Region, die im selben Moment eingefügt wird, in dem sie Inhalt bekommt, wird über Screenreader hinweg uneinheitlich angesagt. Sie leert sich beim nächsten Versuch statt auf einem Timer, damit ein langsamer Leser die Meldung nicht mitten im Satz verliert.

Dann die Lokalisierung, und dort verdient sich ein Selbst-Audit seinen Unterhalt, denn ich kann fünf unserer sechs Sprachen nicht korrekturlesen. Das Tools-Band auf der Startseite renderte in jeder Sprache englische Werkzeugnamen – ein Fallback, der nie aufgefallen war, weil alle, die darauf sahen, Englisch lasen. Der Cron-Parser, der einen Zeitplan in Klartext erklärt, war auf Deutsch, Spanisch und Portugiesisch ungrammatisch. Französisch brauchte eine echte Codeänderung und wurde bewusst in einen eigenen Commit verschoben, statt in einen Fix-Haufen geschmuggelt zu werden: Bereiche laufen jetzt über eigene Klauselfunktionen für Wochentage und Monate, sodass auf Deutsch von Montag bis Freitag steht statt des schlicht falschen am Montag bis Freitag. Die englische und die russische Ausgabe kamen danach bytegleich zurück, und daran habe ich gemerkt, dass das Refactoring nur das angefasst hat, was es zu ändern behauptete.

Wenn die Oberfläche lügt

Drei Defekte gehören zusammen, weil das Produkt bei jedem von ihnen dem Nutzer mit voller Überzeugung etwas Unwahres gesagt hat.

Der Passwortgenerator bewertete ein siebenstelliges Passwort – 44.1 Bit Entropie – als „Fair“, direkt über seiner eigenen Schätzung, dass dieses Passwort binnen fünfzehn Minuten fällt. Beide Zeilen waren korrekt berechnet. Sie kamen nur aus verschiedenen Quellen: die Knackzeit aus einer echten Konstante, die Bandbezeichnung aus einem Schwellenwert, den jemand von Hand eingetippt hatte. Also wird der Schwellenwert nicht mehr getippt.

// 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.03

44.1 Bit liegen jetzt unter der Decke, das Abzeichen liest also Schwach – und es bewegt sich automatisch mit, falls sich die Hardwareannahme je ändert.

Der Zeitstempel-Konverter hatte zwei eigene Rundungslügen. Eine Dauer von 59.999 Sekunden wurde als „in 60 Sekunden“ dargestellt, ein Satz, den es nie geben sollte. Und eine nullgepolsterte Epoche wie 0001753372800 wurde zu Millisekunden befördert, weil die Einheit aus der Anzahl der Ziffern statt aus der Größenordnung erschlossen wurde – dreizehn Zeichen, also Millisekunden, also ein Datum im Jahr 1970. Die Einheitenerkennung testet den Wert jetzt gegen 1e11. Das UUID-Werkzeug wiederum warf auf einer unsicheren Origin schlicht eine Exception, denn crypto.randomUUID ist [SecureContext], und ein nackter Aufruf ist keine graceful degradation, sondern ein Stacktrace.

Die dritte Lüge war die, die wehtat. Die Teilen-Links des Gradient-Generators waren seit dem Moment kaputt, in dem das Feature erschien. Eine Instanz des Editors vor der Hydration hat sich mit dem Standardzustand befüllt und ihr entprelltes replaceState abgefeuert, bevor die lebende Instanz den Hash gelesen hatte – jeder von irgendwem geteilte Gradient fiel also rund 400 ms nach dem Laden auf den Standardgradienten zurück. Der Link funktionierte. Er löschte sich nur vor Ihren Augen selbst. Der Regressionstest dafür ist negativ kontrolliert: Er lief zuerst gegen den alten Code und musste dort nachweislich fehlschlagen, denn ein grüner Test, der ohnehin grün geblieben wäre, ist kein Test.

Der Fix, der einen Fix brauchte

Mein Lieblingsbefund der ganzen Übung kam aus einem zweiten Durchgang über die erste Runde Reparaturen, und er ist der Grund, warum ich „behoben“ nicht mehr als Zustand betrachte.

Das Trennzeichenbudget der Markdown-Vorschau deckelte die Arbeit bei 5,000 – Knoten. Der Tokenizer faltet Folgen von Betonungszeichen aber zu einzelnen Knoten, 20,000 Sternchen sind also eine Handvoll Knoten, segeln glatt unter der Decke durch und sprengen den Stack exakt wie vorher. Die Absicherung war echt, die Zahl war vernünftig, und sie zählte das falsche Substantiv. Sie zählt jetzt Zeichen, mit gemessen rund 1.7× Luft über dem schlimmsten Fall, den wir konstruieren konnten.

Zwei weitere aus diesem Durchgang. Der Helfer für Mehrfach-Downloads brach zusammen, als gzip ihm ein undefiniertes Blob übergab – ein Fehlerpfad, den niemand durchläuft, bis ihn eines Tages alle durchlaufen. Und der WLAN-Modus des QR-Generators bekam sein Datenschutzversprechen von Grund auf neu bewiesen: 900 Einstellungskombinationen, geprüft darauf, dass der Pre-Shared Key unter keiner davon in eine teilbare URL sickern kann. Er konnte es nicht. Ich wollte den Beweis trotzdem, denn „nichts, was Sie hier tippen, verlässt Ihr Gerät“ ist das Versprechen, auf dem die ganze Toolbox steht, und ein Versprechen, das man nur einmal getestet hat, ist ein Versprechen, über das man rät.

Der ehrliche Schluss lautet: Dreißig ist die Zahl, die wir bestätigt haben, nicht die Zahl, die existierte. Ein Selbst-Audit findet die Defekte, nach denen die eigenen Angreifer zu suchen dachten, und ich habe jetzt eine ziemlich genaue Karte davon, worin acht Durchgänge gut sind – CSS-Geometrie, numerische Rundläufe, feindliches Einfügen, Lebenszyklus, ARIA-Semantik, nichtenglische Grammatik – und überhaupt keine Karte davon, was sie übersehen haben. Auslieferungsqualität ist kein Zustand, den man am Launchtag erreicht. Sie ist die Rate, mit der man die eigenen Fehler findet, bevor es jemand anderes tun muss.

Auch deshalb existiert dieser Text. Eine Defektliste zu veröffentlichen ist eine seltsame Marketingentscheidung und eine geradlinige Engineering-Entscheidung: Es ist dasselbe Argument, das ich in Handwerk im Zeitalter generierten Codes gemacht habe – wenn Code schreiben billig wird, ist Code prüfen die eigentliche Arbeit. Wer die Architektur sehen will, in der diese dreißig Defekte gefunden wurden – die Worker-RPC, das Streaming, die Null-Upload-Garantie –, findet sie in fünfundzwanzig Tools, null Uploads.