Como o XSS acontece através de JSON
Resumo: JSON nunca executa sozinho. O XSS acontece no momento em que você o trata como HTML — concatenação de strings no servidor, innerHTML no frontend ou inclusão de JSON direto em uma tag script.

Resumo da ópera: JSON nunca executa sozinho — feche primeiro as três rotas de saída: faça escape no servidor conforme o contexto, use textContent em vez de innerHTML e substitua < por \u003c ao embutir JSON.
{"name": "<img src=x onerror=alert(1)>"} é dado válido. Se é perigoso depende inteiramente de onde você o coloca.
Porta um: montagem de strings no servidor
res.send(`<div>Hello, ${data.name}</div>`);
O navegador analisa as tags dentro de data.name. O JSON aqui é apenas o contêiner; a falha é escapar para o contexto errado.
Correção: use o escape padrão de um engine de templates (<%= %> em EJS, {{ }} em Vue e Django). Evite <%- %>, v-html e |safe para qualquer coisa controlada pelo usuário.
Porta dois: innerHTML
el.innerHTML = user.bio; // analisa marcação
el.textContent = user.bio; // insere texto
O {user.bio} do React e o {{ user.bio }} do Vue criam nós de texto e são seguros por padrão. dangerouslySetInnerHTML e v-html são opt-outs explícitos.
Se você realmente precisa de rich text, sanitize com uma lista de permissões (DOMPurify e similares) em vez de confiar no backend.
Porta três: inclusão em uma tag script
<script>
window.__DATA__ = {"name": "x</script><script>alert(1)</script>"};
</script>
Um </script> dentro dos dados fecha o bloco antes do fim e tudo depois disso roda como um novo script. Este é o caso mais esquecido.
JSON.stringify(data)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026');
Ainda é JSON válido, idêntico após a análise, e não consegue mais fechar a tag. Next.js e Nuxt fazem o mesmo para __NEXT_DATA__ e __NUXT__.
Dois vizinhos que vale conhecer
JSON hijacking — navegadores antigos executavam JSON carregado via <script src>; redefinir Array vazava o conteúdo. Navegadores modernos corrigiram isso, e Content-Type: application/json mais X-Content-Type-Options: nosniff fecham o resto.
Resquícios de JSONP — um nome de callback controlado pelo usuário é execução de código arbitrário por design. Não o use em sistemas novos.
Lista de verificação
- Todo lugar em que um campo JSON chega a HTML é escapado conforme o contexto (HTML, atributo, JS, URL)
- Nenhum
innerHTML,v-htmloudangerouslySetInnerHTMLconsumindo strings de API - JSON embutido escapa
<,>e& - Rich text passa por sanitização com lista de permissões, não por filtro de lista negra
- As respostas trazem
Content-Type: application/jsonenosniff
Perguntas frequentes
Por que JSON válido ainda pode disparar XSS?
O JSON é apenas texto e nunca executa; ser válido só significa que ele é analisável. O perigo aparece quando ele é escrito em um contexto HTML ou de script: o navegador analisa as tags, ou um </script> fecha o bloco antes do fim e o restante roda como código. A falha é a ausência de escape sensível ao contexto na saída, não o formato dos dados.
Devo usar textContent ou innerHTML para renderizar campos de API?
Prefira textContent: ele insere o valor como texto simples, então as tags são exibidas literalmente. innerHTML analisa marcação, então um campo de API contendo <img onerror=...> executa. O {user.bio} do React e o {{ user.bio }} do Vue criam nós de texto por padrão; dangerouslySetInnerHTML e v-html são opt-outs explícitos.
Como devo escapar JSON embutido em uma tag <script>?
Sim. Um </script> dentro dos dados fecha o bloco antes do fim e tudo depois disso roda como um novo script. Após serializar, substitua <, > e & por \u003c, \u003e e \u0026: o resultado ainda é JSON válido, é analisado de forma idêntica e não consegue mais fechar a tag.