Skip to content

Vaynerov Technologies

Não nos limitamos a desenvolver — conjuramos cada linha de código e cada píxel.

Todos os artigosHistórias de construção

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.

Edward AmirainFundador, Vaynerov Technologies
Publicado 12 min de leitura
Nesta página
  1. Oito céticos e uma regra
  2. Peça um: o bloco contentor que comeu a página
  3. Peça dois: o azul que voltou errado
  4. Peças três a sete: entrada hostil, e sair mais cedo
  5. Os defeitos silenciosos: ARIA, área de transferência, cinco línguas
  6. Quando a interface mente
  7. A correção que precisou de correção

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.

Trinta defeitos confirmados, uma marca cada — as chamadas nomeiam os tipos. Quase nenhum era gralha; quase todos eram código correto com uma suposição errada por baixo.

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.

DefeitoAntesDepois
Alvo de clique dos cartões do hub331 × 24 px (~7% do cartão)o cartão inteiro
Ida e volta OKLCH, pior de 140 mil cores47.99 / 2550.77 / 255
Tabela de 19 KB colada na pré-visualização de markdownstring HTML de 275 MBlimitada a 20 000 células
Reanálise por parêntese reto sem par14 s10 ms
Suite de XSS, 116 vetores18 falhas0
Medidor de palavra-passe com 7 caracteres (44.1 bits)«Razoável», por cima de um tempo de quebra de 15 minutosFraca, 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.03

44.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.