Executando verificação de segurança...
9

Segurança: Poluição de Protótipos em Objetos Congelados (JS) 🔐

❄️ Object.freeze

O Object.freeze é usado para impedir modificações em objetos, tornando-os imutáveis ao impedir adições, deleções ou alterações de propriedades em um objeto.

E de acordo com a documentação:

"Também é impossível alterar o protótipo."

🦠 O que é Poluição de Protótipos (Prototype Pollution)?

Focando no caso dessa publicação, a poluição de protótipos ocorre quando propriedades do objeto __proto__ são modificadas por fontes não confiáveis. Também existe o Envenenamento de Protótipos (Prototype Poisoning), onde um ataque manipula ou corrompe o __proto__.

Um exemplo comum de protótipos utilizados em objetos, seria ao verificar se o objeto possui uma propriedade:

user.hasOwnProperty('name');

Nós podemos alterar o comportamento padrão desse protótipo de duas maneiras simples:

user.__proto__.hasOwnProperty = () => '👾';
user.hasOwnProperty = () => '👾';

☣️ Entendendo o Problema

Baseado no que a documentação traz a início, o que você acha que acontece no exemplo a seguir?

const obj = {
  test: {
    a: '✅',
  },
};

// Congelando nosso objeto
Object.freeze(obj);

obj.test.a = '🦠';
obj.test.b = '🦠';

// A) `{ test: { a: '✅' } }`
// B) `{ test: { a: '🦠', b: '🦠' } }`
console.log(obj);

E congelando o construtor de objetos, seus protótipos e inclusive o __proto__ de cada objeto?

// Congelando o construtor e os protótipos de forma global
Object.freeze(Object);
Object.freeze(Object.prototype);

const obj = {};

// Congelando a propriedade `__prop__` do nosso objeto
Object.freeze(obj.__proto__);

obj.__proto__ = {
  toString: () => '🦠',
};

// A) `[object Object]`
// B) `'🦠'`
console.log(obj.toString());

Se você acredita que o Object.freeze garantiu a segurança dos nossos dois testes, cuidado, todos os 🦠 foram executados sem erro algum.

❗️ Além disso: soluções em fóruns como o Stack Overflow, incluindo respostas do GPT 4, enfatizam esses exemplos como medidas de segurança.

ℹ️ Isso mostra a importância de sempre verificar e testar na prática, antes de simplesmente copiar e colar soluções na internet, especialmente geradas pelo GPT e similares.


⛄️ Resolvendo parte do Problema

A documentação fornece um exemplo deepFreeze, que resolverá nosso primeiro teste ao congelar todos os objetos dentro de um objeto:

deepFreeze
function deepFreeze(object) {
  const propNames = Reflect.ownKeys(object);

  for (const name of propNames) {
    const value = object[name];

    if ((value && typeof value === 'object') || typeof value === 'function')
      deepFreeze(value);
  }

  return Object.freeze(object);
}

Mas essa solução só funcionará realmente, se o deepFreeze for aplicado no nível do objeto mais alto:

const obj = {
  key: {
    value: true,
  },
};

// 🔐 Seguro
deepFreeze(obj);

// 🔓 Inseguro
deepFreeze(obj.key);

Usar a função deepFreeze irá lançar um erro se tentarmos proteger o Object.prototype de forma global:

deepFreeze(Object.prototype); // ❌ RangeError: Maximum call stack size exceeded

🔓 Descongelando Objetos (como assim?)

É extremamente comum criarmos objetos e congelar apenas uma ou mais propriedades específicas.

Por exemplo, podemos permitir que qualquer propriedade seja alterada no nosso objeto, exceto pela propriedade __proto__, especialmente ao gerar objetos dinamicamente a partir de valores externos.

  • Porém, ainda que o Object.freeze seja responsável por congelar um objeto, ele não diz respeito à propriedade em si.
  • Além disso, se nós reatribuirmos uma propriedade congelada com outro objeto, esse objeto será descongelado, ainda que a documentação mostre o contrário:
const obj = {
  key: {
    value: true,
  },
};

Object.freeze(obj.key);
console.log(Object.isFrozen(obj.key)); // Irá retornar `true`, então estamos seguros, certo?

// obj.key.value = 123; // 🔐 Cannot assign to read only property 'value' of object '#<Object>'
// ⬆️ O erro acima indica que nosso objeto está realmente seguro, não é?

// 👾 Reatribuindo o objeto congelado:
obj.key = {
  value: '🦠',
};

console.log(obj); // Nosso objeto congelado foi alterado: `{ key: { value: '🦠' } }`

// 🔓 Não apenas isso, nosso objeto também foi completamente descongelado:
obj.key.value = 123;

console.log(obj); // `{ key: { value: 123 } }`
  • Isso responde os perigos do __proto__, afinal, nós congelamos o Object.prototype globalmente e também nosso __proto__ manualmente, mas se uma propriedade dinâmica sobrescrever a propriedade __proto__, isso não só será permitido, como o __proto__ será descongelado, assim como no exemplo acima.

🔐 Alternativas e Abordagens Seguras

  1. Todos os exemplos até o momento dessa publicação funcionam em navegadores, todas versões do Node.js e Bun. Apenas o Deno parece também proteger a propriedade de um objeto congelado (testado apenas na versão mais recente, apenas com a propriedade __proto__).
  2. Infelizmente esses problemas não parecem ser vistos como vulnerabilidades e não tenho como afirmar se isso será resolvido um dia 😕

Pensando nisso, irei trazer alternativas que podem ser somadas ao Object.freeze em casos onde não queremos congelar um objeto completamente, mas apenas parte dele.

A) Chamando Métodos Nativos Diretamente

Chamando os métodos nativos diretamente, ainda que o __proto__ possa estar poluído, isso não deveria afetar o comportamento da nossa aplicação.

Por exemplo, ao invés de:

const obj = {};

obj.test = true;

obj.toString();
obj.hasOwnProperty('test');

Podemos usar:

const obj = Object.create(null);

obj.test = true;

String(obj);
Object.hasOwn(obj, 'test');

Outra alternativa comumente usada ao Object.create(null) seria definir a propriedade __proto__ como null, especialmente para objetos que já vêm "prontos":

const obj = {};

obj.__proto__ = null;

ℹ️ Porém, é importante notar que não protegemos propriedades como .toString e .hasOwnProperty de serem criadas diretamente em nenhum dos casos, por isso a importância de chamar os métodos nativos de forma segura.

Existem diversas formas de chegar nos mesmos resultados, citei apenas uma para cada um dos dois métodos mais comuns e que são compatíveis em 100% dos ambientes e versões.


B) Proxy e Reflect

Também é possível criar um objeto avançado usando Proxy e Reflect, porém isso pode trazer um impacto significativo na performance:

createSafeObject
const createSafeObject = () => {
  const nativeProps = new Set([
    '__defineGetter__',
    '__defineSetter__',
    '__lookupGetter__',
    '__lookupSetter__',
    '__proto__',
    'constructor',
    'hasOwnProperty',
    'isPrototypeOf',
    'propertyIsEnumerable',
    'toLocaleString',
    'toString',
    'valueOf',
  ]);

  const handler = {
    get(_, prop, receiver) {
      const isNativeProps = nativeProps.has(prop);

      if (isNativeProps) {
        return (...args) => Object.prototype[prop].apply(receiver, args);
      }

      return Reflect.get(...arguments);
    },
    set(_, prop) {
      const isNativeProps = nativeProps.has(prop);

      if (isNativeProps) {
        return false;
      }

      return Reflect.set(...arguments);
    },
  };

  const safePrototype = Object.create(Object.prototype);

  return new Proxy(safePrototype, handler);
};

Dessa forma, ao invés de {}, criaríamos nossos objetos como:

const obj = createSafeObject();

obj.test = true;

// ✅ Não irá funcionar
obj.hasOwnProperty = '🦠';

// ✅ Tentando reatribuir o `__proto__` (não irá funcionar)
obj.__proto__ = {};

// ✅ Não irá funcionar
obj.__proto__.hasOwnProperty = '🦠';

obj.hasOwnProperty('test'); // `true`

C) Protegendo as Propriedades Manualmente

Se tratando de propriedades alteradas dinamicamente, também podemos verificar o valor da propriedade antes de permitir que nosso objeto seja alterado:

const nativeProps = new Set([
  '__defineGetter__',
  '__defineSetter__',
  '__lookupGetter__',
  '__lookupSetter__',
  '__proto__',
  'constructor',
  'hasOwnProperty',
  'isPrototypeOf',
  'propertyIsEnumerable',
  'toLocaleString',
  'toString',
  'valueOf',
]);

const obj = {};

// Dados vindos de requisições, bancos de dados, dependências externas, etc.
const property = 'name';
const value = 'John';

// Retornando um erro caso a propriedade esteja na lista de propriedades não permitidas
if (nativeProps.has(property)) {
  throw new Error(
    `The property name can't be the same as an object's native property.`
  );
}

obj[property] = value;

obj.hasOwnProperty('name');

Conclusão

Em todos os exemplos, os objetos são criados manualmente, mas nem sempre temos controle sobre objetos que podem vir prontos de requisições, dependências externas e até mesmo de bancos de dados. Por isso é importante garantir que o nosso objeto é seguro antes de usar ou gravar seus valores e priorizar o uso dos métodos nativos diretamente (como mostrado na alternativa A).

Disclaimer: Essa publicação não diz que o Object.freze deve ser evitado e sim, que deve ser usado com cuidado e considerando seus "pontos fracos". Quando possível, crie objetos sem __proto__, chame os métodos nativos de forma segura, adicione verificações extras se necessário e, principalmente, lembre-se que o objeto é congelado, mas a propriedade (chave) não.


Notas

Notei esse comportamento enquanto corrigia uma vulnerabilidade no MySQL2 que existia desde sua criação e foi descoberta recentemente. A vulnerabilidade foi divulgada pelo Snyk, afetando todas versões anteriores à versão 3.9.4 do mysql2.

Ao corrigir essa vulnerabilidade criando resultados sem o __proto__, usuários reportaram uma quebra de versão (breaking change), pois agora não seria mais possível usar os protótipos nativos a partir dos resultados, especialmente o hasOwnProperty, bastante utilizado para verificar se uma coluna existe no resultado.

Isso me trouxe à importância de enfatizar isso publicamente (inicialmente, mantive privado até conversas com membros do Node.js, onde nosso querido Erick Wendel sugeriu divulgar, justamente para alertar os perigos do Prototype Poisoning / Pollution, mesmo usando o Object.freeze).

Quando levamos em conta que simplesmente por usar Object.hasOwn(result, 'column'), não seríamos afetados por essa vulnerabilidade, mesmo ela existindo em uma dependência direta do nosso projeto, isso reforça a importância de considerarmos sempre boas práticas em segurança, mantendo o equilíbrio entre segurança e performance.

Dica:

Se você utiliza o MySQL2, fortemente recomendo atualizar para versões maiores que 3.9.3, pois foram três vulnerabilidades reportadas ao mesmo tempo pelo analista de segurança Vsevolod Kokorin, sendo uma corrigida na versão 3.9.3 e as outras duas na versão 3.9.4.

Você pode conferir o relato detalhado no blog dele: https://blog.slonser.info/posts/mysql2-attacker-configuration

E marcando mais um ponto para o Brasil, todas as correções de segurança vieram de nós 🇧🇷🎉


  • Notou algum erro? Por favor, me avise e corrigirei 🙋🏻‍♂️
  • Mais dicas de segurança no tema são bem vindas.
1
2