Atacamos nuestra propia Toolbox: 30 defectos en 24 horas
La Toolbox se lanzó con veinticinco utilidades y un tablero de comprobaciones en verde. Veinticuatro horas después, una autoauditoría estructurada había confirmado treinta defectos dentro — incluido uno que dejaba aproximadamente el 93 % del hub sin poder pulsarse. Aquí está la lista entera, y por qué publicarla es justo la idea.
En esta página
Un commit, 161 archivos, 39,246 líneas: la Toolbox salió entera, veinticinco utilidades que hacen todo su trabajo en tu navegador. TypeScript compilaba. ESLint estaba callado. La build de producción pasaba, trece tests de extremo a extremo y nueve de humo estaban en verde, y la cosa parecía, en mi pantalla, terminada.
Al día siguiente apunté ocho escépticos hacia ella y les dije que la rompieran. Confirmaron treinta defectos. El peor llevaba en la fachada de la funcionalidad todo el tiempo que estuvo en línea: tres declaraciones CSS descarriadas habían colapsado el área pulsable de cada tarjeta del hub a una franja de 331 × 24 píxeles, alrededor del 7 % de la tarjeta. Aproximadamente el 93 % de la superficie a la que un visitante apuntaría de verdad no hacía nada.
- 8
- pasadas de revisión adversaria
- 30
- defectos confirmados
- 37
- archivos parcheados, +1,853 / −309
- 116
- vectores XSS en la batería
La autoauditoría de /tools del 26 de julio, un día después del lanzamiento. Todos estos números salen del commit de arreglos.
Ocho escépticos y una regla
Una revisión adversaria de código no es una segunda opinión. Una segunda opinión lee tu código como tú lo escribiste — de principio a fin, con simpatía, buscando si hiciste lo que pretendías hacer. Un ataque parte de la suposición de que hiciste exactamente lo que pretendías y aun así está mal: que la promesa de la página no se cumple, que la entrada que nadie escribiría ya se ha escrito, que el componente se desmontó a mitad de un trabajo, que la cadena está bien en inglés y es un galimatías en alemán.
Así que la superficie publicada de /tools recibió ocho pasadas separadas, cada una cazando una clase distinta de fallo en lugar de barrer los mismos archivos otra vez. Pero el mecanismo que hizo que el ejercicio valiera algo no fue el ataque.
Ningún hallazgo contaba hasta que una segunda ejecución independiente lo reprodujera. La sospecha es gratis; la moneda es la reproducción.
Esa regla hizo dos cosas. Mató los hallazgos que suenan plausibles y se evaporan ante un cronómetro o un depurador — los que me habrían costado un día de reescritura defensiva para nada. Y convirtió a los supervivientes en algo sobre lo que podía actuar sin discutir: el colapso del área de pulsación no era una teoría sobre bloques contenedores, era un rectángulo medido en una ventana de Chromium en vivo. Treinta hallazgos superaron ese listón. El commit de arreglos tocó 37 archivos, +1,853 líneas y −309.
Pieza uno: el bloque contenedor que se comió la página
Cada tarjeta del hub de la Toolbox usa un enlace estirado: un ancla alrededor del nombre de la utilidad, más un pseudoelemento ::after fijado a los cuatro bordes para que la tarjeta entera sea el objetivo. Es un patrón estándar y depende de una sola cosa: que el pseudoelemento resuelva su posición contra la tarjeta, y no contra algo más pequeño que se cuele en medio.
Los dioramas del hub son 3D con CSS puro, así que transform-style: preserve-3d es una declaración legítima y frecuente en esa hoja de estilos. Tres de ellas habían aterrizado en los elementos equivocados, y preserve-3d establece un bloque contenedor para los descendientes posicionados de forma absoluta. La más interna era el <h3> que lleva el nombre de la utilidad, así que la superposición del tamaño de la tarjeta se convirtió calladamente en una superposición del tamaño del título: 331 píxeles de ancho, 24 de alto.
Nada parecía mal. Esa es la parte que conviene rumiar. La tarjeta se renderizaba perfecta al píxel, el estado hover se disparaba, el cursor seguía convirtiéndose en puntero sobre el título, y todos los tests automáticos pasaban — porque un test pulsa un enlace por su nombre accesible, y el enlace estaba en perfecto estado de salud. El fallo solo existía para quien apuntase al centro de una tarjeta con el ratón, es decir: para todos los visitantes reales, y para ninguno de mis instrumentos.
Todo parecía correcto. Mirar es el instrumento más débil que tengo, y es el primero al que echo mano.
El arreglo fue borrar tres declaraciones. Lo que entró junto a ellas fue un comentario de invariante de dieciséis líneas explicando exactamente por qué nada en ese subárbol puede crear un bloque contenedor — porque la siguiente persona que añada un adorno 3D ahí seré yo, dentro de seis meses, sin recuerdo alguno de esto.
Pieza dos: el azul que volvió mal
El conversor de color acepta HEX, RGB, HSL y OKLCH y te muestra los cuatro. Dale azul puro, #0000ff, y devolvía #0031e5: desviado en 48 de 255 en un solo canal, lo que no es un artefacto de redondeo, es otro color. OKLCH describe un espacio perceptual más grande que sRGB, así que un color convertido cae con frecuencia fuera de la caja que la pantalla puede mostrar, y algo tiene que traerlo de vuelta. El nuestro lo traía mal.
La reparación obvia es recortar cada canal al rango, o elegir el color mostrable más cercano y seguir. No hicimos ninguna de las dos, porque un barrido dijo que el atajo era peor de lo que parecía: la elección del más cercano sin protección habría convertido el 76 % de los resultados fuera de gama en recorte visible de canal. En su lugar, el conversor implementa ahora el mapeo de gama de CSS Color 4 §13: reducir el croma por búsqueda binaria, pero detenerse antes cuando el resultado recortado está dentro de una diferencia apenas perceptible del candidato (clipDelta ≤ 0.02), que es donde la honestidad perceptual y los píxeles mostrables se ponen de acuerdo.
Un barrido de 140,000 colores antes y después te dice si eso merecía la pena escribirse: el peor error de ida y vuelta cayó de 47.99/255 a 0.77/255. Menos de una unidad en un canal de un byte es un color que nadie puede ver moverse.
Piezas tres a siete: entrada hostil y marcharse antes de tiempo
La vista previa de markdown es la utilidad más expuesta a entradas que no pidió, y se llevó lo peor de esta ronda. Una tabla de 19 KB pegada dentro producía una cadena HTML de 275 MB: construcción cuadrática encontrándose con un documento que no es más que delimitadores. Un [ sin cerrar patológico disparaba un reescaneo que corría en O(n²): catorce segundos de pestaña congelada, ahora diez milisegundos. Las citas en bloque anidadas recursaban sin suelo, así que un documento suficientemente profundo podía agotar la pila del todo.
Ninguno de esos es un agujero de seguridad por sí solo — esto es un renderizador sin acceso a la red — pero tienen la misma forma que uno, y la misma pasada lanzó una batería de 116 vectores XSS contra el sanitizador. Dieciocho vectores pasaron antes del arreglo. Cero después. La utilidad funciona ahora con presupuestos explícitos: un presupuesto de delimitadores, un presupuesto de escaneo de enlaces cobrado solo al fallar, 20,000 celdas de tabla, 32 niveles de profundidad de bloque.
| Defecto | Antes | Después |
|---|---|---|
| Área de pulsación de la tarjeta del hub | 331 × 24 px (~7 % de la tarjeta) | la tarjeta entera |
| Ida y vuelta OKLCH, peor caso de 140k colores | 47.99 / 255 | 0.77 / 255 |
| Tabla de 19 KB pegada en la vista previa de markdown | cadena HTML de 275 MB | limitada a 20,000 celdas |
| Reescaneo por corchete sin cerrar | 14 s | 10 ms |
| Batería XSS, 116 vectores | 18 fallos | 0 |
| Medidor de contraseña con 7 caracteres (44.1 bits) | «Aceptable», encima de un tiempo de descifrado de 15 minutos | Débil, a partir de un techo derivado de 46.03 bits |
Seis de los treinta, con números adjuntos. Cada uno se reprodujo de forma independiente antes de aceptarse como defecto.
La otra mitad de la entrada hostil es el momento hostil: el usuario que se va. Tres utilidades (gzip, ZIP, regex) filtraban Web Workers al desmontarse y seguían tan felices machacando para una página que ya no miraba nadie. La causa es un idiom de React que he escrito mal antes y probablemente volveré a escribir mal: el efecto de limpieza capturaba los objetos ref en lugar de leer .current al desmontar, así que desmontaba un manejador obsoleto y dejaba el worker vivo corriendo.
De la misma familia: el constructor de ZIP aplicaba su techo DEFLATE de 4 GiB después de comprimirlo todo, así que un lote demasiado grande hacía el trabajo caro entero y luego abortaba. La comprobación tiene que ir antes del trabajo, lo cual es obvio a toro pasado y era invisible en la revisión de código porque la protección existía y hasta era correcta, solo que tarde.
Los defectos callados: ARIA, portapapeles, cinco idiomas
Cuatro utilidades tenían un control de salto al error escrito como <button role="alert">. Ese único atributo hace dos cosas malas a la vez: anula el rol de botón, así que la tecnología asistiva deja de anunciarlo como pulsable, y como el nombre del elemento venía del contexto y no de su texto, el control se anunciaba como nada en absoluto. Un botón sin etiqueta y sin ser botón es peor que no tener control. El rol se movió al contenedor, que es donde va el anuncio, y el botón volvió a ser un botón.
Copiar al portapapeles fallaba en silencio siempre que el navegador decía que no. Ahora tiñe el control y lo anuncia mediante una región activa — una que se mantiene montada y vacía desde el principio, porque una región activa insertada en el mismo instante en que recibe contenido se anuncia de forma inconsistente entre lectores de pantalla. Se limpia en el siguiente intento y no por temporizador, así que quien lee despacio no pierde el mensaje a media frase.
Y luego la localización, que es donde una autoauditoría se gana el sueldo, porque no puedo corregir cinco de nuestros seis idiomas. La banda de utilidades de la portada mostraba los nombres en inglés en todos los idiomas: un fallback que nadie había notado porque todo el que lo miró leía inglés. El analizador de cron, que explica una programación en palabras llanas, era agramatical en alemán, español y portugués. El francés necesitaba un cambio de código de verdad y se aplazó a conciencia a su propio commit en vez de colarse en un montón de arreglos: los rangos se construyen ahora mediante funciones de cláusula dedicadas para el día de la semana y el mes, así que en alemán se lee von Montag bis Freitag en lugar del rotundamente incorrecto am Montag bis Freitag. La salida en inglés y en ruso volvió idéntica byte a byte, que es como supe que la refactorización solo había tocado lo que decía tocar.
Cuando la interfaz miente
Tres defectos van juntos, porque en cada uno de ellos el producto le decía al usuario algo falso con total seguridad.
El generador de contraseñas calificaba una contraseña de siete caracteres — 44.1 bits de entropía — como «Aceptable», justo encima de su propia estimación de que la contraseña caería en quince minutos. Las dos líneas estaban calculadas correctamente. Solo que venían de sitios distintos: el tiempo de descifrado, de una constante real; la etiqueta de la banda, de un umbral que alguien había tecleado a mano. Así que el umbral dejó de teclearse.
// 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 queda ahora por debajo del techo, así que la insignia dice Débil — y la insignia se mueve sola si la suposición sobre el hardware llega a cambiar.
El conversor de marcas de tiempo tenía dos mentiras de redondeo propias. Una duración de 59.999 segundos se mostraba como «dentro de 60 segundos», que es una frase que no debería existir jamás. Y una época con ceros a la izquierda como 0001753372800 se promovía a milisegundos porque la unidad se infería del número de dígitos y no de la magnitud: trece caracteres, luego milisegundos, luego una fecha de 1970. La detección de unidad ahora contrasta el valor contra 1e11. Mientras tanto, la utilidad de UUID reventaba directamente en un origen inseguro, porque crypto.randomUUID es [SecureContext] y una llamada pelada no es una degradación elegante, es una traza de pila.
La tercera mentira fue la que escoció. Los enlaces para compartir del generador de degradados llevaban rotos desde el instante en que se publicó la funcionalidad. Una instancia del editor previa a la hidratación se inicializaba con el estado por defecto y disparaba su replaceState con debounce antes de que la instancia viva hubiera leído el hash — así que cada degradado que alguien compartía volvía al degradado por defecto unos 400 ms después de cargarse. El enlace funcionaba. Solo que se borraba a sí mismo delante de ti. El test de regresión tiene control negativo: se ejecutó primero contra el código antiguo y se confirmó que fallaba, porque un test verde que habría seguido verde no es un test.
El arreglo que necesitaba un arreglo
Mi hallazgo favorito de todo el ejercicio salió de una segunda pasada sobre la primera ronda de reparaciones, y es la razón de que ya no trate «arreglado» como un estado.
El presupuesto de delimitadores de markdown limitaba el trabajo a 5,000 — de nodos. Pero el tokenizador pliega las rachas de caracteres de énfasis en nodos únicos, así que 20,000 asteriscos son un puñado de nodos, cruzan el techo sin despeinarse y revientan la pila exactamente igual que antes. La protección era real, el número era razonable, y estaba contando el sustantivo equivocado. Ahora cuenta caracteres, con aproximadamente 1.7× de margen medido sobre el peor caso que fuimos capaces de construir.
Dos más de esa pasada. El ayudante de descarga múltiple se venía abajo cuando gzip le entregaba un blob indefinido — una ruta de error que nadie ejercita hasta el día en que la ejercita todo el mundo. Y el modo Wi-Fi del generador de QR se sometió a una nueva demostración de su promesa de privacidad desde cero: 900 combinaciones de ajustes, comprobando que la clave precompartida no puede filtrarse a una URL compartible bajo ninguna de ellas. No podía. Quería la prueba igualmente, porque «nada de lo que escribas aquí sale de tu dispositivo» es la promesa sobre la que está construida la Toolbox entera, y una promesa que solo has probado una vez es una promesa sobre la que estás haciendo conjeturas.
El cierre honesto es este: treinta es el número que confirmamos, no el número que existía. Una autoauditoría encuentra los defectos que a tus atacantes se les ocurrió buscar, y ahora tengo un mapa bastante preciso de en qué son buenas ocho pasadas — geometría CSS, idas y vueltas numéricas, pegado hostil, ciclo de vida, semántica ARIA, gramática no inglesa — y ningún mapa de lo que se les escapó. La calidad al publicar no es un estado que alcanzas el día del lanzamiento. Es el ritmo al que encuentras tus propios errores antes de que tenga que hacerlo otro.
Que es también por lo que existe este artículo. Publicar una lista de defectos es una decisión de marketing extraña y una decisión de ingeniería directa: es el mismo argumento que hice en oficio en la era del código generado, que cuando escribir código se abarata, revisarlo es todo el trabajo. Si quieres la arquitectura en la que se encontraron estos treinta defectos — el RPC con workers, el streaming, la garantía de cero subidas — está en veinticinco utilidades, cero subidas.