Inicio / Artículos / Refuerzo de la seguridad en Node.js: ataques DoS, inyección y defensa de contenedores

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.

1457 palabras

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.
  • Construcción en múltiples etapas: mantiene las herramientas de desarrollo, los compiladores y los conjuntos de pruebas fuera de la imagen final, reduciendo lo que un atacante podría explotar
  • npm ci: garantiza instalaciones reproducibles al respetar estrictamente las versiones exactas indicadas en package-lock.json
  • Lista 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 helmet para 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 node para 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