02Seguretat5 min

El que vaig aprendre auditant el meu propi codi com si fos d'un altre

Un ACL buit que retornava el corpus sencer, una injecció de CSS sense una línia de JavaScript i una API key que encara valia després de donar de baixa l'amo. Tot en un dia, tot en codi que havia escrit jo.

Per Salva Castells

Imatge de portada: Ildar Sagdejev (Specious), CC BY-SA 3.0.
En aquest post

En un sol dia vaig tancar sis errors de seguretat en una API de transcripció que feia mesos que estava en marxa. Cap no el va trobar un test. Els tests passaven en verd abans i després. El que va canviar no va ser l'eina: va ser que em vaig asseure a llegir el meu propi codi preguntant-me com el trencaria si ho volgués fer, en comptes de si feia el que jo havia pensat.

Aquesta diferència sembla d'actitud. És de mètode, i es nota en la mena d'error que surt.

Un test comprova el camí que et vas imaginar

Un test s'escriu des del cas d'ús: dades raonables, sortida raonable. Un atacant no passa dades raonables. Passa el buit, el zero, el negatiu, l'enorme. Aquests valors rarament són a la suite, perquè quan escrius el test no se t'acudeixen i quan se t'acudeixen ja has vist el bug.

El cas més clar va ser un ACL buit. El cercador de recuperació rebia una llista d'identificadors als quals l'usuari tenia accés i filtrava el corpus per ella. Si la llista venia buida, el codi la interpretava com a "no hi ha filtre a aplicar" en comptes de com a "aquest usuari no té accés a res". Retornava el corpus sencer.

Allò fallava obert: el valor per defecte d'una absència de permisos era permetre-ho tot. Cap test de camí feliç no ho toca mai, perquè en el camí feliç la llista sempre porta alguna cosa.

Des de llavors reviso tots els filtres d'autorització amb la mateixa pregunta: què fa això si la col·lecció és buida? No "què fa si l'usuari no té permís?", que sol estar ben cobert, sinó què fa amb el buit.

Les categories es repeteixen més del que sembla

Les troballes s'agrupen gairebé sempre en les mateixes famílies.

  • Permisos que no s'exigeixen on toca. Un endpoint d'exportació que deixava treure dades sense demanar l'autoritat de gestió d'estructura. Un esborrat en cascada d'un domini que comprovava el permís un cop al principi en comptes de comprovar l'autoritat d'esborrat dur sobre cada element afectat. La cascada és el lloc clàssic: autoritzes l'operació i després el bucle intern fa coses que ningú no va autoritzar.
  • Límits que no existeixen. Els llistats d'administració passaven el limit que arribés directe a la consulta, sense sostre. Un valor gran des d'un script materialitzava la taula sencera a la memòria del worker. L'arreglada cap en una línia, Math.min(Math.max(limit, 1), 1000), però no hi era perquè a la UI el limit sempre valia deu.
  • Estat que es comprova un cop i mai més. authenticateApiKey validava el hash, l'expiració i el flag isActive de la clau. Però la fila de la clau sobreviu a l'estat del compte de l'amo: si el registre de facturació es neteja en donar de baixa algú, ningú no baixa aquell flag i la clau continua autenticant. Ara la funció revalida que el propietari continuï tenint registre de facturació, en un sol lloc, perquè tots els endpoints que autentiquen per clau ho heretin en comptes d'haver-se'n de recordar cadascun.
  • Contingut de tercers que es renderitza. Aquí hi ha el que més em va sorprendre.

El CSS també és una injecció

El sanejador de markdown permetia els atributs style i class lliures a div, span i svg. La cadena va de raw a sanitize a harden i cap etapa no mira dins del CSS: la llibreria de sanejat tracta style com a cadena opaca i l'endureix només reescriu URLs a a i img. L'estil arribava intacte al render final.

Vaig pensar a escriure un parser de CSS amb llista blanca de propietats. No ho vaig fer: vaig comprovar que cap vista legítima no necessitava style i vaig treure els dos atributs de la llista permesa. Un filtre de CSS propi és superfície nova per mantenir per cobrir un cas que no existia.

L'important de la troballa no és l'arreglada, és la mena de vector. No cal JavaScript per atacar un altre usuari: amb CSS pots tapar un botó, moure un text a sobre d'un altre o canviar el que sembla que diu un missatge. I qualsevol vista que pinti contingut aliè, no només el propi, és un vector cross-user.

Secrets als logs i enllaços que duren massa

Dues troballes que no són bugs de lògica, sinó de cicle de vida.

La primera: quan l'autenticació per API key fallava per clau corrupta o mal formada, el catch ficava la clau en brut al context del log, i el logger serialitzava aquell context amb JSON.stringify sense redactar res. Una clau vàlida que s'enviés a un endpoint equivocat acabava en text clar al log. La redacció que vaig posar ataca per dos costats, per nom de camp (/rawkey|token|authorization|password/i) i per forma del valor (el prefix dk_live_ / dk_test_), i deixa visibles els vuit primers caràcters seguits de ***REDACTED***, el mateix format amb què la UI torna a amagar la clau després de mostrar-la una vegada.

La segona: les URL signades d'S3 per al reproductor d'àudio es generaven amb expiresIn de set dies. Set dies és una eternitat per a un enllaç que només ha de sobreviure al fet que carregui un <audio>. Qualsevol amb el link —reenviat, a l'historial, cauejat per un proxy corporatiu— conservava accés a l'àudio original tota aquella finestra, i esborrar la transcripció no revocava res. Vaig baixar l'expiració a 600 segons, amb renovació des del client als 480, i vaig afegir el DeleteObjectCommand contra l'objecte en esborrar la transcripció, perquè fins llavors la cascada només esborrava la fila.

El cost en experiència d'usuari va ser zero tret d'un flux: reenviar l'enllaç de l'àudio per correu a un company. Aquell flux mai no va ser una funcionalitat suportada ni tenia cap control d'accés, i em sembla correcte que deixés de funcionar.

Ara ho programo en comptes de recordar-me'n

El problema de les auditories és que depenen que un dia et vingui de gust. La meva va sortir perquè estava documentant decisions de disseny i en rellegir em van grinyolar coses.

Cada troballa es converteix en un fitxer curt a docs/security/ amb el vector i per què l'arreglada és aquella i no una altra, i d'aquí surt la llista amb què reviso la resta. Quan vaig repetir l'exercici contra el boilerplate amb què arrenco projectes van sortir trenta-nou punts entre seguretat, SEO i arquitectura, i la majoria eren versions del mateix grapat de preguntes: què passa amb la llista buida, qui exigeix el permís al bucle intern, quin és el sostre d'aquest límit, quant viu aquest enllaç.

Llegir el teu codi per confirmar que funciona i llegir-lo per veure com trencar-lo són dues lectures diferents. La segona no passa sola mentre fas la primera.