Op één dag heb ik zes beveiligingsfouten gedicht in een transcriptie-API die al maanden draaide. Geen enkele werd gevonden door een test. De tests stonden op groen, ervoor en erna. Wat veranderde was niet het gereedschap: het was dat ik ging zitten om mijn eigen code te lezen met de vraag hoe ik hem zou breken als ik dat wilde, in plaats van of hij deed wat ik had bedacht.
Dat verschil lijkt een kwestie van houding. Het is een kwestie van methode, en je ziet het terug in het soort fout dat eruit komt.
Een test controleert het pad dat jij bedacht hebt
Een test schrijf je vanuit het gebruiksgeval: redelijke data, redelijke uitvoer. Een aanvaller geeft geen redelijke data door. Hij geeft leegte door, nul, negatief, enorm. Die waarden zitten zelden in de suite, want als je de test schrijft komen ze niet bij je op, en als ze wel bij je opkomen heb je de bug al gezien.
Het duidelijkste geval was een lege ACL. De retrieval-zoekmachine kreeg een lijst identifiers waartoe de gebruiker toegang had en filterde het corpus daarmee. Als de lijst leeg binnenkwam, las de code dat als "er is geen filter toe te passen" in plaats van als "deze gebruiker heeft nergens toegang toe". Hij gaf het hele corpus terug.
Dat faalde open: de standaardwaarde bij een afwezigheid van rechten was alles toestaan. Geen enkele happy-path-test raakt dat ooit, want op het gelukkige pad bevat de lijst altijd iets.
Sindsdien bekijk ik alle autorisatiefilters met dezelfde vraag: wat doet dit als de collectie leeg is? Niet "wat doet het als de gebruiker geen rechten heeft", wat meestal goed is afgedekt, maar wat het doet met de leegte.
De categorieën herhalen zich vaker dan je denkt
De bevindingen vallen bijna altijd in dezelfde families.
- Rechten die niet worden afgedwongen waar het hoort. Een exportendpoint dat data liet weglekken zonder om de beheerbevoegdheid over de structuur te vragen. Een cascadeverwijdering in een domein die het recht één keer aan het begin controleerde in plaats van de bevoegdheid voor harde verwijdering te controleren op elk geraakt element. De cascade is de klassieke plek: je autoriseert de operatie en daarna doet de binnenste lus dingen die niemand heeft geautoriseerd.
- Limieten die niet bestaan. De beheerlijsten gaven de binnenkomende
limitrechtstreeks door aan de query, zonder plafond. Een grote waarde vanuit een script materialiseerde de hele tabel in het geheugen van de worker. De fix past op één regel,Math.min(Math.max(limit, 1), 1000), maar hij stond er niet omdat delimitin de UI altijd tien was. - State die één keer wordt gecontroleerd en daarna nooit meer.
authenticateApiKeyvalideerde de hash, de vervaldatum en deisActive-flag van de sleutel. Maar de rij van de sleutel overleeft de status van het account van de eigenaar: als de facturatieregistratie wordt opgeschoond bij een opzegging, zet niemand die flag om en blijft de sleutel authenticeren. Nu hercontroleert de functie of de eigenaar nog steeds een facturatieregistratie heeft, op één plek, zodat alle endpoints die via sleutel authenticeren dat erven in plaats van dat elk endpoint er zelf aan moet denken. - Content van derden die wordt gerenderd. Hier zit degene die me het meest verraste.
CSS is ook een injectie
De markdown-sanitizer stond de attributen style en class vrij toe op div, span en svg. De keten loopt van raw naar sanitize naar harden en geen enkele stap kijkt in de CSS: de sanitize-library behandelt style als een ondoorzichtige string en de hardener herschrijft alleen URL's in a en img. De stijl kwam intact aan bij de uiteindelijke render.
Ik dacht erover een CSS-parser te schrijven met een whitelist van eigenschappen. Dat heb ik niet gedaan: ik controleerde dat geen enkele legitieme weergave style nodig had en haalde beide attributen uit de toegestane lijst. Een eigen CSS-filter is nieuw oppervlak om te onderhouden voor een geval dat niet bestond.
Het belangrijke aan de bevinding is niet de fix, het is het soort vector. Je hebt geen JavaScript nodig om een andere gebruiker aan te vallen: met CSS kun je een knop bedekken, tekst over andere tekst schuiven of veranderen wat een bericht lijkt te zeggen. En elke weergave die content van anderen tekent, niet alleen die van jezelf, is een cross-user vector.
Geheimen in logs en links die te lang leven
Twee bevindingen die geen logicabugs zijn, maar levenscyclusbugs.
De eerste: als de authenticatie via API key faalde door een corrupte of misvormde sleutel, stopte de catch de ruwe sleutel in de context van de log, en de logger serialiseerde die context met JSON.stringify zonder iets te redigeren. Een geldige sleutel die naar een verkeerd endpoint werd gestuurd, belandde in leesbare tekst in de log. De redactie die ik heb toegevoegd valt van twee kanten aan, op veldnaam (/rawkey|token|authorization|password/i) en op vorm van de waarde (het voorvoegsel dk_live_ / dk_test_), en laat de eerste acht tekens zichtbaar gevolgd door ***REDACTED***, hetzelfde formaat waarmee de UI de sleutel weer verbergt nadat hij één keer is getoond.
De tweede: de gesigneerde S3-URL's voor de audiospeler werden gegenereerd met een expiresIn van zeven dagen. Zeven dagen is een eeuwigheid voor een link die alleen hoeft te overleven totdat een <audio> geladen is. Iedereen met die link — doorgestuurd, in de geschiedenis, gecachet door een bedrijfsproxy — hield al die tijd toegang tot de originele audio, en de transcriptie verwijderen trok niets in. Ik heb de vervaltijd verlaagd naar 600 seconden, met vernieuwing vanaf de client na 480, en ik heb het DeleteObjectCommand tegen het object toegevoegd bij het verwijderen van de transcriptie, want tot dan toe verwijderde de cascade alleen de rij.
De kosten in gebruikerservaring waren nul, op één flow na: de link van de audio per mail doorsturen naar een collega. Die flow is nooit een ondersteunde functionaliteit geweest en had geen enkele toegangscontrole, en ik vind het prima dat hij is gestopt met werken.
Nu plan ik het in plaats van eraan te denken
Het probleem met audits is dat ze ervan afhangen of je er op een dag zin in hebt. De mijne kwam er omdat ik ontwerpbeslissingen aan het documenteren was en er bij het herlezen dingen begonnen te knarsen.
Elke bevinding wordt een kort bestand in docs/security/ met de vector en waarom de fix deze is en geen andere, en daaruit komt de lijst waarmee ik de rest nakijk. Toen ik de oefening herhaalde tegen de boilerplate waarmee ik projecten start, kwamen er negenendertig punten uit tussen beveiliging, SEO en architectuur, en de meeste waren varianten van dezelfde handvol vragen: wat gebeurt er met de lege lijst, wie dwingt het recht af in de binnenste lus, wat is het plafond van deze limiet, hoe lang leeft deze link.
Je code lezen om te bevestigen dat hij werkt en hem lezen om te zien hoe je hem breekt, zijn twee verschillende manieren van lezen. De tweede gebeurt niet vanzelf terwijl je de eerste doet.
