Refuerzo de la seguridad en Node.js: ataques DoS, inyección y defensa de contenedores
Aprenda técnicas prácticas para proteger los servicios de Node.js contra ataques DoS y ReDoS en el bucle de eventos, ataques de inyección y contenedores inseguros, utilizando la limitación de tasas y Helmet.
Incluso un backend con una excelente cobertura de pruebas y arquitectura limpia puede quedar fuera de servicio en cuestión de momentos si es vulnerable a la parálisis del bucle de eventos, ataques de inyección o un entorno de contenedores poco protegido.
Dado que Node.js se ejecuta en un bucle de eventos de un solo hilo, las debilidades de seguridad en un servicio Node.js suelen manifestarse no solo como fugas de datos, sino también como una completa indisponibilidad: una denegación total de servicio.
1. Defendiendo el bucle de eventos: previniendo la denegación de servicio (DoS)
El riesgo operativo más grave en Node.js es el bloqueo del bucle de eventos. Si un atacante envía datos que obligan al motor de expresiones regulares a realizar retrocesos en O(2^n), o sobrecarga al servidor con cuerpos de solicitudes HTTP demasiado grandes, el bucle de eventos se detiene. Una vez congelado, el proceso deja de responder a todas las solicitudes, provenientes de cualquier cliente, no solo del malintencionado.
Incoming Request Floods ──┐
▼
┌──────────────────────────┐
│ Node.js Event Loop │
│ (Single Thread Execution)│
└────────────┬─────────────┘
│ ❌ Synchronous CPU Block
▼
┌──────────────────────────┐
│ Event Loop Freeze │ ──► All other concurrent requests
│ (High Latency / DoS) │ time out or drop!
└──────────────────────────┘
Defensa A: Límites estrictos para el cuerpo de la solicitud y la carga útil
Si se dejan en sus valores predeterminados, muchos middleware de análisis de cuerpos aceptarán sin problema cargas útiles lo suficientemente grandes como para agotar la memoria disponible o consumir un número excesivo de ciclos de CPU solo al analizar la entrada.
El método incorrecto: Análisis ilimitado de cuerpos
// ❌ BAD Accepts arbitrarily large JSON payloads
app.use(express.json());
El método correcto: Imponer límites a la carga útil
Configure límites de tamaño explícitos tanto en la capa de la aplicación como en los puntos de entrada, es decir, en su proxy inverso o capa de ingreso, como NGINX, Cloudflare o un AWS Application Load Balancer.
// shared/middleware/security.js
const express = require('express');
// Strict payload limits per content type
const configurePayloadLimits = (app) => {
// Limit standard JSON bodies to 100kb
app.use(express.json({ limit: '100kb' }));
// Limit URL-encoded forms to 50kb
app.use(express.urlencoded({ extended: true, limit: '50kb' }));
};
module.exports = { configurePayloadLimits };
Defensa B: Prevención de ReDoS (Denegación de servicio mediante expresiones regulares)
Una vulnerabilidad ReDoS surge cuando su código ejecuta una expresión regular con retrocesos catastróficos contra datos controlados por un atacante.
Patrón vulnerable: Cuantificadores anidados
// ❌ VULNERABLE: Exponential backtracking O(2^n)
const emailRegex = /^([a-zA-Z0-9_\.\-])+\@(([a-zA-Z0-9\-])+\.)+([a-zA-Z0-9]{2,4})+$/;
// An input like "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa!" will hang the process for minutes!
emailRegex.test(userInput);
Patrón seguro: Utilice bibliotecas validadas o motores lineales
En lugar de crear expresiones regulares manualmente para lógicas de validación complejas, recurra a bibliotecas de esquemas bien probadas como Zod o Joi, o a un paquete de validación especializado como validator.
const { z } = require('zod');
// Schema validation using safe built-in string parsers
const UserInputSchema = z.object({
email: z.string().email().max(255), // Enforces linear-time length constraints
username: z.string().alphanumeric().min(3).max(30)
});
function validatePayload(payload) {
return UserInputSchema.parse(payload);
}
2. Prevención de ataques de inyección (SQL, NoSQL y comando)
Las vulnerabilidades de inyección ocurren cada vez que valores no confiables se insertan directamente en consultas a la base de datos o en comandos enviados al sistema operativo.
El método incorrecto: concatenación de cadenas en consultas
// ❌ VULNERABLE: Direct string interpolation (SQL Injection)
async function findUserByEmail(email) {
const query = `SELECT * FROM users WHERE email = '${email}'`;
return await db.query(query); // Attacker passes: "' OR '1'='1"
}
El método correcto: consultas parametrizadas y tipado estricto
Evite crear consultas interpolando parámetros sin procesar en las llamadas a su controlador de base de datos u ORM. Utilice marcadores de posición para parámetros — $1, $2 o ? según el controlador — de modo que el motor de la base de datos siempre trate los valores proporcionados como datos puros, nunca como SQL ejecutable.
// ✅ GOOD: Parameterized SQL Execution
async function findUserByEmail(email) {
const text = 'SELECT id, email, role, created_at FROM users WHERE email = $1';
const values = [email];
const result = await db.query(text, values);
return result.rows[0] || null;
}
3. Limitación de tasas distribuida mediante Redis
La limitación de tasas no es opcional para una API seria. Si la omite, deja abierta la puerta a clientes abusivos o integraciones problemáticas que puedan saturar sus puntos finales, agotando las conexiones a la base de datos y afectando negativamente el rendimiento para todos los demás.
El problema es que en entornos con múltiples instancias en ejecución —piense en pods de Kubernetes detrás de un balanceador de carga— un limitador de velocidad que mantiene sus contadores en memoria local simplemente no funciona, ya que cada proceso tiene su propio estado aislado. En su lugar, lo que se necesita es un almacenamiento compartido y distribuido como Redis, de modo que cada instancia verifique y actualice el mismo contador atómico.
Client Requests ──► [ Pod 1 ] ──┐
├──► [ Distributed Redis ]
Client Requests ──► [ Pod 2 ] ──┘ (Atomic Request Counter)
Implementación de limitador de velocidad empresarial
// shared/middleware/rateLimiter.js
const { RateLimiterRedis } = require('rate-limiter-flexible');
const redisClient = require('../database/redis');
// Create rate limiter instance: max 100 requests per 15 minutes per IP
const rateLimiter = new RateLimiterRedis({
storeClient: redisClient,
keyPrefix: 'rl_api',
points: 100, // Maximum requests allowed
duration: 15 * 60, // Per 15 minutes (in seconds)
blockDuration: 60 * 5 // Block IP for 5 minutes if exceeded
});
const rateLimiterMiddleware = async (req, res, next) => {
try {
const clientIp = req.ip || req.headers['x-forwarded-for'];
await rateLimiter.consume(clientIp);
next();
} catch (rejRes) {
// Return standard HTTP 429 Too Many Requests
res.setHeader('Retry-After', Math.round(rejRes.msBeforeNext / 1000) || 60);
return res.status(429).json({
status: 'error',
code: 'TOO_MANY_REQUESTS',
message: 'Rate limit exceeded. Please slow down your requests.'
});
}
};
module.exports = rateLimiterMiddleware;
4. Seguridad de encabezados HTTP con Helmet
Por defecto, las respuestas de Express revelan detalles internos a través de encabezados como X-Powered-By: Express. Ese único encabezado proporciona a los atacantes una vía rápida: saben exactamente qué framework están utilizando y pueden dirigirse directamente a exploits conocidos específicos para ese framework.
La solución consiste en aplicar un conjunto reforzado de encabezados de respuesta predeterminados con el middleware helmet.
// shared/middleware/securityHeaders.js
const helmet = require('helmet');
function applySecurityHeaders(app) {
app.use(
helmet({
// Hide frame options to mitigate Clickjacking
frameguard: { action: 'deny' },
// Strict Content Security Policy
contentSecurityPolicy: {
directives: {
defaultSrc: ["'self'"],
scriptSrc: ["'self'"],
objectSrc: ["'none'"],
upgradeInsecureRequests: []
}
},
// Remove X-Powered-By header
hidePoweredBy: true,
// Enforce HTTPS
hsts: {
maxAge: 31536000, // 1 year
includeSubDomains: true,
preload: true
}
})
);
}
module.exports = { applySecurityHeaders };
5. Seguridad de contenedores y entorno de ejecución: Refuerzo de Dockerfiles
Escribir código de aplicación seguro no es suficiente si su despliegue lo anula. Si el proceso de Node.js se ejecuta como root dentro de su contenedor Docker, cualquier vulnerabilidad de ejecución remota que aparezca le otorga al atacante acceso a nivel de root, lo que podría permitirle acceder al sistema de archivos del contenedor o incluso al host.
Buenas prácticas para Dockerfiles de producción en múltiples etapas
# -------------------------------------------------------------------
# STAGE 1: Build & Dependencies
# -------------------------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /usr/src/app
# Copy dependency manifests
COPY package*.json ./
# Install all dependencies (including devDependencies for building/testing)
RUN npm ci
# Copy source code
COPY . .
# Prune devDependencies for production runtime
RUN npm prune --production
# -------------------------------------------------------------------
# STAGE 2: Minimal Production Runtime
# -------------------------------------------------------------------
FROM node:20-alpine AS runner
# Set NODE_ENV to production
ENV NODE_ENV=production
WORKDIR /usr/src/app
# Copy built artifacts and production node_modules from builder
COPY --chown=node:node package*.json ./
COPY --chown=node:node --from=builder /usr/src/app/node_modules ./node_modules
COPY --chown=node:node --from=builder /usr/src/app/src ./src
# 🔒 SECURITY REQUIREMENT: Never run Node.js as root in container environments
USER node
EXPOSE 3000
CMD ["node", "src/server.js"]
Por qué es importante:
USER node: elimina los privilegios de root del proceso en ejecución y lo hace funcionar como un usuario sin privilegios.
npm ci: garantiza instalaciones reproducibles al respetar estrictamente las versiones exactas indicadas en package-lock.jsonLista de verificación de arquitectura para la Parte 4
Utilice esta lista de verificación para auditar un despliegue en producción antes de enviarlo:
- Restricciones en la carga útil: ¿Está cada punto de extremo API protegido por un límite de tamaño para la carga útil HTTP, como
express.json({ limit: '100kb' })? - Defensa contra ReDoS: ¿Se valida toda la entrada de texto no confiable mediante bibliotecas verificadas (Zod, Joi) o lógica de validación sin retroceso, en lugar de expresiones regulares personalizadas?
- Consultas parametrizadas: ¿Todas las llamadas a la base de datos pasan los datos ingresados por el usuario a través de parámetros vinculados ($1, $2 o equivalentes) para que la inyección SQL y NoSQL sea estructuralmente imposible?
- Límite de frecuencia distribuido: ¿Existe un limitador de frecuencia atómico respaldado por Redis que proteja activamente sus APIs públicas contra tráfico de denegación de servicio?
- Encabezados de seguridad: ¿Está configurado
helmetpara suprimir los encabezados que identifican al framework, aplicar HSTS y bloquear el incrustamiento de marcos? - Usuario de contenedor sin privilegios: ¿Su Dockerfile declara explícitamente
USER nodepara que el proceso nunca se ejecute como root? - Escaneo de dependencias: ¿Están integradas las verificaciones automatizadas de vulnerabilidades —
npm audit, Snyk, Dependabot o similares — en su pipeline CI/CD?
Lecturas relacionadas
- 20 patrones de Node.js que evitan el cese del servidor en producción — Aprenda 20 patrones prácticos de Node.js, desde el manejo de errores hasta el apagado ordenado y la gestión de conexiones, que impiden los fallos antes de que sea necesario reiniciar el servidor.
- Referencia de comandos de Node.js para servidores de desarrollo local y producción — Una referencia de comandos fácil de consultar que abarca la gestión de versiones de Node.js, los administradores de paquetes, la configuración del entorno, el depurado, PM2 y las implementaciones en Linux sin interrupciones.