Skip to main content

Como evitar vulnerabilidades de atribuição em massa no Node.js

feature buffer overflow

28 de março de 2023

0 minutos de leitura

A atribuição em massa é uma vulnerabilidade que permite aos invasores explorar padrões previsíveis de registros e executar ações não autorizadas. Ela geralmente ocorre quando as propriedades não são filtradas ao vincular dados fornecidos pelo cliente a modelos de dados. Vulnerabilidades desse tipo permitem que um invasor crie objetos adicionais nos payloads de requisições POST e modifique propriedades que deveriam ser imutáveis.

Node.js é um ambiente de servidor de código aberto que pode ser usado em diferentes plataformas para desenvolver aplicações web. Ele permite que desenvolvedores criem aplicações poderosas e escaláveis com rapidez. Em sua essência, o Node.js é uma plataforma testada em campo e pronta para produção — mas os desafios de segurança das aplicações também envolvem a segurança da cadeia de suprimentos, associada a pacotes de terceiros instalados para criá-las. Às vezes, uma aplicação Node.js pode usar milhares de pacotes npm de código aberto do registro npm, ficando exposta a diversos vetores de ataque.

Vulnerabilidades de atribuição em massa são comuns em aplicações Node.js que enviam payloads ao banco de dados em requisições POST. Um invasor pode explorar essas vulnerabilidades para orquestrar ataques de injeção de SQL ou outros ataques direcionados aos dados armazenados nos bancos de dados. Também pode usar uma vulnerabilidade de atribuição em massa para assumir o controle total de um sistema ou roubar dados confidenciais. Por isso, é essencial se proteger contra esse tipo de ataque.

Este artigo demonstra uma vulnerabilidade de atribuição em massa em um projeto Node.js, como um invasor pode explorá-la e como proteger uma aplicação web.

Como eliminar vulnerabilidades de atribuição em massa no Node.js

Este exemplo usa Mongoose, uma biblioteca de mapeamento de objetos para Node.js e MongoDB (ODM). Ela coordena os objetos no código e no MongoDB, valida o esquema e ajuda a gerenciar as relações entre os dados.

Com base nas informações acima, para proteger uma aplicação Node.js com MongoDB contra atribuição em massa, você precisa fazer isso no Mongoose. Veja abaixo um exemplo de exploração de uma vulnerabilidade de atribuição em massa em uma aplicação Node.js.

Pré-requisitos

Para acompanhar este projeto, você precisará de:

  • Node.js instalado

  • Acesso a um banco de dados MongoDB e uma string de conexão válida. Este tutorial usa um ambiente na nuvem do MongoDB Atlas, mas você também pode configurar um servidor MongoDB local e atualizar a string de conexão conforme necessário.

Atribuição em massa no Node.js

Para começar este projeto, abra o terminal e execute este comando:

npm init

Em seguida, instale as dependências:

npm i mongoose
npm i express

Para lidar com os dados do formulário acima, precisamos criar um arquivo newUser.js na pasta models para gerenciar o esquema do usuário.

const mongoose = require('mongoose');

const Schema = mongoose.Schema;

const userSchema = new Schema({

    first_name: String,
    last_name: String,
    email: String,
    password: String,
    isAdmin: {
        type: Boolean,
        protect: true,
        default: false
    }

});

const User = mongoose.model("User", userSchema);

module.exports = User;

Este modelo reúne todos os campos do formulário de cadastro. O campo extra isAdmin, do tipo Boolean, define a função do usuário. Se você definir esse campo como true, criará um usuário com privilégios de administrador; se definir como false, criará um usuário sem esses privilégios.

Crie um novo diretório chamado routes e adicione o arquivo routes.js para gerenciar os dados do usuário:

    const express = require("express");
    const userModel = require("../models/newUser");

    const app = express();

    app.post("/add_user", async (request, response) => {
        const user = new userModel(request.body);

        try {
          await user.save();
          response.send(user);
        } catch (error) {
          response.status(500).send(error);
        }
    });

module.exports = app;

O código acima captura os dados do formulário e, em seguida, os estrutura com o modelo newUSer.

Este código tem duas falhas que introduzem uma vulnerabilidade de atribuição em massa. Primeiro, ele usa um nome e um tipo comuns para um campo confidencial: isAdmin. Segundo, o modelo de usuário contém um campo confidencial que não é usado. Para explorar a vulnerabilidade, vamos iniciar um servidor.

Crie um arquivo chamado server.js na pasta raiz do projeto e adicione o código a seguir, substituindo <APP_ID> pelo valor da sua string de conexão:

const express = require("express");
const mongoose = require("mongoose");
const Router = require("./routes/routes")

const app = express();

app.use(express.json());

const username = "<USER>";
const password = "<USER_PASSWORD>";
const cluster = "<CLUSTER>";
const dbname = "<DATABSE_NAME>";

mongoose.connect(
  `mongodb+srv://${username}:${password}@${cluster}.<APP_ID>.mongodb.net/${dbname}?retryWrites=true&w=majority`,
  {
    useNewUrlParser: true,
    useUnifiedTopology: true
  }
);

const db = mongoose.connection;
db.on("error", console.error.bind(console, "connection error: "));
db.once("open", function () {
  console.log("Connected successfully");
});

app.use(Router);

app.listen(3000, () => {
  console.log("Server is running at port 3000");
});

Inicie o servidor executando node server.js.

Um agente malicioso pode explorar essa vulnerabilidade criando uma requisição curl POST que contenha o campo isAdmin definido como true, conforme mostrado abaixo:

curl --location --request POST 'http://localhost:3000/add_user' \
--header 'Content-Type: application/json' \
--data-raw '{
    "first_name": "Mike",
    "last_name": "Jones",
    "email": "mikejones@hmail.com",
    "isAdmin":"true"
}'

A requisição acima cria um usuário, Mike, com privilégios de administrador. Esse usuário pode usar suas credenciais para entrar no sistema e fazer tudo o que um administrador pode fazer.

Como se defender contra a atribuição em massa

Podemos fazer três ajustes para aumentar a proteção da aplicação contra ataques de atribuição em massa.

Crie modelos enxutos

Modelos enxutos incluem apenas os dados que se espera que o usuário forneça e excluem campos confidenciais.

No exemplo acima, a primeira e mais importante medida é remover o campo confidencial do modelo newUser, como mostrado abaixo:

const mongoose = require("mongoose");

// create a schema
var userSchema = new mongoose.Schema({
	first_name: String,
	last_name: String,
	email: String,
	password: String

	//NB: there is no isAdmin field

});

const User = mongoose.model("User", userSchema);

module.exports = User;

Assim, se os invasores conseguirem acessar o código-fonte, não poderão ver o campo confidencial nem usá-lo para orquestrar um ataque. Evite também usar termos previsíveis no banco de dados para definir a função do usuário — como isAdmin, admin e role —, pois um invasor ainda pode adivinhá-los e lançar um ataque.

Valide o esquema dos dados de entrada do usuário

Validação com underscore

Outra forma de proteger sua aplicação é especificar e limitar os campos que uma requisição POST pode processar. Essa técnica é conhecida como lista de permissões e usa a biblioteca underscore. Primeiro, instale a biblioteca:

npm install underscore

Acesse a pasta routes e substitua o código de route.js por este:

const express = require("express");
const userModel = require("../models/newUser");
var _ = require('underscore');

const app = express();

app.post("/add_user", async (request, response) => {
    const user = new userModel( _.pick(request.body, 'first_name', 'last_name', 'email', 'password'));

    try {
      await user.save();
      response.send(user);
    } catch (error) {
      response.status(500).send(error);
    }
});

module.exports = app;

No código acima, usamos a função pick para especificar as variáveis extraídas de uma requisição POST. Isso impede que um invasor use o campo confidencial isAdmin.

O desafio de usar underscore é que a aplicação precisa processar os dados para saber se são válidos. Em outras palavras, os erros não são detectados com antecedência suficiente.

Validação com Zod

Para validar esquemas com rigor, use Zod. Ele valida o esquema para garantir que os dados sigam à risca a estrutura, o padrão e o tipo de dado especificados. O Zod identifica dados incompletos ou incorretos com antecedência, evitando erros na aplicação. Além disso, você pode criar facilmente uma mensagem de erro que oriente o usuário sobre os dados que causaram o erro e o tipo de erro.

O trecho de código demonstra como o Zod funciona. Observe que o trecho a seguir está em TypeScript e exige algumas configurações adicionais.

import { z, AnyZodObject } from "zod";
import express, { Request, Response, NextFunction } from 'express';

const app = express();

app.use(express.json());

const dataSchema = z.object({
    body: z.object({
        first_name: z.string({
            required_error: "First name is required",
            invalid_type_error: "First name must be a string",
        }),
        last_name: z.string({
            required_error: "Last name is required",
            invalid_type_error: "Last name must be a string",
        }),
        password: z.string({
            required_error: "Password is required",
        }),
        email: z
            .string({
                required_error: "Email is required",
            })
            .email("Not a valid email"),
    }),
});

const validate =
    (schema: AnyZodObject) =>
    async (req: Request, res: Response, next: NextFunction) => {
        try {
            await schema.parseAsync({
                body: req.body,
                query: req.query,
                params: req.params,
            });
            return next();
        } catch (error) {
            return res.status(400).json(error);
        }
    };

app.post("/create",
    validate(dataSchema),
    (req: Request, res: Response): Response => {
        return res.json({
            ...req.body
        });
    }
);

Se o usuário tentar inserir dados que não correspondam ao esquema, receberá um erro invalid_type_error. Se deixar um campo vazio, receberá um erro required_error.

Mais exemplos de vulnerabilidades de atribuição em massa em ORMs

Vamos ver mais três exemplos de vulnerabilidades de atribuição em massa em soluções ORM comuns.

Vulnerabilidade de atribuição em massa em código Sequelize

Veja um exemplo de código Sequelize vulnerável à atribuição em massa. O código define um modelo de usuário, cria um usuário e salva os dados dele no banco de dados:

const { Sequelize } = require("sequelize");

const User = sequelize.define("users", {
    username: {
        type: DataTypes.STRING,
        allowNull: false
    },

    email: {
        type: DataTypes.STRING,
        allowNull: false
    },

    password: {
        type: DataTypes.STRING,
        allowNull: false
    },

    is_verified: {
        type: DataTypes.BOOLEAN,
        allowNull: false,
        default: false
    },

 });

Ao criar um novo usuário, o controlador chama o modelo acima, como mostrado a seguir:

// Create a User
const user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password,
    is_verified: false
  };

//Save User in the database
User.create(user)
.then(data => {
    res.send(data);
})
.catch(err => {
    res.status(500).send({
    message:
        err.message || "Error occurred. User not created!"
    });
});

O código acima é vulnerável porque expõe o campo confidencial is_verified. Um invasor pode criar uma requisição POST com o campo is_verified definido como true e, assim, ignorar a etapa de verificação.

Podemos eliminar a vulnerabilidade de atribuição em massa neste código removendo o campo is_verified ao criar o usuário. O código seguro seria assim:

// Create a User
const user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password
  };

Isso limita as variáveis aceitas na requisição POST a nome de usuário, e-mail e senha.

Vulnerabilidade de atribuição em massa em código Prisma

Abaixo está um exemplo de código Prisma vulnerável à atribuição em massa, que expõe o campo confidencial Role.

model User {
    id      		Int    @id @default(autoincrement())
    username        String
    email       	String
    Role    		Boolean    @default(USER)
  }

enum Role {
    USER
    ADMIN
}

Para adicionar um usuário, criamos uma rota add_user.

const { PrismaClient } = require("@prisma/client");
const express = require("express");
const prisma = new PrismaClient();

const app = express();

app.use(express.json());

app.post('/add_user', async (req, res) => {
    const user = await prisma.user.create({ data: req.body });
    res.json(user);
  });

Esta requisição POST espera um objeto de usuário no corpo. O código acima é vulnerável a ataques de atribuição em massa porque um hacker pode definir o campo role como ADMIN para obter privilégios de administrador.

Podemos eliminar a vulnerabilidade no código acima excluindo do modelo o campo confidencial Role.

model User {
    id      		Int    @id @default(autoincrement())
    username        String
    email       	String
  }

Vulnerabilidade de atribuição em massa em código MySQL

Veja um exemplo de código MySQL vulnerável à atribuição em massa que expõe o campo confidencial isAdmin:

// Defining the user data
let user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password,
    isAdmin: req.body.role
};

Este código adiciona o usuário definido acima:

// Saving user details
app.post('/add_user',(req, res) => {

let myQuery = "INSERT INTO users SET ?";

    let query = connection.query(myQuery, user,(err, results) => {
        if(err) throw err;
        res.send(apiResponse(results));
    });

});

O código acima tem uma vulnerabilidade de atribuição em massa porque expõe um campo confidencial — isAdmin — e permite incluí-lo em uma requisição POST. Um invasor pode criar uma requisição POST que defina o campo isAdmin como true, criando um usuário administrador.

Podemos corrigir essa vulnerabilidade excluindo o campo confidencial ao criar o objeto user. Veja o código seguro:

// Defining the user data
let user = {
    username: req.body.username,
    email: req.body.email,
    password: req.body.password
};

O valor padrão false do campo isAdmin pode ser definido alterando a tabela com o seguinte comando:

ALTER TABLE users ALTER isAdmin SET DEFAULT ‘false’;

Mantenha seu código seguro no Node.js

O Node.js é seguro, mas às vezes os pacotes necessários para criar uma aplicação web não são. Em aplicações Node.js que enviam requisições a um banco de dados, a vulnerabilidade de atribuição em massa é um dos vetores de ataque mais comuns. Ela permite que usuários maliciosos realizem ataques de injeção de SQL, que envolvem o envio de requisições maliciosas a um banco de dados.

No entanto, é fácil evitar a atribuição em massa no Node.js higienizando os dados de entrada. Você pode fazer isso deixando de expor campos confidenciais, limitando os campos aceitos ou impedindo a edição de determinados campos.

A falta de uma validação rigorosa dos dados de entrada do usuário expõe o sistema a ataques como poluição de protótipo. Esses ataques podem contornar mecanismos de validação fracos, como o underscore. Por isso, use sempre soluções robustas, como o Zod.

Confira o exemplo completo do código-fonte em nosso repositório do GitHub e faça seus próprios testes de segurança locais para ver como evitar vulnerabilidades de atribuição em massa no Node.js.

Siga as 10 principais práticas recomendadas de segurança para Node.js da Snyk para otimizar a segurança da sua aplicação Node.js.

Comece a jogar Capture the Flag

Aprenda a resolver desafios de Capture the Flag assistindo à gravação sob demanda do nosso workshop virtual introdutório.