Back

Manejo Seguro de Entrada de Usuario en Node.js

Manejo Seguro de Entrada de Usuario en Node.js

Toda API de Node.js recibe datos en los que no puede confiar plenamente. Los cuerpos JSON, las cadenas de consulta, los campos de formulario y las cargas de archivos llegan desde el mundo exterior, y cualquiera de ellos puede transportar cargas maliciosas. Inyección SQL, inyección NoSQL, inyección de comandos, contaminación de prototipos — estos ataques comparten un punto de entrada común: la entrada de usuario sin validar que alcanza la lógica de negocio, una base de datos o un comando de shell. Este artículo recorre los patrones prácticos que los detienen.

Puntos Clave

  • Siempre valida los datos entrantes contra un esquema estricto antes de que lleguen a la lógica de tu aplicación — define lo que está permitido y rechaza todo lo demás.
  • Usa consultas parametrizadas para SQL y aplica tipos de datos estrictos para NoSQL para prevenir ataques de inyección.
  • Evita child_process.exec() para comandos del sistema; usa execFile() o spawn() con arrays de argumentos explícitos en su lugar.
  • Nunca expandas cuerpos de solicitud sin procesar sobre modelos; selecciona solo los campos que esperas para prevenir asignación masiva y contaminación de prototipos.
  • Establece límites de tamaño de cuerpo y valida las cargas de archivos a nivel del analizador para protegerte contra ataques de denegación de servicio.

Valida Primero, Procesa Después

La regla más efectiva en el manejo seguro de solicitudes en APIs de Node.js es: nunca permitas que los datos sin procesar de una solicitud toquen tu lógica de aplicación. Valida la forma y el tipo de cada valor entrante antes de usarlo.

La validación basada en esquemas es el enfoque más limpio. Bibliotecas como Zod y Joi te permiten declarar exactamente cómo debería verse una solicitud y rechazar cualquier cosa que no coincida.

import { z } from "zod"

const RegisterSchema = z.object({
  username: z.string().min(3).max(20).regex(/^[a-zA-Z0-9_]+$/),
  email: z.string().email(),
  password: z.string().min(10),
})

app.post("/register", (req, res) => {
  const result = RegisterSchema.safeParse(req.body)
  if (!result.success) {
    return res.status(400).json({ errors: result.error.flatten() })
  }
  // result.data ahora es seguro de usar
})

Este es un enfoque de lista de permitidos: defines lo que está permitido, y todo lo demás es rechazado. Es mucho más confiable que intentar crear una lista de denegación de caracteres peligrosos. El mismo principio se enfatiza en la Hoja de Referencia de Validación de Entrada de OWASP.


Prevenir Ataques de Inyección

Inyección SQL y NoSQL

Nunca concatenes entrada de usuario en consultas. Usa sentencias parametrizadas para SQL:

// ❌ Vulnerable
const query = `SELECT * FROM users WHERE email = '${req.body.email}'`

// ✅ Seguro
const result = await db.query("SELECT * FROM users WHERE email = $1", [email])

Las bases de datos NoSQL tienen su propio riesgo de inyección. Los operadores de MongoDB como $gt pueden ser inyectados a través de cuerpos JSON si pasas objetos directamente:

// ❌ Vulnerable — req.body.username podría ser { $gt: "" }
User.find({ username: req.body.username })

// ✅ Seguro — aplica el tipo esperado primero
User.find({ username: String(req.body.username) })

En la práctica, la validación de esquema debería asegurar que el campo sea una cadena (por ejemplo con z.string()) antes de que llegue a la capa de consulta.

Inyección de Comandos

Si tu API llama comandos del sistema, evita child_process.exec() por completo — pasa argumentos a través de un shell y es trivialmente explotable. Usa execFile() o spawn() con arrays de argumentos explícitos en su lugar:

import { execFile } from "child_process"

// ✅ Los argumentos se pasan directamente, no son interpretados por un shell
execFile("convert", [userSuppliedFilename, outputPath], (err, stdout) => {
  // manejar resultado
})

Aún así valida y crea una lista de permitidos para los valores de argumentos. Si la operación es de alto riesgo, ejecútala dentro de un contenedor aislado.


Evitar Asignación Masiva y Contaminación de Prototipos

La asignación masiva ocurre cuando expandes un cuerpo de solicitud directamente sobre un modelo:

// ❌ Un usuario podría enviar { role: "admin" }
const user = await User.create({ ...req.body })

// ✅ Selecciona solo los campos que esperas
const user = await User.create({
  username: result.data.username,
  email: result.data.email,
})

La contaminación de prototipos es más sutil. Fusionar objetos no confiables puede permitir que un atacante inyecte propiedades como __proto__ o constructor.prototype que afectan a cada objeto en tu proceso. Evita utilidades de fusión profunda en datos de solicitud sin procesar. Si debes fusionar, valida la entrada primero y crea objetos intermedios con Object.create(null) para que no hereden de la cadena de prototipos predeterminada. La Hoja de Referencia de Prevención de Contaminación de Prototipos de OWASP proporciona orientación adicional.


Limitar el Tamaño y la Estructura de las Solicitudes

Las cargas grandes o profundamente anidadas pueden causar problemas de denegación de servicio. Establece límites de tamaño de cuerpo a nivel del analizador:

app.use(express.json({ limit: "50kb" }))

Para cargas de archivos, usa multer con límites explícitos de fileSize y files, y valida los tipos MIME — nunca confíes solo en la extensión del archivo.


Conclusión

El manejo seguro de entrada de usuario en Node.js se reduce a un principio: define cómo se ve la entrada válida, aplícala en el límite y nunca dejes pasar nada más. La validación de esquemas, las consultas parametrizadas, el paso explícito de argumentos y la selección estricta de campos cubren la mayoría de las superficies de ataque del mundo real. Aplica estos patrones de manera consistente en la capa de solicitud, antes de que los datos lleguen a cualquier lógica descendente, y la mayoría de las vulnerabilidades de clase inyección simplemente no tendrán dónde aterrizar.

Preguntas Frecuentes

La validación de esquema es una primera línea de defensa sólida porque rechaza la entrada mal formada antes de que llegue a tu lógica. Sin embargo, debe combinarse con consultas parametrizadas y ejecución segura de comandos. La validación asegura que los datos tengan la forma y el tipo correctos, pero el código descendente también debe manejar esos datos de manera segura para cubrir casos extremos que la validación por sí sola podría pasar por alto.

Ambas bibliotecas son maduras y ampliamente utilizadas. Zod ofrece soporte de primera clase para TypeScript con inferencia de tipos sólida, lo que la convierte en una opción natural para proyectos TypeScript. Joi tiene un historial más largo en el ecosistema Express y ofrece una API rica y encadenable. Elige según las preferencias de lenguaje de tu proyecto y las dependencias existentes.

Evita ejecutar funciones de fusión profunda en datos de solicitud sin procesar. Valida la entrada con un esquema estricto primero para que solo pasen los campos conocidos. Cuando la fusión sea necesaria, crea objetos intermedios con Object.create(null) para producir objetos sin una cadena de prototipos. Bibliotecas como lodash.merge han tenido vulnerabilidades de contaminación de prototipos en el pasado, así que mantén siempre las dependencias actualizadas.

No hay una respuesta universal, pero 100kb a 1MB cubre la mayoría de los casos de uso de APIs JSON. Comienza con un límite conservador como 100kb y auméntalo solo si las solicitudes legítimas requieren más. Para cargas de archivos, configura límites separados a través de tu analizador multipart. El objetivo es rechazar cargas anormalmente grandes temprano antes de que consuman memoria o tiempo de procesamiento.

Gain Debugging Superpowers

Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers. Check our GitHub repo and join the thousands of developers in our community.

OpenReplay