DEV Community

Bernardo Bosak de Rezende
Bernardo Bosak de Rezende

Posted on

DevSecOps - Mass Assignment

Seguindo a série de posts sobre segurança no processo de desenvolvimento de software, hoje o assunto abordado será Mass Assignment, ou atribuição em massa, que é um sub-tipo de exploração utilizando alteração de parâmetros.

A OWASP elencou este tipo de vulnerabilidade como a 6ª mais importante se tratando de segurança para APIs.

Conheci este tipo de exploração quando o Rails 4 foi lançado em 2013, e junto com ele o conceito de Strong Parameters. Percebi que de lá pra cá muitos times de desenvolvimento deixam escapar verificações importantes de segurança que impediriam esse tipo de exploração. Por isso a importância de ser compartilhado.

Como funciona essa exploração

Basicamente, pessoas exploradoras podem tentar alterar valores críticos no seu banco de dados a partir de implementações no servidor que, ao evitar escrever códigos repetitivos, genericamente atualizam todos campos que foram enviados na requisição, sem verificações. Como exemplo, tomemos a seguinte estrutura de registro, para armazenar usuários em um sistema1:

{
  "id": String,
  "name": String,
  "department": String,
  "address": String,
  "mobile": String,
  "isAdmin": Boolean
}

Em poucas palavras, usuários possuem nome, departamento onde trabalham, endereço, número de celular e a informação se um usuário é administrador do sistema ou não, que é utilizada em verificações de segurança.

Digamos que uma das funcionalidades do sistema é a de poder atualizar os dados de um usuário, como nome (name), endereço (address) e celular (mobile).

Uma implementação trivial, para esta funcionalidade, seria:

async function updateUser(id, newData) {
  try {
    await db.user.update(id, {
      name: newData.name,
      address: newData.address,
      mobile: newData.mobile
    })
  } catch (err) {
    console.error(err)
    return false
  }
  return true
}

Onde newData é um objeto com os valores atualizados dos campos de usuário, que provavelmente será enviado pela aplicação que controla a interface gráfica onde os usuários podem realizar as atualizações. Também estamos assumindo que db.user.update é uma função que atualiza o registro no banco de dados, de acordo com id. Foi realizado um tratamento mínimo de erro e, caso a operação não dê erro, retornamos true (caso contrário, false).

Onde começa o problema...

Muitos campos podem ser adicionados na estrutura de usuários, e com o passar do tempo esta rotina para atualizar os campos pode tornar-se repetitiva, exigindo que a cada inclusão de campo novo a função updateUser também seja alterada. Por este motivo, é comum que as pessoas desenvolvedoras tornem esta atualização "genérica", atualizando os registros de user independente dos campos que forem enviados em newData:

async function updateUser(id, newData) {
  try {
    await db.user.update(id, newData)
  } catch (err) {
    console.error(err)
    return false
  }
  return true
}

Do ponto de vista de engenharia e design de código, a segunda versão do código parece mais robusta, certo? Certo.

Porém, o código acima abriu uma brecha crítica de segurança: uma tentativa mal-intencionada de atualizar estas informações pode colocar, também, o campo isAdmin no objeto newData (e defini-lo como true). Caso o sistema não se proteja em relação a isso, mesmo que a interface gráfica do usuário não permita a edição deste campo, seria possível tornar qualquer usuário administrador do sistema.

Em termos práticos, é possível "montar" uma requisição de atualização de campos para o servidor (com analisadores de rede ou até mesmo as ferramentas de desenvolvimento dos browsers) e, mesmo havendo autenticação no sistema, ainda assim seria possível alterar campos de acesso crítico.

Em outros exemplos, seria possível zerar valores de produtos, alterar endereços de entrega de um pedido entre outras alterações de impacto crítico.

Resolvendo de forma trivial, porém segura...

Caso não queiramos permitir que apenas o campo isAdmin seja alterado, vamos retirá-lo do objeto que enviamos para db.user.update:

async function updateUser(id, newData) {
  try {
    // remove isAdmin from update using rest operator
    const { isAdmin, ...safeNewData } = newData
    await db.user.update(id, safeNewData)
  } catch (err) {
    console.error(err)
    return false
  }
  return true
}

Existem formas diferentes para remover um campo de um objeto, inclusive na mesma linguagem. No código acima, optei por utilizar o operador rest em uma atribuição por desestruturação, ambas construções do ECMAScript, para criar um novo objeto sem o campo isAdmin, em uma referência chamada safeNewData. Desta forma, respeitamos o princípio da imutabilidade, bem importante na programação funcional. Agora, independente dos valores que cheguem no objeto newData, o campo isAdmin não será alterado.

Tornando a solução segura e robusta...

Embora tenhamos resolvido o problema com segurança, agora temos dois code smells no código: rigidez e fragilidade. Existe um grande risco de qualquer alteração na estrutura de user demandar alterações na função updateUser, aumentando o acoplamento e custo de manutenção do código. Novos campos críticos, que não devem ser mexidos pelos usuários, deverão ser manualmente removidos no código de updateUser.

Como solução geral, independente da forma como for implementado, podemos criar uma lista com os campos que não podem ser atualizados (a menos que o usuário possua permissão para tal), você pode chamar esta lista como deny list, ou forbidden list.

const FORBIDDEN_FIELDS = [
  'id',
  'isAdmin'
]

Uma vez com a lista de campos que não podem ser alterados2, nosso código pode, antes de atualizar o registro no banco de dados, remover todos os campos que estão em FORBIDDEN_FIELDS e também no objeto com os dados da tentativa de atualização.

Versão imperativa:

const maliciousUpdate = {
  name: 'New Name',
  address: 'New Address',
  isAdmin: true,
}

for (const [k, v] of Object.entries(maliciousUpdate)) {
  if (FORBIDDEN_FIELDS.includes(k)) {
    delete maliciousUpdate[k]
  }
}
console.log(maliciousUpdate)
// { name: "New Name", address: "New Address" }

Versão funcional:

const removeUnsafeFields = obj => Object.entries(obj)
  .filter(([k,v]) => !FORBIDDEN_FIELDS.includes(k))
  .reduce((acc, [k,v]) => ({ ...acc, [k]: v }), {})

const safeUpdate = removeUnsafeFields(maliciousUpdate)
console.log(safeUpdate)
// { name: "New Name", address: "New Address" }

Explicando um pouco a versão funcional: primeiro filtramos todas as tuplas [chave, valor] do objeto e pegamos somente aquelas cujo campo não esteja na lista de proibição de atualização (FORBIDDEN_FIELDS), depois nós juntamos todas estas tuplas restantes a partir de um objeto vazio, com uma operação de redução.

Basta aplicar a função removeUnsafeFields em pontos do código onde exista atualização. Caso você precise ter proibições diferentes para estruturas diferentes, basta parametrizar uma lista de proibição na função removeUnsafeFields, invés de usar sempre a mesma referência para FORBIDDEN_FIELDS.

Unificando estas verificações no nível da aplicação

Se você utilizar frameworks de desenvolvimento, provavelmente eles permitem que você execute código customizado toda vez que algum evento aconteça. No exemplo abaixo, toda vez que uma web app Express.js recebe uma requisição HTTP e a ação for um PATCH (update), realizamos a verificação de segurança no campo body da requisição:

const massAssignmentProtector = (req, res, next) => {
  if (req.method === 'PATCH') {
    const { body } = req
    req.body = removeUnsafeFields(body)
  }
  next()
}
app.use(massAssignmentProtector)

O exemplo completo, incluindo cenários demonstrando a brecha de segurança, encontra-se no replit abaixo:

Conclusões

  1. Códigos genéricos, como essa atribuição em massa, tem pontos positivos (como a redução de boilerplate), mas podem expôr brechas de segurança. Por isso, sempre questione o código também na dimensão da segurança, pois ela é uma parte (bem) importante na qualidade do seu software.
  2. Se preciso, implemente verificações por roles e permita que somente alguns perfis de usuários podem alterar todos os campos.
  3. Se você possui campos sensíveis e que não podem ser alterados por interações com usuário (mas podem ser autocalculados ou coisas do tipo), nunca confie 100% nas atualizações ou operações que alguma camada externa manda para seu software. Sempre realize as suas verificações de segurança.

  1. a estrutura dos campos é meramente didática e não representa os padrões de modelagem e normalização de dados. 

  2. em muitos bancos de dados alterar o campo id gera um erro, mas o exemplo é didático. 

Top comments (0)