Atacámos a nossa caixa de ferramentas: 30 defeitos em 24 horas
A caixa de ferramentas saiu com vinte e cinco utilitários e todas as verificações verdes. Vinte e quatro horas depois, uma auditoria interna estruturada tinha confirmado trinta defeitos — incluindo um que tornava cerca de 93% do hub inclicável. Aqui vai a lista inteira, e porque é que publicá-la é o essencial.
Nesta página
Um commit, 161 ficheiros, 39 246 linhas: a caixa de ferramentas saiu inteira, vinte e cinco utilitários que fazem todo o trabalho no seu navegador. O TypeScript compilou. O ESLint ficou calado. A build de produção passou, treze testes ponta a ponta e nove testes de fumo estavam verdes, e a coisa parecia, no meu ecrã, acabada.
No dia seguinte apontei-lhe oito céticos e disse-lhes que a partissem. Confirmaram trinta defeitos. O pior estivera à frente da funcionalidade durante todo o tempo em que ela esteve no ar: três declarações CSS perdidas tinham encolhido a área clicável de todos os cartões do hub para uma tira de 331 × 24 píxeis — cerca de 7% do cartão. Perto de 93% da superfície a que um visitante realmente apontaria não fazia nada.
- 8
- passagens de revisão adversarial
- 30
- defeitos confirmados
- 37
- ficheiros corrigidos, +1853 / −309
- 116
- vetores de XSS na suite
A auditoria interna de /tools a 26 de julho, um dia depois do lançamento. Todos os números aqui vêm do commit de correção.
Oito céticos e uma regra
Uma revisão de código adversarial não é uma segunda opinião. Uma segunda opinião lê o seu código da maneira como o escreveu — do princípio ao fim, com simpatia, à procura de saber se fez aquilo que queria fazer. Um ataque parte do princípio de que fez exatamente aquilo que queria fazer e mesmo assim está errado: que a promessa da página não é cumprida, que a entrada que ninguém escreveria foi escrita, que o componente foi desmontado a meio de uma tarefa, que a frase está bem em inglês e é um disparate em alemão.
Por isso a superfície de /tools já lançada levou oito passagens separadas, cada uma à caça de uma classe diferente de falha em vez de varrer outra vez os mesmos ficheiros. Mas o mecanismo que deu valor ao exercício não foi o atacar.
Nenhum achado contava enquanto uma segunda execução independente não o reproduzisse. A suspeita é grátis; a reprodução é que é moeda.
Essa regra fez duas coisas. Matou os achados de som plausível que evaporam perante um cronómetro ou um depurador — os que me teriam custado um dia de reescrita defensiva para nada. E transformou os sobreviventes em algo sobre o qual podia agir sem discutir: o colapso da área de clique não era uma teoria sobre blocos contentores, era um retângulo medido numa janela de Chromium a correr. Trinta achados sobreviveram a essa fasquia. O commit de correção tocou em 37 ficheiros, +1853 linhas e −309.
Peça um: o bloco contentor que comeu a página
Todos os cartões do hub da caixa de ferramentas usam um link esticado: uma âncora à volta do nome do utilitário, mais um pseudoelemento ::after fixo aos quatro lados para que o cartão inteiro seja o alvo. É um padrão corrente, e depende de uma coisa — que o pseudoelemento resolva a sua posição contra o cartão e não contra alguma coisa mais pequena pelo meio.
Os dioramas do hub são 3D em CSS puro, portanto transform-style: preserve-3d é uma declaração legítima e frequente nessa folha de estilos. Três delas tinham aterrado nos elementos errados, e preserve-3d estabelece um bloco contentor para os descendentes posicionados de forma absoluta. O mais interior era o <h3> que carrega o nome do utilitário, portanto a sobreposição do tamanho do cartão passou discretamente a ter o tamanho do título: 331 píxeis de largura, 24 de altura.
Nada parecia errado. É essa a parte com que vale a pena ficar. O cartão desenhava-se ao píxel, o estado de passagem do rato disparava, o cursor continuava a virar ponteiro por cima do título, e todos os testes automáticos passavam — porque um teste clica num link pelo seu nome acessível, e o link em si estava de perfeita saúde. A falha só existia para quem apontasse ao meio de um cartão com um rato, ou seja: para todos os visitantes reais e para nenhum dos meus instrumentos.
Estava tudo com bom aspeto. Olhar é o instrumento mais fraco que tenho, e é o primeiro a que pego.
A correção foi apagar três declarações. O que entrou ao lado delas foi um comentário de dezasseis linhas a explicar exatamente porque é que nada naquela subárvore pode criar um bloco contentor — porque a próxima pessoa a acrescentar ali um floreado 3D vou ser eu, daqui a seis meses, sem memória nenhuma disto.
Peça dois: o azul que voltou errado
O conversor de cores aceita HEX, RGB, HSL e OKLCH e mostra-lhe as quatro. Dê-lhe azul puro, #0000ff, e devolvia #0031e5 — 48 de 255 fora num único canal, o que não é um artefacto de arredondamento, é outra cor. O OKLCH descreve um espaço percetual maior do que o sRGB, portanto uma cor convertida cai rotineiramente fora da caixa que o ecrã consegue mostrar, e alguma coisa tem de a trazer de volta. A nossa trazia-a de volta mal.
A reparação óbvia é limitar cada canal ao intervalo, ou escolher a cor apresentável mais próxima e seguir em frente. Não fizemos nem uma coisa nem outra, porque uma varredura mostrou que o atalho era pior do que parecia: a escolha da mais próxima, sem proteção, teria transformado 76% dos resultados fora de gamute em corte visível de canal. Em vez disso, o conversor implementa agora o mapeamento de gamute do CSS Color 4 §13 — reduzir a croma por pesquisa binária, mas parar mais cedo quando o resultado cortado fica dentro de uma diferença apenas percetível do candidato (clipDelta ≤ 0.02), que é onde a honestidade percetual e os píxeis apresentáveis concordam.
Uma varredura de 140 000 cores antes e depois diz se valeu a pena escrever aquilo: o pior erro de ida e volta caiu de 47.99/255 para 0.77/255. Menos de uma unidade num canal de um byte é uma cor que ninguém vê mexer.
Peças três a sete: entrada hostil, e sair mais cedo
A pré-visualização de markdown é o utilitário mais exposto a entradas que não pediu, e foi a que levou pior nesta ronda. Uma tabela de 19 KB colada lá dentro produzia uma string HTML de 275 MB — construção quadrática a encontrar um documento que é só delimitadores. Um [ patológico sem par desencadeava uma reanálise que corria em O(n²): catorze segundos de separador congelado, agora dez milissegundos. As citações em bloco aninhadas recorriam sem chão, portanto um documento suficientemente fundo podia esgotar a pilha por completo.
Nenhuma destas é, por si só, uma falha de segurança — isto é um renderizador sem acesso à rede —, mas têm a mesma forma que uma teria, e a mesma passagem correu uma suite de 116 vetores de XSS contra o sanitizador. Dezoito vetores passavam antes da correção. Zero depois. O utilitário corre agora com orçamentos explícitos: um orçamento de delimitadores, um orçamento de varrimento de links cobrado apenas em caso de falha, 20 000 células de tabela, 32 níveis de profundidade de blocos.
| Defeito | Antes | Depois |
|---|---|---|
| Alvo de clique dos cartões do hub | 331 × 24 px (~7% do cartão) | o cartão inteiro |
| Ida e volta OKLCH, pior de 140 mil cores | 47.99 / 255 | 0.77 / 255 |
| Tabela de 19 KB colada na pré-visualização de markdown | string HTML de 275 MB | limitada a 20 000 células |
| Reanálise por parêntese reto sem par | 14 s | 10 ms |
| Suite de XSS, 116 vetores | 18 falhas | 0 |
| Medidor de palavra-passe com 7 caracteres (44.1 bits) | «Razoável», por cima de um tempo de quebra de 15 minutos | Fraca, a partir de um teto derivado de 46.03 bits |
Seis dos trinta, com números agarrados. Cada um foi reproduzido de forma independente antes de ser aceite como defeito.
A outra metade da entrada hostil é o momento hostil — o utilizador que se vai embora. Três utilitários (gzip, ZIP, regex) deixavam Web Workers a fugir ao desmontar e continuavam alegremente a moer para uma página que ninguém estava a ver. A causa é um idioma de React que já escrevi mal antes e provavelmente voltarei a escrever mal: o efeito de limpeza capturava os objetos de ref em vez de ler .current no momento do desmonte, portanto desmontava um handle obsoleto e deixava o worker vivo a correr.
Na mesma família: o construtor de ZIP impunha o seu teto de 4 GiB de DEFLATE depois de comprimir tudo, portanto um lote grande de mais fazia o trabalho caro inteiro e só então abortava — a verificação tem de vir antes do trabalho, o que é óbvio em retrospetiva e era invisível na revisão de código porque a proteção existia e até estava correta, apenas chegava tarde.
Os defeitos silenciosos: ARIA, área de transferência, cinco línguas
Quatro utilitários tinham um controlo de saltar-para-o-erro escrito como <button role="alert">. Esse único atributo faz duas coisas más ao mesmo tempo: sobrepõe-se ao papel de botão, portanto a tecnologia de apoio deixa de anunciar a coisa como premível e, como o nome do elemento vinha do contexto e não de texto, o controlo era anunciado como absolutamente nada. Um botão sem etiqueta e sem botão é pior do que não haver controlo. O papel mudou-se para o invólucro, onde o anúncio pertence, e o botão voltou a ser um botão.
Copiar para a área de transferência falhava em silêncio sempre que o navegador dizia que não. Agora tinge o controlo e anuncia através de uma região dinâmica — mantida montada e vazia desde o início, porque uma região dinâmica inserida no mesmo instante em que ganha conteúdo é anunciada de forma inconsistente pelos vários leitores de ecrã. Limpa-se na tentativa seguinte e não a temporizador, para que um leitor lento nunca perca a mensagem a meio da frase.
Depois a localização, que é onde uma auditoria interna se paga a si própria, porque não consigo rever cinco das nossas seis línguas. A faixa de utilitários da página inicial mostrava os nomes ingleses em todas as línguas — uma alternativa de recurso que nunca tinha sido notada porque toda a gente que olhou para aquilo lia inglês. O parser de cron, que explica um agendamento por palavras, estava agramatical em alemão, espanhol e português. O francês exigia uma alteração de código a sério e foi deliberadamente adiado para o seu próprio commit em vez de contrabandeado para dentro de uma pilha de correções: os intervalos passam a construir-se por funções dedicadas de cláusula para o dia da semana e para o mês, portanto o alemão lê von Montag bis Freitag em vez do simplesmente errado am Montag bis Freitag. O resultado em inglês e em russo voltou idêntico ao byte, que foi como soube que a refatoração só tinha tocado naquilo que dizia tocar.
Quando a interface mente
Três defeitos pertencem ao mesmo grupo, porque em cada um deles o produto dizia ao utilizador uma coisa falsa com total confiança.
O gerador de palavras-passe classificava uma palavra-passe de sete caracteres — 44.1 bits de entropia — como «Razoável», mesmo por cima da sua própria estimativa de que a palavra-passe cairia em quinze minutos. As duas linhas estavam calculadas corretamente. Só vinham de sítios diferentes: o tempo de quebra de uma constante real, a etiqueta da faixa de um limiar que alguém tinha escrito à mão. Por isso o limiar deixou de ser escrito à mão.
// 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 está agora abaixo do teto, portanto o crachá lê-se Fraca — e o crachá move-se automaticamente se a suposição sobre o hardware alguma vez mudar.
O conversor de datas tinha duas mentiras de arredondamento suas. Uma duração de 59.999 segundos aparecia como «dentro de 60 segundos», que é uma frase que nunca devia existir. E uma época com zeros à esquerda, como 0001753372800, era promovida a milissegundos porque a unidade era inferida da contagem de dígitos e não da magnitude — treze caracteres, logo milissegundos, logo uma data em 1970. A deteção de unidade testa agora o valor contra 1e11. Entretanto, o utilitário de UUID rebentava à cabeça numa origem insegura, porque crypto.randomUUID é [SecureContext] e uma chamada nua não é uma degradação graciosa, é um stack trace.
A terceira mentira foi a que doeu. Os links de partilha do gerador de gradientes estavam partidos desde o instante em que a funcionalidade saiu. Uma instância do editor anterior à hidratação semeava-se com o estado por omissão e disparava o seu replaceState com atraso antes de a instância viva ter lido o hash — portanto todos os gradientes que alguém partilhasse revertiam para o gradiente por omissão cerca de 400 ms depois de carregarem. O link funcionava. Só se apagava a si próprio à sua frente. O teste de regressão para isto tem controlo negativo: foi corrido primeiro contra o código antigo e confirmou-se que falhava, porque um teste verde que teria ficado verde na mesma não é um teste.
A correção que precisou de correção
O meu achado preferido de todo o exercício veio de uma segunda passagem sobre a primeira ronda de reparações, e é a razão pela qual deixei de tratar «corrigido» como um estado.
O orçamento de delimitadores do markdown limitava o trabalho a 5000 — de nós. Mas o tokenizador dobra sequências de caracteres de ênfase num único nó, portanto 20 000 asteriscos são um punhado de nós, passam a direito pelo teto e rebentam a pilha exatamente como antes. A proteção era real, o número era razoável, e estava a contar o substantivo errado. Passa a contar caracteres, com cerca de 1.7× de folga medida sobre o pior caso que conseguimos construir.
Mais dois dessa passagem. O auxiliar de descarga de múltiplos ficheiros ruía quando o gzip lhe entregava um blob indefinido — um caminho de erro que ninguém exercita até ao dia em que toda a gente o exercita. E o modo Wi-Fi do gerador de códigos QR teve a sua promessa de privacidade provada de novo, de raiz: 900 combinações de definições, a verificar que a chave pré-partilhada não pode escapar para um URL partilhável em nenhuma delas. Não podia. Quis a prova à mesma, porque «nada do que escreve aqui sai do seu dispositivo» é a promessa sobre a qual a caixa de ferramentas inteira está construída, e uma promessa que só testou uma vez é uma promessa sobre a qual está a adivinhar.
O fecho honesto é este: trinta é o número que confirmámos, não o número que existia. Uma auditoria interna encontra os defeitos que os seus atacantes se lembraram de procurar, e agora tenho um mapa bastante preciso daquilo em que oito passagens são boas — geometria CSS, idas e voltas numéricas, colagens hostis, ciclo de vida, semântica ARIA, gramática fora do inglês — e mapa nenhum daquilo que lhes escapou. A qualidade não é um estado a que se chega no dia do lançamento. É o ritmo a que encontra os seus próprios erros antes de outra pessoa ter de os encontrar.
Que é também a razão pela qual este texto existe. Publicar uma lista de defeitos é uma decisão de marketing estranha e uma decisão de engenharia simples: é o mesmo argumento que fiz em o ofício na era do código gerado, o de que, quando escrever código fica barato, revê-lo passa a ser o trabalho todo. Se quiser a arquitetura em que estes trinta defeitos foram encontrados — o RPC dos workers, o streaming, a garantia de zero carregamentos —, está em vinte e cinco utilitários, zero carregamentos.