JSON Toolkit

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.

Intermediário~12 min de leituraPublicado em 2026-10-30Atualizado em 2026-10-30
Capa de Lista de verificação de riscos de segurança no processamento JSON

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: nosniff e defina Content-Type: application/json estritamente;
  • 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 pattern do JSON Schema e o match() 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+2028 e U+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.