Skip to main content

Confusão de tipos em JavaScript: como contornar a validação de entrada (e como corrigir)

Escrito por
Headshot of Alessio Della Libera

Alessio Della Libera

blog feature snyk open source blue

3 de novembro de 2021

0 minutos de leitura

Em uma publicação anterior no blog, mostramos como a manipulação de tipos (ou confusão de tipos) pode ser usada para escapar de sandboxes de templates, levando a vulnerabilidades de cross-site scripting (XSS) ou injeção de código.

Um dos principais objetivos desta pesquisa foi explorar, no ecossistema JavaScript, se é possível contornar algumas correções de segurança ou validações de entrada com um ataque de confusão de tipos (ou seja, fornecendo um tipo de entrada inesperado).

Começamos nossa pesquisa investigando como vulnerabilidades comuns, como poluição de protótipo e XSS, são corrigidas. Para isso, usamos os dados do nosso Snyk Intel Vulnerability Database, o maior banco de dados de vulnerabilidades de código aberto do setor. Com essas informações, conseguimos identificar alguns padrões usados por diferentes mantenedores para prevenir determinadas classes de vulnerabilidade.

Agora é hora de analisar as vulnerabilidades de confusão de tipos. Nesta publicação, queremos demonstrar cenários comuns em que a sanitização e a validação de entradas podem ser contornadas com o fornecimento de um tipo de entrada inesperado. Também apresentamos exemplos de correção e sugestões para quem mantém projetos de código aberto e quer corrigir esse tipo de falha na validação de entradas.

Fundamentos de JavaScript

Antes de discutir como um valor de array pode ser usado para contornar algumas validações de entrada e levar a uma possível vulnerabilidade de segurança, vamos revisar alguns conceitos fundamentais sobre o funcionamento do JavaScript. Eles serão muito úteis mais adiante, especialmente quando analisarmos o estudo de caso de poluição de protótipo.

Comparação de valores: === e ==

No JavaScript, há dois operadores diferentes que podem ser usados para comparar valores:

  • Igualdade estrita ===

  • Igualdade não estrita ==

Uma das principais diferenças entre eles é que o operador === sempre retorna false quando os operandos têm tipos diferentes (ele não realiza nenhum tipo de coerção). Já com o operador ==, se os operandos tiverem tipos diferentes, primeiro eles são convertidos para um tipo comum e depois comparados. Para saber mais sobre o funcionamento desses operadores, consulte a documentação oficial do operador de igualdade estrita e do operador de igualdade não estrita.  

O exemplo a seguir mostra esse comportamento:

let a = "test"
console.log(a == "test") // true
console.log(a === "test") // true

let b = ["test"]
console.log(b == "test") // true: ["test"] is first converted to "test" and then compared
console.log(b === "test") // false!

Quando a comparação é entre operandos com o mesmo valor e tipo, os operadores == e === retornam true (linhas 2 e 3). Porém, ao comparar um array cuja representação em string é igual à do outro operando, o resultado só será true se for usado o operador ==. Na linha 6, vemos que o valor ["test"] é primeiro convertido para sua representação em string e depois comparado com o outro operando, "test". Quando os operandos têm tipos diferentes, o operador === sempre retorna false (linha 7).

Observação: As considerações acima também se aplicam aos operadores de desigualdade !== e !=.

Algumas formas de obter a representação em string de um valor são:

  • chamar o método toString() (linha 2)

  • converter para string usando a função String (linha 2)

  • concatenar o valor com uma string vazia usando o operador + (linha 3)

let arr = ["test"]
console.log(arr.toString() === "test") // true
console.log(String(arr) === "test") // true
console.log(('' + arr) === "test") // true

No caso de um valor de array, sua representação em string é a concatenação de seus elementos, separados por vírgula.

Acesso a propriedades

Outro recurso importante do JavaScript é a possibilidade de especificar valores de diferentes tipos (não apenas strings) para acessar propriedades de objetos usando a notação de colchetes. Se o valor não for uma string (nem Symbol), ele será primeiro convertido para string e então usado para acessar a propriedade do objeto.

Considere o exemplo a seguir:

let obj = {}

let prop1 = ["test"]
obj[prop1] = 1
console.log(prop1.toString()) // 'test'
console.log(obj["test"]) // 1

let prop2 = {} // empty object
obj[prop2] = 2
console.log(prop2.toString()) // [object Object]
console.log(obj['[object Object]']) // 2

let prop3 = [] // empty array
obj[prop3] = 3
console.log(prop3.toString()) // ''
console.log(obj['']) // 3

Como podemos ver, a chave usada para acessar uma propriedade pode ter diferentes tipos. Na linha 4, a propriedade ["test"] é primeiro convertida para uma string (ou seja, para o valor "test"), e o resultado dessa operação é usado como chave para acessar a propriedade do objeto. De fato, se usarmos a string "test" na linha 6, poderemos obter o valor definido na linha 4.

Da mesma forma, se usarmos um objeto vazio {} (linha 9), cuja representação em string é [object Object], para gravar um valor no objeto, podemos acessar esse mesmo valor usando diretamente a string [object Object] (linha 11).

Métodos de String e Array

Há métodos nativos definidos tanto em String quanto em Array que têm o mesmo nome. Alguns exemplos são includes, indexOf, lastIndexOf etc.

Por que isso é relevante para esta publicação? Esses métodos se comportam de maneiras diferentes dependendo do tipo de entrada em que são chamados. Se um deles for usado para validar a entrada do usuário, a validação poderá ser contornada caso seja fornecida uma entrada de outro tipo (por exemplo, um array) e nenhuma verificação adicional seja feita.

O exemplo a seguir mostra como métodos nativos definidos tanto em String quanto em Array se comportam de maneiras diferentes:

let a = "<script>"
console.log(a.includes("<script>")) // true
console.log(a.indexOf("<")) // 0

let b = ["<script>"]
console.log(b.includes("<script>")) // true
console.log(b.indexOf("<")) // -1

let c = [["<script>"]]
console.log(c.includes("<script>")) // false
console.log(c.indexOf("<")) // -1

console.log(a.toString() === b.toString()) // true
console.log(b.toString() === c.toString()) // true

Observação: Todos os valores do exemplo ("<script>", ["<script>"] e [["<script>"]]) têm a mesma representação em string.

Por exemplo, na linha 3, o método chamado será String.prototype.indexOf(). Ele verifica se o caractere < está na string e retorna o índice de sua primeira ocorrência; caso contrário, retorna -1.

No entanto, se a entrada for um array, o método chamado será Array.prototype.indexOf(), que verifica se o elemento < está no array. Como o array contém apenas um elemento (a string "<script>"), a função retornará -1, impedindo a possível sanitização desse caractere.

Resumo dos conceitos fundamentais de JavaScript

Vamos resumir o que vimos:

  • Se os operandos tiverem tipos diferentes, o operador === sempre retornará false

  • É possível usar chaves de diferentes tipos para acessar propriedades de objetos (quando se usa a notação de colchetes)

  • Alguns métodos nativos definidos tanto nos tipos String quanto Array (como indexOf ou includes) podem se comportar de maneiras diferentes dependendo do tipo da entrada

Antes de continuar, vamos analisar os cenários a seguir, em que alguns dos métodos mencionados são usados para validar a entrada do usuário.

A verificação a seguir pode não ser suficiente para impedir que a chave isAdmin seja definida no objeto obj (vamos supor que a variável prop seja controlada pelo usuário):

let prop = ["isAdmin"] // user controlled

let obj = {}

if(prop === "isAdmin") { // ["isAdmin"] === "isAdmin" -> false
    throw new Error("Ops!");
} else {
    obj[prop] = true; // obj[["isAdmin"]] is equivalent to obj["isAdmin"]
}

console.log(obj["isAdmin"]) // true

Se o valor de prop for alterado para "isAdmin", o exemplo acima lançará uma exceção.

Este é outro exemplo de verificação “fraca” usada para evitar um possível XSS, identificando se a entrada contém caracteres perigosos:

let user = ["<img src=x onerror='alert(1)'/>"] // user controlled

if (user.indexOf("<") || user.indexOf(">")){
    throw new Error("Characters not allowed!");
}

message = "Hello : " + user
console.log(message) // Hello : <img src=x onerror='alert(1)'/>

É importante observar que esses desvios só são possíveis em determinadas condições. Por exemplo, a entrada precisa vir de fontes específicas (veja a próxima seção) e nenhum método nativo definido apenas em String pode ser chamado na entrada. Caso contrário, o aplicativo retornará um erro ou uma exceção, pois esses métodos não estão definidos para outros tipos.

No exemplo a seguir, o método toLowerCase() é chamado na entrada. Embora já tenhamos visto que é possível contornar a comparação === com um valor de array, se um array for fornecido, o aplicativo lançará uma exceção, pois o método toLowerCase() só está definido para strings.

let prop = ["isAdmin"] // user controlled

let obj = {}

prop = prop.toLowerCase() // Uncaught TypeError: toLowerCase is not a function (it's only defined on String)

if(prop === "isAdmin") {
   throw new Error("Ops!");
} else {
   obj[prop] = true;
 }

console.log(obj["isAdmin"])

Como obter valores de array

A esta altura, você pode estar se perguntando: como obter valores de array a partir de dados remotos?

Há diferentes maneiras de obter valores de array:

  • Do lado do servidor: Ao usar o popular framework express, valores de req.query ou req.body (se o middleware express.json() for usado) também podem ser analisados como arrays ou objetos, e não apenas como strings.

  • Do lado do cliente: Dados recebidos pela API postMessage.

Do lado do servidor

Em solicitações GET, ao usar o popular framework express e receber o valor de req.query, é possível obter valores de array usando a notação parameter[]=value. Em geral, isso também pode se aplicar a qualquer aplicativo que use a popular biblioteca qs para analisar a querystring.

Em solicitações POST, se o middleware express.json() for usado, o corpo da solicitação será analisado como um objeto JSON.

O aplicativo express a seguir demonstra como obter valores de array:

const express = require('express')
const app = express()
const port = 3000

// curl -i -X GET "https://localhost:3000/test1?path[0][]=foo&path[]=bar&val=test"
app.get('/test1', (req, res) => {
    console.log(typeof req.query.path, req.query.path); // object [ [ 'foo' ], 'bar' ]   
    res.send('Hello World!')
})

app.use(express.json());

// curl -i -H "Content-Type: application/json" -X POST --data '{"path":[["foo"], "bar"], "val":"test"}' "https://localhost:3000/test2"
app.post('/test2', (req, res) => {
    console.log(typeof req.body.path, req.body.path); // object [ [ 'foo' ], 'bar' ]   
    res.send('Hello World!')
})

app.listen(port, () => {
    console.log(`App listening at https://localhost:${port}`)
})

Do lado do cliente

Os dados recebidos pela API postMessagetambém podem ter diferentes tipos, e não apenas ser strings:

<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
</head>
<body>
  postMessage example
  <script>
      window.addEventListener('message', function(event) {
          let name = event.data.name
          console.log(typeof name, name)
      });
  </script>
</body>
</html>

Para testar o código acima, abra o console do desenvolvedor do navegador e execute: window.postMessage({name: ["array"]}, "*"). Você deverá ver a seguinte saída:

Console do desenvolvedor do navegador mostrando um exemplo de postMessage com um objeto que contém um valor de matriz.

Resultados

Como parte desta pesquisa, encontramos e divulgamos alguns dos problemas a seguir:

Módulo

Vulnerabilidade

Comunicado da Snyk

CVE

object-path

Poluição de protótipo

CVE-2021-23434

immer

Poluição de protótipo

CVE-2021-23436

mpath

Poluição de protótipo

CVE-2021-23438

set-value

Poluição de protótipo

CVE-2021-23440

edge.js

Cross-site scripting (XSS)

CVE-2021-23443

jointjs

Poluição de protótipo

CVE-2021-23444

datatables.net

Cross-site scripting (XSS)

CVE-2021-23445

teddy

Cross-site scripting (XSS)

CVE-2021-23447

Entramos em contato de forma responsável e privada com vários mantenedores, seguindo nosso processo de divulgação de vulnerabilidades (alguns achados ainda estão em processo de divulgação).

Acima de tudo, queremos agradecer pelo tempo de todos os mantenedores com quem entramos em contato.

Nos próximos estudos de caso, vamos explicar como foi possível contornar uma correção contra poluição de protótipo e uma validação de entrada contra XSS usando valores de array.

Estudo de caso: poluição de protótipo em object-path

object-path é uma biblioteca usada para acessar propriedades aninhadas por meio de um caminho. Ela também aceita um caminho em formato de array, o que permite fornecer caminhos como ['part1', 'part2', etc.]. Essa biblioteca já era vulnerável a uma falha de poluição de protótipo (CVE-2020-15256). A correção introduzida foi a seguinte:

if (options.includeInheritedProps && (currentPath === '__proto__' ||
  (currentPath === 'constructor' && typeof currentValue === 'function'))) {
  throw new Error('For security reasons, object\'s magic properties cannot be set')
}

Essa correção lança corretamente uma exceção quando o caminho contém um desses componentes perigosos (e eles são strings). De fato, com o payload a seguir, a função lançará um erro:

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, ['__proto__', 'polluted'], 'yes');
console.log(polluted); // Error: For security reasons, object's magic properties cannot be set

No entanto, como vimos anteriormente:

  • É possível usar uma chave de qualquer tipo para acessar propriedades de objetos com a notação de colchetes

  • O operador === retorna false quando os operandos têm tipos diferentes. A condição currentPath === '__proto__' retornará false se currentPath for ['__proto__'] (o mesmo vale para constructor).

Isso significa que envolver a chave perigosa em um array com apenas um elemento (a própria chave) contorna a correção e ainda leva à poluição de protótipo:

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, [['__proto__'], 'polluted'], 'yes');
console.log(polluted); // yes

Correção

A Snyk entrou em contato em particular com o mantenedor em 25 de agosto de 2021, e o problema foi corrigido rapidamente, em 27 de agosto de 2021, na versão v0.11.6. A correção introduzida impede esse cenário ao converter os componentes do caminho para string (se não forem do tipo number ou string) antes de verificá-los:

var currentPath = path[0];
if (typeof currentPath !== 'string' && typeof currentPath !== 'number') {
  currentPath = String(currentPath)
}
var currentValue = getShallowProperty(obj, currentPath);
if (options.includeInheritedProps && (currentPath === '__proto__' ||
  (currentPath === 'constructor' && typeof currentValue === 'function'))) {
  throw new Error('For security reasons, object\'s magic properties cannot be set')
}

O payload anterior não funcionará mais:

const objectPath = require('object-path');

objectPath.withInheritedProps.set({}, [['__proto__'], 'polluted'], 'yes');
console.log(polluted); // Error: For security reasons, object's magic properties cannot be set

Estudo de caso: Cross-Site Scripting (XSS) no edge.js

edge.js é um mecanismo de templates para Node.js. Essa biblioteca tem funcionalidades integradas que podem ser ativadas para prevenir vulnerabilidades de segurança, como XSS. Em particular, de acordo com a documentação, “a saída da interpolação (o código dentro das chaves) é escapada como HTML para evitar ataques XSS”. A função original responsável por escapar caracteres HTML perigosos para prevenir XSS é a seguinte:

// https://github.com/edge-js/edge/blob/1ade7fbb81fbc1b52757650214d6baca140d3eb0/src/Template/index.ts#L183
public escape<T>(input: T): T extends SafeValue ? T['value'] : T {
  return typeof input === 'string'
    ? string.escapeHTML(input)
    : input instanceof SafeValue
    ? input.value
    : input
}

Como podemos ver, o input só é escapado como HTML se for do tipo string. Isso significa que, se a entrada controlada pelo usuário for do tipo objeto (por exemplo, um array) e não um SafeValue, mesmo que {{ }} seja usado no template, ela será retornada sem ser escapada (sem erros ou exceções), o que pode levar a uma vulnerabilidade de XSS.

Para demonstrar como isso pode ser explorado, considere o seguinte aplicativo web, que usa essa biblioteca como mecanismo de templates para renderizar um valor controlado pelo usuário em um template:

const express = require('express')
const app = express()
const port = 3000
const { join } = require('path')
const edge = require('edge.js').default

edge.mount(join(__dirname, 'views'))

// curl -i -X GET "https://localhost:3000/test?name[]=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E"
app.get('/test', (req, res) => {
    let n = req.query.name

    console.log(typeof n, n);

    edge.render('welcome', {
      greeting: n
    }).then(html => res.send(html))
})

app.listen(port, () => {
    console.log(`App listening at https://localhost:${port}`)
})

O conteúdo do arquivo views/welcome.edge é o seguinte:

<p> {{ greeting }} </p>

Como vimos anteriormente, dados de determinadas fontes também podem ser de tipos diferentes. Ao acessar a URL http://localhost:3000/test?name=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E, a função escapa a entrada. No entanto, ao acessar esta outra URL (observe os colchetes []) http://localhost:3000/test?name[]=%3Cimg%20src=x%20onerror=%27alert(1)%27%20/%3E, é possível acionar um XSS, pois o parâmetro req.query.name será analisado como um array e, portanto, não será escapado.

Correção

A Snyk entrou em contato em particular com o mantenedor em 1º de setembro de 2021, e o problema foi corrigido rapidamente na versão v5.3.2. A correção implementada trata esse cenário escapando todas as entradas que não são do tipo SafeValue:

export function escape(input: any): string {
  return input instanceof SafeValue ? input.value : string.escapeHTML(String(input))
}

Uma alternativa para evitar esse tipo de situação é converter a entrada em uma string antes de escapá-la (evitando, assim, verificar o tipo da entrada, como no caso acima) ou retornar uma string vazia (ou uma mensagem de erro) se a entrada não for uma string.

Conclusões para...

Desenvolvedores

Ao usar o operador === para fazer algum tipo de sanitização, é importante garantir que os dois operandos sejam do mesmo tipo. Ao escapar entradas, também é importante tratar o caso em que a entrada não é do tipo string (especialmente se o comportamento da função para valores que não são strings não estiver documentado).

Mantenedores

Se uma função/API é responsável pela sanitização de entradas e não trata o tipo da entrada, vale documentar esse comportamento para evitar confundir os usuários. Afinal, o mantenedor pode tratar alguns problemas de sanitização (como escapar a entrada se ela for uma string), mas não outros (como lidar com entradas que não são strings).

Pesquisadores de segurança

Concentramos nossa atenção em vulnerabilidades de poluição de protótipos e XSS, mas acreditamos que também pode haver outros casos ou cenários em que esse vetor de ataque seja usado para contornar sanitizações existentes e gerar possíveis problemas de segurança. Se você encontrar problemas semelhantes (ou qualquer outra vulnerabilidade de segurança) em um projeto de código aberto coberto pelo nosso programa, envie um relato pelo formulário de divulgação de vulnerabilidades da Snyk.

Referências

Comece a resolver desafios de capture the flag

Aprenda a resolver desafios de capture the flag assistindo sob demanda ao nosso workshop virtual introdutório.

Leia mais

blog feature snyk container custom base recommendations
Blog

Conheça o Evo Agentic AppSec: segurança que acompanha o ritmo do desenvolvimento com IA

O desenvolvimento impulsionado por IA está ultrapassando os fluxos de trabalho tradicionais de segurança. O Agentic AppSec da Snyk usa agentes de segurança para encontrar, corrigir e verificar vulnerabilidades continuamente, enquanto as pessoas definem os critérios.

blog feature ai violet
Blog

VulnBump: agentes de IA conseguem corrigir dependências vulneráveis sem quebrar o build?

O VulnBump testa agentes de IA em 307 atualizações reais de dependências. Uma correção só é considerada válida se eliminar a vulnerabilidade e mantiver o build e os testes passando.

Blog

Modelos de ponta encontraram as vulnerabilidades. Só o atacante encontrou as cadeias.

A análise estática encontrou as falhas, mas só os testes de ataque em aplicações ativas provaram como elas poderiam ser encadeadas para causar invasões. Uma comparação entre Evo COS, Claude Security e Claude Code Security.