Turbo Frames, um sanitizador de defesa em profundidade, e ensinando o Brakeman sobre isso
A próxima peça do editor de posts do blog-manager era um painel de preview em GFM renderizado no servidor - renderizar o corpo Markdown do rascunho com Commonmarker, mostrar num Turbo Frame, manter sincronizado com o autosave. Uma feature que soa pequena, mas tocou quatro coisas que eu nunca tinha feito nesse app antes: o primeiro Turbo Frame, uma allowlist de sanitizador de HTML escrita à mão, uma abordagem sem Node para tipografia de prosa, e ensinar o Brakeman sobre um falso positivo em vez de só dar de ombros para um CI vermelho.
O toggle em vez de lado a lado
A issue dizia “toggle ou lado a lado.” O layout do editor é uma coluna de corpo larga mais uma sidebar estreita de 320px (onde Metadata/Tags/Hero/Translation já vivem). Um artigo renderizado em 320px seria ilegível, então fui com um toggle Write/Preview dentro da coluna principal - dois botões que trocam o textarea por um Turbo Frame no mesmo espaço. Botões de toggle simples com aria-pressed em vez de um padrão completo de tabs ARIA, já que é um switch binário, não um widget multi-aba.
O primeiro Turbo Frame nesse app
Nada no blog-manager usava um Turbo Frame antes disso. A pegadinha que eu não esperava: quando um frame recarrega, o Turbo substitui o elemento <turbo-frame> inteiro - incluindo seus atributos - pelo que vier na resposta. Eu tinha colocado um atributo target do Stimulus (data-draft-target="previewFrame") só no markup inicial de placeholder do frame na página do editor. No primeiro reload, esse atributo teria sumido, quebrando silenciosamente toda busca futura de this.previewFrameTarget. A correção foi mecânica assim que entendi: o partial de resposta do controller precisa carregar exatamente o mesmo atributo data-draft-target na sua própria chamada de turbo_frame_tag, não só a página que renderiza o frame pela primeira vez.
Também pulei de propósito o atributo nativo loading="lazy" do Turbo. Ele é baseado em IntersectionObserver, e um elemento com display:none (que é como escondo o painel de preview enquanto escrevo) nunca intersecta - então o lazy-loading simplesmente nunca dispararia. Em vez disso, eu controlo o src do frame inteiramente pelo Stimulus: sem src até o usuário clicar em Preview pela primeira vez, depois frame.reload() a cada toggle ou autosave seguinte.
Sanitização: defesa em profundidade, não só um portão
O Commonmarker (baseado em Rust/comrak) remove HTML cru embutido da fonte Markdown por padrão - confirmei isso antes de confiar, alimentando ele com uma tag <script> literal e conferindo a saída intermediária: ela sai como um placeholder de comentário HTML, nunca chega ao DOM. Mas eu não queria que a segurança do preview dependesse do comportamento interno de uma única biblioteca nunca mudar, então passei a saída também pelo Rails::Html::SafeListSanitizer do próprio Rails, com uma allowlist explícita de tag/atributo restrita a exatamente o que o GFM pode produzir (headings, listas, tabelas, código, checkboxes de tasklist - sem iframe, sem style, sem atributos on*). Duas camadas independentes que não compartilham uma suposição. Escrevi testes afirmando que tanto uma tag script quanto um atributo onerror são removidos, não só que o caminho feliz renderiza.
Ensinando o Brakeman em vez de discutir com ele
Marcar o HTML sanitizado como .html_safe na view (a forma padrão e correta de renderizar conteúdo pré-sanitizado no Rails) dispara a checagem genérica do Brakeman de “unescaped model attribute” - ele não consegue enxergar através de um service object customizado para saber que a string foi de fato sanitizada. Em vez de rebaixar a checagem ou silenciar por reflexo algo de que o CI depende, usei o próprio mecanismo de arquivo de ignore do Brakeman: gerei a fingerprint exata do warning, anexei uma nota explicando a sanitização em duas camadas e apontando para o arquivo de teste que prova isso, e commitei config/brakeman.ignore. O bin/brakeman não conseguia rodar interativamente no meu ambiente (sem TTY de verdade para o prompt), então usei a própria API Ruby do Brakeman diretamente - escaneei, encontrei o warning pela fingerprint, chamei .to_hash nele, escrevi a nota, salvei o arquivo exatamente no formato JSON esperado pelo Brakeman. Mesmo resultado de rodar brakeman -I manualmente, só que via script.
Tipografia de prosa sem Node
Esse repositório tem footprint zero de Node/npm - o Tailwind v4 roda inteiramente pela gem, nenhum package.json em lugar nenhum. Adicionar o @tailwindcss/typography para classes de estilo de prosa significaria introduzir uma toolchain JS inteira por causa de um plugin CSS. Em vez disso, construí um bloco .prose à mão, seguindo o estilo de CSS de componente já escrito à mão no app (mesmo padrão de .field-input, .btn) e referenciando as mesmas variáveis CSS de design token que tudo mais usa - o que significa que o suporte a dark mode veio de graça, sem precisar de um bloco de override separado para dark mode.
Números
Benchmarquei contra um post real de 22.6KB do vdaluz.com (20 execuções): média de 9.2ms, pior caso de 30.6ms. A meta da issue era abaixo de 500ms. O Commonmarker ser baseado em Rust tornou isso uma não-questão - o orçamento nunca ia ser o gargalo.
O que vem a seguir
O painel de preview está pronto e testado (testes unitários do renderizador, testes de controller, uma passada completa no navegador incluindo o refresh disparado pelo autosave funcionando mesmo quando a validação de metadata falha mas o corpo ainda salva - uma interação com o fix do painel de metadata de mais cedo no mesmo dia). Próximo no conjunto do editor: o painel de imagem de destaque e o fluxo de commit, ambos ainda bloqueados atrás desse trabalho até agora.
Leitura relacionada
Transformando um seletor de imagem de destaque só do Pexels num registro de provedores (e a revisão que pegou uma falsificação de licença)
Uma refatoração de rotina no registro de provedores revelou um parâmetro sem validação que podia commitar uma imagem mal licenciada como legítima.
Três linhas de config, uma tarde de verificação
Descomentar três flags de SSL do Rails levou dez minutos. Ler o framework e testar dois achados de revisão plausíveis mas errados levou o resto do dia.
Consertando um redirect, e o caso extremo que uma linha deixou passar
Um fix de uma palavra no caminho de retorno do login que um scanner e uma revisão pegaram incompleto, cada um do seu jeito: requisições HEAD escapando, e uma URL guardada e nunca limpa.
Você também pode achar útil
Proton Pass
Gerenciador de senhas focado em privacidade, da equipe por trás do Proton Mail.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisProton VPN
VPN comercial com filtragem NetShield e interruptor de desligamento automático.
Como parceiro da Proton, ganho com compras qualificadas dos serviços de privacidade e segurança da Proton (Pass, Mail, VPN, Drive).
Saiba maisRackNerd VPS
Hospedagem VPS econômica para serviços leves que funcionam continuamente.
Como afiliado da RackNerd, ganho com compras qualificadas.
Saiba mais