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:
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.
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".
O ataque
Um nome é um campo de texto, então ninguém pensa nele como um lugar para colocar código. Digite isto nele:
<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çar | O que isso significa para o visitante |
|---|---|
| Cookies e armazenamento do navegador | A sessão dele pode ser copiada e usada em outro lugar |
| A própria página | Ela pode ser reescrita para dizer ou pedir qualquer coisa |
| Sua API, se passando por ele | As requisições saem já autenticadas |
| Navegação | Ele pode ser redirecionado para uma cópia convincente do seu site |
<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.
A correção
Uma propriedade:
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.
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.
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.
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.
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:
<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
onmouseoverdispara 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.
onloaddispara 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.

