Skip to main content

Cómo evitar las vulnerabilidades de asignación masiva en Node.js

feature buffer overflow

28 de marzo de 2023

0 minutos de lectura

La asignación masiva es una vulnerabilidad que permite a los atacantes aprovechar patrones de registros predecibles y ejecutar acciones no autorizadas. Suele ocurrir cuando no se filtran las propiedades al vincular datos proporcionados por el cliente con modelos de datos. Las vulnerabilidades de este tipo permiten que un atacante agregue objetos adicionales a las cargas útiles de las solicitudes POST y modifique propiedades que deberían ser inmutables.

Node.js es un entorno de servidor de código abierto que se puede usar en distintas plataformas para desarrollar aplicaciones web. Permite a los desarrolladores crear aplicaciones potentes y escalables con rapidez. En esencia, Node.js es una plataforma probada en batalla y lista para producción; los desafíos de seguridad de las aplicaciones incluyen la seguridad de la cadena de suministro asociada con los paquetes de terceros que se instalan para crearlas. A veces, crear una aplicación de Node.js implica usar miles de paquetes npm de código abierto del registro de npm, lo que la expone a diversos vectores de ataque.

Las vulnerabilidades de asignación masiva son comunes en las aplicaciones de Node.js que envían cargas útiles a la base de datos en solicitudes POST. Un atacante puede aprovechar estas vulnerabilidades para orquestar ataques de inyección SQL u otros ataques dirigidos a los datos almacenados en las bases de datos. También puede aprovechar una vulnerabilidad de asignación masiva para tomar el control total de un sistema o robar datos confidenciales, por lo que es fundamental defenderse de este tipo de ataque.

En este artículo se muestra una vulnerabilidad de asignación masiva en un proyecto de Node.js, cómo puede explotarla un atacante y cómo proteger una aplicación web.

Cómo eliminar las vulnerabilidades de asignación masiva en Node.js

En este ejemplo se usa Mongoose, una biblioteca de modelado de datos de objetos (ODM) para Node.js y MongoDB. Coordina los objetos del código con los objetos de MongoDB, valida esquemas y ayuda a administrar las relaciones entre los datos.

Con base en la información anterior, si quieres proteger una aplicación de Node.js con MongoDB contra la asignación masiva, debes hacerlo en Mongoose. A continuación, se muestra un ejemplo de cómo explotar una vulnerabilidad de asignación masiva en una aplicación de Node.js.

Requisitos previos

Para seguir este proyecto, necesitarás lo siguiente:

  • Tener Node.js instalado

  • Acceso a una base de datos MongoDB y una cadena de conexión válida. Este tutorial usa un entorno en la nube de MongoDB Atlas, pero puedes iniciar un servidor local de MongoDB y actualizar la cadena de conexión según corresponda.

Asignación masiva en Node.js

Para empezar con este proyecto, abre la terminal y ejecuta este comando:

npm init

A continuación, instala las dependencias:

npm i mongoose
npm i express

Para procesar los datos del formulario anterior, necesitamos un archivo newUser.js en la carpeta models para gestionar el esquema del usuario.

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 incluye todos los campos del formulario de registro. El campo adicional isAdmin, de tipo Boolean, define el rol del usuario. Si estableces este campo en true, crearás un usuario con privilegios de administrador; si lo estableces en false, crearás un usuario sin esos privilegios.

Crea un directorio llamado routes y agrega el archivo routes.js para gestionar los datos del usuario:

    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;

El código anterior captura los datos del formulario y luego los estructura con el modelo newUSer.

Este código tiene dos fallas que introducen una vulnerabilidad de asignación masiva. Primero, usa un nombre y un tipo comunes para un campo confidencial: isAdmin. Segundo, el modelo del usuario contiene un campo confidencial que no se usa. Para explotar la vulnerabilidad, iniciemos un servidor.

Crea un archivo llamado server.js en la carpeta raíz del proyecto y agrega el siguiente código. Reemplaza <APP_ID> por el valor de tu cadena de conexión:

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");
});

Inicia el servidor con node server.js.

Un actor malicioso puede explotar esta vulnerabilidad creando una solicitud curl POST que incluya el campo isAdmin con el valor true, como se muestra en el código a continuación:

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"
}'

La solicitud anterior crea un usuario, Mike, con privilegios de administrador. Este usuario ahora puede usar sus credenciales para iniciar sesión en el sistema y hacer todo lo que puede hacer un administrador.

Cómo defenderte contra la asignación masiva

Podemos hacer tres ajustes para reforzar la protección de la aplicación contra los ataques de asignación masiva.

Crea modelos reducidos

Los modelos reducidos solo incluyen los datos que se espera que el usuario ingrese y excluyen los campos confidenciales.

A partir del ejemplo anterior, el primer paso y el más importante es eliminar el campo confidencial del modelo newUser, como se muestra a continuación:

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;

Ahora, si los atacantes obtienen acceso al código fuente, no podrán ver el campo confidencial ni usarlo para orquestar un ataque. También debes evitar usar términos predecibles en la base de datos para definir el rol de un usuario, como isAdmin, admin y role, porque un atacante aún podría adivinarlos y lanzar un ataque.

Valida el esquema de los datos de entrada del usuario

Validación con underscore

Otra forma de proteger tu aplicación es especificar y limitar los campos que puede procesar una solicitud POST. Esto se conoce como lista de permitidos y utiliza la biblioteca underscore. Primero, instala la biblioteca:

npm install underscore

Ve a la carpeta routes y reemplaza el 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;

En el código anterior, usamos la función pick para especificar las variables que se extraen de una solicitud POST. Esto evita que un atacante use el campo confidencial isAdmin.

El problema de usar underscore es que la aplicación debe procesar los datos para determinar si son válidos. En otras palabras, no detecta los errores con suficiente anticipación.

Validación con Zod

Para validar esquemas de forma sólida, debes usar Zod. Esta herramienta valida el esquema y garantiza que los datos cumplan estrictamente con la estructura, el patrón y el tipo de datos especificados. Zod detecta los datos incompletos o incorrectos con suficiente anticipación para evitar errores en la aplicación. Además, puedes crear fácilmente un mensaje de error que indique al usuario qué datos de entrada causaron el error y de qué tipo es.

Este fragmento de código muestra cómo funciona Zod. Ten en cuenta que está escrito en TypeScript, por lo que requiere configuración adicional.

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
        });
    }
);

Si un usuario intenta ingresar datos que no coinciden con el esquema, recibirá un error que indica que se produjo un invalid_type_error. Si deja un campo vacío, recibirá un required_error.

Más ejemplos de vulnerabilidades de asignación masiva en ORM

Veamos otros tres ejemplos de vulnerabilidades de asignación masiva en soluciones ORM comunes.

Vulnerabilidad de asignación masiva en código de Sequelize

Veamos un ejemplo de código de Sequelize vulnerable a la asignación masiva. Este código define un modelo de usuario, crea un usuario y guarda sus datos en la base de datos:

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
    },

 });

Al crear un usuario nuevo, el controlador llama al modelo anterior, como se muestra a continuación:

// 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!"
    });
});

El código anterior es vulnerable porque expone el campo confidencial is_verified. Un atacante puede crear una solicitud POST con el campo is_verified establecido en true y así omitir el paso de verificación.

Podemos eliminar la vulnerabilidad de asignación masiva de este código si quitamos el campo is_verified al crear el usuario. El código seguro se vería así:

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

Esto limita las variables aceptadas en la solicitud POST a username, email y password.

Vulnerabilidad de asignación masiva en código de Prisma

A continuación, se muestra un ejemplo de código de Prisma vulnerable a la asignación masiva que expone el campo confidencial Role.

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

enum Role {
    USER
    ADMIN
}

Para agregar un usuario, creamos la ruta 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 solicitud POST espera que el cuerpo incluya un objeto de usuario. El código anterior es vulnerable a los ataques de asignación masiva porque un hacker puede agregar el campo role con el valor ADMIN para obtener privilegios de administrador.

Podemos eliminar la vulnerabilidad del código anterior si excluimos el campo confidencial Role del modelo.

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

Vulnerabilidad de asignación masiva en código de MySQL

Este es un ejemplo de código de MySQL vulnerable a la asignación masiva que expone el 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 agrega el usuario definido anteriormente:

// 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));
    });

});

El código anterior tiene una vulnerabilidad de asignación masiva porque expone un campo confidencial, isAdmin, y permite incluirlo en una solicitud POST. Un atacante puede crear una solicitud POST que establezca el campo isAdmin en true y crear así un usuario administrador.

Podemos corregir esta vulnerabilidad si excluimos el campo confidencial al crear el objeto user. Este es el código seguro:

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

El valor predeterminado false del campo isAdmin se puede establecer modificando la tabla con el siguiente comando:

ALTER TABLE users ALTER isAdmin SET DEFAULT ‘false’;

Cómo mantener seguro tu código en Node.js

Node.js es seguro, pero a veces los paquetes necesarios para crear una aplicación web no lo son. En las aplicaciones de Node.js que envían solicitudes a una base de datos, la vulnerabilidad de asignación masiva es uno de los vectores de ataque más comunes. Permite que usuarios maliciosos realicen ataques de inyección SQL, que consisten en enviar solicitudes maliciosas a una base de datos.

Sin embargo, puedes prevenir fácilmente la asignación masiva en Node.js depurando los datos de entrada. Para hacerlo, evita exponer campos confidenciales, limita los campos aceptados o impide que se editen ciertos campos.

Si no implementas una validación sólida de los datos de entrada del usuario, expones el sistema a ataques como la contaminación de prototipos. Estos ataques pueden eludir mecanismos de validación débiles, como underscore. Por eso, asegúrate de usar siempre soluciones sólidas como Zod.

Consulta el ejemplo completo de código fuente en nuestro repositorio de GitHub y realiza tus propios experimentos de seguridad locales para aprender cómo evitar las vulnerabilidades de asignación masiva en Node.js.

Sigue las 10 mejores prácticas de seguridad de Snyk para Node.js para optimizar la seguridad de tu aplicación de Node.js.

Comienza con Capture the Flag

Aprende a resolver desafíos de Capture the Flag con nuestro taller virtual 101 a pedido.