Skip to content
This page has been auto-translated and may contain errors.View in English

Cross-site scripting

Você se cadastra, e a página te cumprimenta. O formulário de cadastro pega um nome, envia para o servidor, e o servidor devolve esse nome para que a página possa te saudar com ele.

Essa ida e volta é o bug inteiro.

Um texto que veio de um visitante se torna perigoso no momento em que uma página o trata como marcação.

O bug

Aqui está a função que exibe a saudação:

Vulnerable
ts
type RegisterResponse = { user: { name: string } }

function displaySuccess(response: RegisterResponse) {
  successBox.innerHTML = `Welcome, ${response.user.name}`
}

innerHTML significa "interprete esta string como HTML". O navegador lê a string, constrói os elementos que ela descreve e ativa qualquer comportamento que esses elementos peçam.

Para o nome Priya, isso é inofensivo. O navegador cria um nó de texto e segue em frente.

Para um nome que contém uma tag, o navegador cria essa tag em vez disso. Ele não tem como saber que essa marcação em particular veio do teclado de um estranho, porque no momento em que chega ao parser, ela já faz parte da sua página.

Isso é cross-site scripting, geralmente escrito como XSS: script injetado em um site em que a vítima confia, executando no navegador da vítima. Existe desde o final dos anos 1990 e ainda aparece o tempo todo, porque os ingredientes são banais. Pegue uma entrada, coloque em uma página.

JunoO bug O navegador não está sendo descuidado aqui. Ele está fazendo exatamente o que foi mandado: innerHTML é um pedido para tratar uma string como estrutura da página, e é isso que ele faz.

O problema é que a string veio de alguém que você nunca conheceu, e nada no meio do caminho avisou "essa parte é só texto".

JunoO bug A propriedade que está sendo atribuída é a descoberta inteira. Quando você revisa código de front end, procure por innerHTML, outerHTML, insertAdjacentHTML e document.write, e depois pergunte de onde veio cada valor.

Esses pontos são chamados de sinks: os lugares onde um valor deixa de ser dado e passa a ser interpretado. Um valor só é seguro em relação ao sink em que ele cai, então o mesmo nome pode ser inofensivo em uma linha e perigoso na linha seguinte.

JunoO bug Vale a pena nomear as três variantes, porque cada uma exige uma correção diferente. Esta é XSS refletido: o valor vai até o servidor e volta direto na resposta. XSS armazenado é o mesmo bug com paciência, salvo uma vez e servido para todos que carregarem a página depois.

XSS baseado em DOM nunca envolve o servidor. O DOM é o modelo de objetos vivo que o navegador mantém da página, e essa variante é um script que o reescreve usando um valor que a própria página leu.

Esse payload pode viver no fragmento da URL depois do hash, que os navegadores não enviam para o servidor.

Esse último ponto importa para como você procura esses casos. Logs do servidor não mostram um payload de XSS baseado em DOM, porque o servidor nunca o recebeu.

Se a revisão de logs é como você caça essa classe de bug, existe um terço dela para o qual você está estruturalmente cego.

O ataque

Um nome é um campo de texto, então ninguém pensa nele como um lugar para colocar código. Digite isto nele:

html
<img src="x" onerror="alert('XSS successful')">

Envie o formulário. O servidor armazena o valor e o devolve. displaySuccess passa esse valor para innerHTML, o navegador cria um elemento <img> de verdade, tenta carregar uma imagem chamada x, falha e executa o manipulador onerror.

Nenhuma tag <script> em lugar nenhum. É essa a parte que surpreende as pessoas: bloquear a palavra script não impede quase nada, porque o HTML tem dezenas de atributos que executam código quando algo comum acontece.

Execute isso apenas na sua própria build

Os payloads aqui existem para que você reconheça esse bug em código pelo qual você é responsável. Um payload armazenado não fica só com você: ele dispara no navegador de quem quer que carregue a página em seguida.

Isso exclui qualquer lugar com visitantes reais. Use apenas seu próprio projeto, ou um sistema para o qual você tenha permissão por escrito para testar.

O script roda com a origem da página, então ele pode fazer o que quer que o seu próprio JavaScript pudesse fazer:

O que o script consegue alcançarO que isso significa para o visitante
Cookies e armazenamento do navegadorA sessão dele pode ser copiada e usada em outro lugar
A própria páginaEla pode ser reescrita para dizer ou pedir qualquer coisa
Sua API, se passando por eleAs requisições saem já autenticadas
NavegaçãoEle pode ser redirecionado para uma cópia convincente do seu site
JunoO ataque A palavra "script" dá a impressão de que é preciso de uma tag <script>. Não é preciso.

Uma imagem que falha ao carregar, um elemento por onde o mouse passa, um SVG que termina de carregar: cada um desses pode carregar uma instrução. Bloquear uma palavra-chave deixa todo o resto passar.

JunoO ataque "Mesma origem" é o ponto em que vale a pena se deter. O script roda como se fosse o seu app, então toda proteção construída sobre a confiança no seu próprio front end desaparece naquele momento.

Uma consequência prática: HttpOnly em um cookie de sessão impede que o script o leia. Isso não impede em nada que o script envie requisições às quais o navegador anexa esse cookie de qualquer jeito. É um bom controle, mas mais limitado do que parece.

JunoO ataque O motivo pelo qual listas de bloqueio perdem essa batalha é que a superfície de ataque é a especificação do HTML, não uma lista de palavras. Só os atributos de manipuladores de evento já somam dezenas, e continuam surgindo novos.

É por isso que a Content Security Policy, um cabeçalho de resposta que diz ao navegador quais fontes de script ele pode executar, vale a pena ter mesmo depois que seu escaping já está correto. É uma segunda camada para o dia em que um caminho de saída passar despercebido.

A versão que realmente ajuda é baseada em nonce ou em hash. Uma política que contém unsafe-inline permite exatamente o manipulador inline deste ataque, que é a forma mais comum de uma política acabar sendo puramente decorativa.

Não recorra a ela em vez de corrigir o sink. Recorra a ela porque, eventualmente, você vai deixar passar um sink.

A correção

Uma propriedade:

Fixed
ts
function displaySuccess(response: RegisterResponse) {
  successBox.textContent = `Welcome, ${response.user.name}`
}

textContent define texto. Não uma marcação que talvez seja texto, texto de verdade. O mesmo payload agora aparece na página como os caracteres literais <img src="x" onerror="alert('XSS successful')">, visíveis e inertes.

JunoA correção Repare no que a correção não faz. Ela não inspeciona o nome, não remove nada dele, nem decide se ele parece suspeito.

Ela muda o que foi pedido ao navegador para fazer com o valor. O valor continua o mesmo, e ele é seguro porque nunca chegaria a ser executado.

JunoA correção Use textContent por padrão e trate todo innerHTML como algo que precisa de uma justificativa. Na maioria das vezes, ele está ali porque alguém queria uma quebra de linha ou uma palavra em negrito, algo que um pequeno elemento criado com createElement resolve sem abrir brecha nenhuma.

Quando a marcação realmente precisa vir da entrada do usuário — um texto formatado em um comentário, por exemplo —, isso é trabalho de um sanitizador. Um mantido de verdade, como o DOMPurify, nunca uma expressão regular que você mesmo escreveu, porque o que você está analisando é HTML, e HTML é muito mais estranho do que parece.

JunoA correçãotextContent é o escaping correto para um contexto: texto HTML. Contextos diferentes não compartilham a mesma resposta. O mesmo valor colocado em um atributo precisa de codificação de atributo, em uma URL precisa de codificação de URL, em um bloco <script> precisa de codificação de string JavaScript, e em CSS precisa da sua própria codificação, de novo diferente.

A falha clássica é um valor escapado uma vez, na entrada, e depois reutilizado em outro lugar com regras diferentes. É por isso que o escaping pertence à saída, onde o destino é conhecido, e a validação pertence à entrada, onde você decide se aceita o valor ou não.

Os frameworks modernos escapam a interpolação de texto para você, o que elimina a maior parte disso. Mas cada um também mantém uma válvula de escape, dangerouslySetInnerHTML no React e v-html no Vue, e esses nomes já são o aviso completo. Procure por eles primeiro em qualquer revisão.

Por que a correção funciona

O navegador precisa de uma de duas instruções para qualquer valor: trate isso como estrutura, ou trate isso como texto. innerHTML dá a primeira, textContent dá a segunda. Mesma string, instrução diferente, resultado diferente.

A correção aqui está no front end, o que resolve esta página e apenas esta página. O valor continua armazenado sem tratamento no servidor, e é no armazenamento que ele espera:

  • Um painel de administração listando cadastros recentes.
  • Um modelo de e-mail que saúda o usuário pelo nome.
  • Um relatório exportado para outra pessoa abrir.
  • O cliente de outra equipe chamando a mesma API.

Cada um desses é um destino diferente, com regras diferentes, e nenhum deles sabe o que esta página em particular decidiu fazer. Então o servidor também não pode confiar no valor, e é aí que a validação de schema entra em cena mais adiante: recusar um valor absurdo na entrada reduz o que qualquer destino precisa sobreviver.

JunoPor que a correção funciona Corrigir uma página não torna o valor seguro em geral. Torna o valor seguro ali.

O mesmo nome continua guardado no armazenamento, esperando a próxima tela que o exiba, e essa tela vai ter que tomar sua própria decisão.

JunoPor que a correção funciona Quando você encontrar um caso desses, resista à tentação de corrigir só a linha citada no relatório de bug. Procure todos os lugares onde esse campo é renderizado, porque um payload armazenado dispara em qualquer lugar onde caia, e as ferramentas administrativas costumam ser a superfície menos revisada de toda a base de código.

Páginas de administração também são o pior lugar para isso disparar, já que a sessão que ele toma emprestada tem a maior autoridade.

JunoPor que a correção funciona É por isso que "higienizar na entrada" continua falhando como estratégia. Isso embute as suposições de um destino específico nos dados armazenados, corrompe valores que eram legítimos e deixa você com uma tabela cheia de strings parcialmente codificadas, que ninguém consegue reverter com segurança assim que aparece um segundo consumidor.

A divisão que se sustenta é: valide na fronteira, porque você está decidindo se aceita o valor; escape na saída, porque só quem renderiza sabe para onde o valor vai. O guia de prevenção da OWASP é a referência que vale manter aberta, e ele é organizado por contexto de saída exatamente por esse motivo.

Experimente

Três valores, cada um digitado no campo de nome de uma página que ainda usa innerHTML. Descubra quais deles disparam, e o que faz cada um disparar:

html
<div onmouseover="alert('one')">hover me</div>
<svg onload="alert('two')"></svg>
<iframe src="javascript:alert('three')"></iframe>
Compare suas respostas

Os três disparam, e nenhum dos dois precisa da mesma coisa do visitante.

  • A div espera. Ela é renderizada como texto comum dizendo "hover me", e onmouseover dispara quando o ponteiro passa por cima dela. Nada acontece até alguém mover o mouse, e é por isso que um payload pode parecer inofensivo em uma captura de tela.
  • O svg não espera. onload dispara assim que o elemento termina de ser analisado, então ele executa no exato momento em que a saudação é renderizada.
  • O iframe depende do navegador. O esquema de URL javascript: é bloqueado nos navegadores atuais para navegação dentro de frames, então esse é o mais provável de não fazer nada — e é por isso que um payload que falha prova muito pouco. Um navegador diferente, um mais antigo, ou um sink ligeiramente diferente pode mudar a resposta.

Troque o sink para textContent e os três voltam a ser o que sempre foram: texto estranho dentro de uma saudação.

Para onde isso vai a seguir

Cada um desses payloads é uma string curta. Eles causam dano por serem interpretados, não por serem grandes.

Negação de serviço inverte essa lógica, com uma entrada que nunca é interpretada como nada e causa problema puramente pela quantidade dela.