Lista de verificação de riscos de segurança no processamento JSON
Resumo: O `JSON.parse` em si é seguro; o perigo está no que acontece depois dele: merge profundo, atribuição por caminho, embutir em HTML, profundidade e tamanho ilimitados.

Resumo da ópera: feche primeiro as duas portas de entrada — todo merge profundo e toda atribuição por caminho devem rejeitar __proto__, prototype e constructor, e o tamanho do corpo da requisição mais a profundidade de aninhamento precisam de limites rígidos que valham antes da análise. Vigiar o JSON.parse em si deixa as portas perigosas escancaradas.
O atacante não precisa inventar uma nova vulnerabilidade; basta adicionar mais uma chave ao JSON.
1. Prototype pollution: o mais comum e o mais negligenciado
1.1 O JSON.parse em si é seguro
const data = JSON.parse('{"__proto__": {"polluted": true}}');
console.log(data.polluted); // undefined
console.log({}.polluted); // undefined
JSON.parse cria __proto__ como uma propriedade própria comum e não reescreve a cadeia de protótipos. Então a afirmação de que "JSON.parse causa prototype pollution" é imprecisa.
1.2 O merge que vem depois é o que é perigoso
/**
* Merge profundo inseguro: grava as chaves do objeto de origem direto no objeto alvo.
* @param target Objeto alvo
* @param source Objeto de origem (pode vir de JSON do usuário)
* @returns Objeto mesclado
*/
function unsafeMerge(target, source) {
for (const key of Object.keys(source)) {
if (typeof source[key] === 'object' && source[key] !== null) {
target[key] = target[key] ?? {};
unsafeMerge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
unsafeMerge({}, JSON.parse('{"__proto__": {"isAdmin": true}}'));
console.log({}.isAdmin); // true — o site inteiro foi poluído
constructor.prototype é outra porta de entrada do mesmo tipo:
{ "constructor": { "prototype": { "isAdmin": true } } }
1.3 Como corrigir
/** Nomes de chave que precisam ser rejeitados */
const FORBIDDEN_KEYS = new Set(['__proto__', 'prototype', 'constructor']);
/**
* Merge profundo seguro: rejeita chaves relacionadas a protótipo e trata apenas propriedades próprias.
* @param target Objeto alvo
* @param source Objeto de origem
* @returns Novo objeto mesclado
*/
function safeMerge(target, source) {
const result = { ...target };
for (const key of Object.keys(source)) {
if (FORBIDDEN_KEYS.has(key)) {
continue;
}
const sourceValue = source[key];
const isPlainObject =
typeof sourceValue === 'object' && sourceValue !== null && !Array.isArray(sourceValue);
if (isPlainObject) {
result[key] = safeMerge(target[key] ?? {}, sourceValue);
continue;
}
// Use defineProperty para que um setter não possa disparar mutação de protótipo
Object.defineProperty(result, key, {
value: sourceValue,
enumerable: true,
writable: true,
configurable: true,
});
}
return result;
}
Uma abordagem mais minuciosa é usar Object.create(null) como contêiner (não há cadeia de protótipos a poluir), ou simplesmente um Map.
Ao usar uma biblioteca pronta, confirme que ela já trata essas três chaves — muitas versões antigas do merge do lodash foram reportadas exatamente por isso.
1.4 Atribuição por caminho é igualmente perigosa
// Antipadrão: o caminho vem da entrada do usuário
for (const segment of pathSegments) {
current = current[segment] ?? (current[segment] = {});
}
Quando __proto__ aparece em pathSegments, isso é equivalente à poluição acima. Implementações de JSON Pointer / JSON Patch precisam validar cada segmento do token.
2. Ataques de esgotamento de recursos
| Ataque | Tática | Mitigação |
|---|---|---|
| Aninhamento profundo | Cem mil níveis de [[[[...]]]] |
Limite a profundidade de aninhamento (normalmente 32–64) e faça uma pré-varredura antes da análise |
| Arrays desproporcionais | Um array com dezenas de milhões de elementos | Limite o tamanho do corpo da requisição e o comprimento do array |
| Strings extremamente longas | Uma única string de centenas de MB | Limite o comprimento de campo |
| Explosão de chaves duplicadas | Um número enorme de chaves duplicadas | Limite o número de chaves de objeto |
As duas defesas mais eficazes no servidor são limites de tamanho do corpo da requisição e limites de profundidade, e ambos precisam entrar em vigor antes que qualquer análise aconteça.
/**
* Verifica a profundidade de aninhamento de forma barata antes da análise para evitar estouro de pilha durante a análise.
* @param text Texto JSON
* @param maxDepth Profundidade máxima permitida
* @returns Se a profundidade está dentro da faixa permitida
*/
function isDepthAcceptable(text: string, maxDepth = 64): boolean {
let currentDepth = 0;
let isInsideString = false;
let isEscaped = false;
for (const character of text) {
if (isInsideString) {
if (isEscaped) {
isEscaped = false;
} else if (character === '\\') {
isEscaped = true;
} else if (character === '"') {
isInsideString = false;
}
continue;
}
if (character === '"') {
isInsideString = true;
} else if (character === '{' || character === '[') {
currentDepth += 1;
if (currentDepth > maxDepth) {
return false;
}
} else if (character === '}' || character === ']') {
currentDepth -= 1;
}
}
return true;
}
3. Injeção ao embutir em HTML
Gravar JSON diretamente em uma página é uma prática comum, mas introduz três classes de problemas:
<script>
window.__DATA__ = {"title": "</script><script>alert(1)</script>"};
</script>
O </script> dentro da string fecha o bloco de script antes do fim, e o resto é analisado como HTML — um XSS clássico.
Mais dois caracteres se comportam do mesmo jeito: \u2028 (separador de linha) e \u2029 (separador de parágrafo) são legais em JSON, mas embuti-los no código-fonte JS dispara um erro de sintaxe.
Correção:
/**
* Serializa JSON com segurança para um script inline.
* @param value Objeto de dados
* @returns String que pode ser colocada com segurança dentro de uma tag script
*/
function serializeForInlineScript(value: unknown): string {
return JSON.stringify(value)
.replace(/</g, '\\u003c')
.replace(/>/g, '\\u003e')
.replace(/&/g, '\\u0026')
.replace(/\u2028/g, '\\u2028')
.replace(/\u2029/g, '\\u2029');
}
Uma abordagem mais robusta é colocar os dados dentro de <script type="application/json"> (cujo conteúdo não é executado) e depois lê-los com JSON.parse(document.getElementById('data').textContent).
4. JSON hijacking e JSONP
Navegadores antigos permitiam que um <script src="https://api.example.com/user"> cross-origin executasse diretamente o JavaScript retornado,
e JSON cujo nível superior é um array por acaso também é uma expressão JS válida, então um atacante podia ler os dados sobrescrevendo o construtor Array.
Navegadores modernos já têm proteções, mas ainda recomendamos:
- Use um objeto em vez de um array no nível superior da API (
{"data": [...]}); - Envie
X-Content-Type-Options: nosniffe definaContent-Type: application/jsonestritamente; - Pare de usar JSONP; use CORS quando o acesso cross-origin for realmente necessário.
5. Confusão de tipos ao desserializar em objetos
Uma vez habilitada a tipagem padrão no Jackson (enableDefaultTyping), um atacante pode especificar um campo @class para fazer o servidor instanciar uma classe arbitrária,
e combiná-la com uma cadeia de gadgets explorável para obter execução remota de código. Vulnerabilidades desse tipo apareceram muitas vezes (tanto Fastjson quanto Jackson foram atingidos).
Princípio: o tipo alvo da desserialização precisa ser estaticamente especificado pelo código do servidor e nunca determinado pelo conteúdo do payload.
6. Negação de serviço em expressões regulares e schemas
- O
patterndo JSON Schema e omatch()do JSONPath aceitam expressões regulares; - Padrões como
(a+)+$fazem backtracking exponencial em entradas longas; - Se a regex vem dos usuários (regras de validação customizadas), você precisa limitar seu comprimento, desproibir backreferences e quantificadores aninhados, ou mudar para um engine linear como o RE2.
7. Logging e mascaramento
Logs JSON são a forma mais fácil de vazar informações sensíveis:
/** Nomes de campo que precisam ser mascarados nos logs (comparados em minúsculas) */
const SENSITIVE_KEYS = new Set(['password', 'token', 'secret', 'idcard', 'phone', 'authorization']);
/**
* Mascara recursivamente campos sensíveis em um objeto para saída de log.
* @param value Valor original
* @returns Cópia mascarada
*/
function maskSensitive(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => maskSensitive(item));
}
if (typeof value === 'object' && value !== null) {
return Object.fromEntries(
Object.entries(value).map(([entryKey, entryValue]) => {
if (SENSITIVE_KEYS.has(entryKey.toLowerCase())) {
return [entryKey, '***'];
}
return [entryKey, maskSensitive(entryValue)];
}),
);
}
return value;
}
Observe que o mascaramento precisa acontecer antes de o log ser gravado, não limpando a plataforma de logging depois.
8. Lista de verificação antes do lançamento
- [ ] Todas as funções de merge profundo rejeitam
__proto__/prototype/constructor; - [ ] Os tokens de caminho do JSON Pointer / Patch são validados contra uma whitelist;
- [ ] O tamanho do corpo da requisição e a profundidade de aninhamento têm limites rígidos, e esses limites valem antes da análise;
- [ ] JSON embutido em HTML escapa
<,>,&,U+2028eU+2029; - [ ] As APIs usam um objeto no nível superior e as respostas enviam
nosniff; - [ ] Os tipos alvo de desserialização são estaticamente especificados no código;
- [ ] Expressões regulares controladas pelo usuário têm limites de complexidade;
- [ ] Os campos de log têm regras de mascaramento e essas regras são testadas.
Perguntas frequentes
Como verifico se meu merge profundo é vulnerável a prototype pollution?
Passe JSON.parse('{"__proto__": {"isAdmin": true}}') pela função de merge e depois verifique se {}.isAdmin virou true; repita com um payload constructor.prototype. Com uma biblioteca de terceiros, confirme que ela rejeita __proto__, prototype e constructor — muitas versões antigas do merge do lodash foram reportadas exatamente por isso. A atribuição baseada em caminho precisa de sua própria verificação, porque um token __proto__ no caminho polui da mesma forma.
Por que o JSON.parse em si não é a origem do prototype pollution?
Ele cria __proto__ como uma propriedade própria comum e nunca reescreve a cadeia de protótipos, então tanto data.polluted quanto {}.polluted permanecem undefined. A poluição acontece depois: um merge profundo inseguro grava as chaves do objeto de origem direto no alvo, e constructor.prototype é uma porta de entrada do mesmo tipo. Tratar o JSON.parse como causa raiz significa corrigir o lugar errado.
JSON embutido em HTML deve sempre ser escapado?
Sim. Um </script> dentro de uma string fecha o bloco de script antes do fim e o resto é analisado como HTML — um XSS clássico; <, >, &, U+2028 e U+2029 também precisam de escape. Uma opção mais segura é colocar os dados dentro de <script type="application/json">, cujo conteúdo não é executado, e lê-los com JSON.parse no textContent.