Saltar al contenido
Development

Turbo Frames, un sanitizador de defensa en profundidad, y cómo enseñarle eso a Brakeman

Por Victor Da Luz
railsrubysecuritydev-logblog-manager

La siguiente pieza del editor de posts de blog-manager fue un panel de vista previa GFM renderizado en el servidor: renderizar el cuerpo Markdown del borrador con Commonmarker, mostrarlo en un Turbo Frame, mantenerlo sincronizado con el autoguardado. Suena a una funcionalidad pequeña, pero tocó cuatro cosas que nunca había hecho en esta app antes: el primer Turbo Frame, una lista blanca de sanitización de HTML escrita a mano, un enfoque sin Node para la tipografía de prosa, y enseñarle a Brakeman sobre un falso positivo en vez de simplemente encogerme de hombros ante un CI en rojo.

El interruptor en vez de lado a lado

El issue decía “interruptor o lado a lado.” El layout del editor es una columna de cuerpo ancha más una barra lateral angosta de 320px (donde ya viven Metadata/Tags/Hero/Traducción). Un artículo renderizado en 320px sería ilegible, así que opté por un interruptor Escribir/Vista previa dentro de la columna principal, dos botones que intercambian el textarea por un Turbo Frame en el mismo espacio. Botones de interruptor simples con aria-pressed en vez del patrón completo de pestañas ARIA, ya que es un switch binario, no un widget multi-pestaña.

El primer Turbo Frame en esta app

Nada en blog-manager usaba un Turbo Frame antes de esto. La trampa que no esperaba: cuando un frame se recarga, Turbo reemplaza todo el elemento <turbo-frame>, incluyendo sus atributos, con lo que sea que venga en la respuesta. Había puesto un atributo target de Stimulus (data-draft-target="previewFrame") solo en el markup inicial del frame en la página del editor. En la primera recarga, ese atributo habría desaparecido, rompiendo en silencio cada futura búsqueda de this.previewFrameTarget. La corrección fue mecánica una vez que entendí el problema: el parcial de respuesta del controlador necesita llevar exactamente el mismo atributo data-draft-target en su propia llamada a turbo_frame_tag, no solo la página que renderiza el frame por primera vez.

También me salté a propósito el atributo nativo loading="lazy" de Turbo. Está basado en IntersectionObserver, y un elemento con display:none (que es cómo oculto el panel de vista previa mientras se escribe) nunca intersecta, así que la carga diferida simplemente nunca se activaría. En vez de eso, manejo el src del frame enteramente desde Stimulus: sin src hasta que el usuario hace clic en Vista previa por primera vez, y luego frame.reload() en cada interruptor o autoguardado posterior.

Sanitización: defensa en profundidad, no solo un portón

Commonmarker (respaldado por Rust/comrak) elimina el HTML incrustado crudo de la fuente Markdown por defecto, confirmé esto antes de confiar en ello, dándole un tag <script> literal y revisando la salida intermedia: sale como un placeholder de comentario HTML, nunca llega al DOM. Pero no quería que la seguridad de la vista previa dependiera de que el comportamiento interno de una sola librería nunca cambiara, así que pasé la salida también por el Rails::Html::SafeListSanitizer propio de Rails, con una lista blanca explícita de tags/atributos limitada exactamente a lo que GFM puede producir (encabezados, listas, tablas, código, checkboxes de tasklist, nada de iframe, nada de style, nada de atributos on*). Dos capas independientes que no comparten ninguna suposición. Escribí pruebas que verifican que tanto un tag script como un atributo onerror se eliminan, no solo que el camino feliz renderiza.

Enseñarle a Brakeman en vez de discutir con él

Marcar el HTML sanitizado como .html_safe en la vista (la forma estándar y correcta de renderizar contenido pre-sanitizado en Rails) activa el chequeo genérico de Brakeman de “atributo de modelo sin escapar,” no puede ver a través de un objeto de servicio personalizado para saber que el string en realidad fue sanitizado. En vez de degradar el chequeo o silenciar por reflejo algo de lo que depende el CI, usé el propio mecanismo de archivo de ignorados de Brakeman: generé la huella exacta de la advertencia, adjunté una nota explicando la sanitización de dos capas y señalando el archivo de pruebas que lo demuestra, y confirmé config/brakeman.ignore. bin/brakeman no podía correr interactivamente en mi entorno (sin una TTY real para el prompt), así que manejé directamente la propia API de Ruby de Brakeman: escaneé, encontré la advertencia por su huella, llamé .to_hash sobre ella, escribí la nota, guardé el archivo con la forma JSON exacta que Brakeman espera. Mismo resultado que correr brakeman -I a mano, solo que scripteado.

Tipografía de prosa sin Node

Este repo no tiene ninguna huella de Node/npm, Tailwind v4 corre enteramente a través del gem, sin package.json en ningún lado. Agregar @tailwindcss/typography para clases de estilo prosa habría significado introducir todo un toolchain de JS para un solo plugin de CSS. En vez de eso, escribí a mano un bloque .prose, siguiendo el estilo de CSS de componentes escrito a mano que ya existe en la app (mismo patrón que .field-input, .btn) y referenciando las mismas variables CSS de design tokens que usa todo lo demás, lo que significa que el soporte de modo oscuro salió gratis, sin necesitar un bloque de override separado para modo oscuro.

Números

Medido contra un post real de 22.6KB de vdaluz.com (20 corridas): promedio 9.2ms, peor caso 30.6ms. El objetivo del issue era estar bajo 500ms. Que Commonmarker esté respaldado por Rust volvió esto una no-pregunta, el presupuesto nunca iba a ser el cuello de botella.

Qué sigue

El panel de vista previa está terminado y probado (pruebas unitarias del renderer, pruebas de controlador, una pasada completa en navegador incluyendo que el refresco disparado por el autoguardado funcione incluso cuando la validación de metadata falla pero el cuerpo igual se guarda, una interacción con la corrección del panel de metadata de ese mismo día, más temprano). Lo siguiente en el clúster del editor: el panel de hero y el flujo de commit, ambos bloqueados detrás de este trabajo hasta ahora.

Lecturas relacionadas

Development

Tres líneas de configuración, una tarde de verificación

Descomentar las flags de SSL de Rails tomó diez minutos. Leer el código fuente del framework, poner a prueba dos hallazgos de revisión que sonaban plausibles pero eran incorrectos, y comprobar la cookie en producción se llevó el resto.

Leer