La arquitectura del entorno de ejecución de Node.js concede a las aplicaciones acceso directo y de bajo nivel a las primitivas del sistema operativo: el sistema de archivos (node:fs), la creación de subprocesos secundarios (node:child_process) y los sockets de red (node:net). Cuando la lógica de manipulación de datos se ve comprometida, un atacante puede escalar desde una vulnerabilidad de validación en la capa de aplicación hasta la Ejecución Remota de Código (Remote Code Execution, RCE) o la exfiltración de credenciales sensibles de la infraestructura cloud.
El hardening integral de la seguridad en el runtime de Node.js exige neutralizar cuatro vectores de ataque fundamentales e implementar el modelo nativo de permisos (Permission Model).
Vectores de Ataque en el Runtime de Node.js y Capas de Defensa:
1. Prototype Pollution:
JSON de entrada: {"__proto__": {"shell": "/bin/bash", "env": {"NODE_OPTIONS": "--require /tmp/evil.js"}}}
│
▼
Contaminación de Object.prototype
│
▼
child_process.spawn() lee opciones del prototipo ──> ¡Ejecución RCE no autorizada!
[Defensa: Object.create(null), Map, Object.freeze(Object.prototype), validación estricta de claves]
2. ReDoS (Catastrophic Backtracking):
Cadena de entrada: "aaaaaaaaaaaaaaaaaaaaaaaaaaaa!" ──> Regex: /(a+)+$/
│
▼
El motor NFA de V8 realiza 2^N iteraciones de retroceso (backtracking)
│
▼
El Event Loop se bloquea al 100% de CPU ──> Denegación de Servicio (DoS) total
[Defensa: safe-regex, motor Google RE2 (DFA O(N)), tiempos límite de evaluación regex]
3. SSRF y DNS Rebinding:
URL de usuario: http://api.webhook.internal/ ──> Se resuelve a 169.254.169.254 (AWS IMDSv2)
│
▼
El servidor ejecuta fetch() hacia la subred interna ──> ¡Exfiltración de credenciales cloud IAM!
[Defensa: Inspección DNS Pre-flight, validación de CIDR privados RFC 1918, fijación de IP en socket]En JavaScript, la herencia se basa en prototipos. Todas las instancias de objetos estándar heredan implícitamente propiedades de Object.prototype. Si una aplicación realiza fusiones recursivas no seguras (Deep Merge), clonaciones o asignación de propiedades por ruta sin filtrar las claves, un atacante puede inyectar propiedades directamente en el prototipo raíz:
// Fusión de objetos insegura
target["__proto__"]["isAdmin"] = true;
// Ahora CUALQUIER objeto en el runtime hereda la propiedad isAdmin:
const user = {};
console.log(user.isAdmin); // true!En Node.js, las consecuencias de la contaminación de prototipos van mucho más allá de la alteración de la lógica de negocio. Al generar procesos externos, child_process.spawn() examina el objeto de configuración options. La alteración de propiedades como shell, NODE_OPTIONS o env en Object.prototype desencadena la ejecución involuntaria de binarios maliciosos al invocar cualquier comando del sistema.
El motor de expresiones regulares de V8 implementa un Autómata Finito No Determinista (NFA) con retroceso (Backtracking). Cuando un patrón contiene cuantificadores anidados (por ejemplo, (a+)+$ o ([a-zA-Z]+)*$), la evaluación contra una entrada maliciosa de solo 30 a 40 caracteres genera una explosión combinatoria:
Complejidad Temporal = O(2^n)Dado que Node.js ejecuta JavaScript sobre un Event Loop de un solo hilo, la evaluación de una expresión regular catastrófica satura un núcleo de CPU al 100%, bloqueando la atención de peticiones concurrentes y congelando el servidor por completo.
La seguridad en Node.js se fundamenta en el principio de Defensa en Profundidad (Defense in Depth), abarcando la sanitización de estructuras de datos de entrada, la ejecución criptográfica en tiempo constante y la restricción de privilegios sobre el proceso del sistema operativo.
Prototype Pollution ReDoS (Regular Expression Denial of Service) SSRF (Server-Side Request Forgery) DNS Rebinding Timing-Safe Equal Node.js Permission ModelComparación de Métodos de Comparación: === frente a crypto.timingSafeEqual
1. Comparación estándar con === (Vulnerable a ataques de temporización):
Secreto: "a b c d e f g h"
Intento 1: "x b c d e f g h" ──> ¡Fallo en el carácter 0! (Duración: ~0.001 μs)
Intento 2: "a x c d e f g h" ──> ¡Fallo en el carácter 1! (Duración: ~0.002 μs)
Intento 3: "a b x d e f g h" ──> ¡Fallo en el carácter 2! (Duración: ~0.003 μs)
¡El atacante reconstruye estadísticamente el secreto carácter por carácter midiendo la latencia!
2. Comparación criptográfica con crypto.timingSafeEqual (Tiempo Constante):
Secreto: [0x61, 0x62, 0x63, 0x64]
Intento: [0x78, 0x62, 0x63, 0x64]
Operación: ¡Operación XOR bit a bit en cada byte hasta el final sin salida anticipada!
Duración: Estrictamente idéntica sin importar cuántos bytes coincidan.crypto.timingSafeEqual para erradicar la fuga de secretos mediante análisis de temporización a nivel de nanosegundos.169.254.169.254), previniendo DNS Rebinding.RE2 de Google para garantizar una complejidad lineal $O(N)$ y neutralizar ataques ReDoS sobre el Event Loop.--permission y --allow-fs-read, impidiendo que paquetes npm maliciosos accedan a claves SSH, secretos en .env o archivos sensibles del sistema operativo./**
* Lista negra de claves peligrosas en la jerarquía de prototipos
*/
const FORBIDDEN_KEYS = new Set(["__proto__", "constructor", "prototype"]);
/**
* Determina si un valor constituye un objeto JavaScript plano
*/
function isPlainObject(value: unknown): value is Record<string, unknown> {
if (typeof value !== "object" || value === null) {
return false;
}
const proto = Object.getPrototypeOf(value);
return proto === Object.prototype || proto === null;
}
/**
* Fusión profunda segura que neutraliza la mutación del prototipo
*/
export function safeDeepMerge<T extends Record<string, any>>(
target: T,
source: Record<string, any>
): T {
// Inicialización de objeto sin prototipo para almacenamiento seguro
const output = (target ?? Object.create(null)) as T;
for (const key of Object.keys(source)) {
// 1. Bloqueo de propiedades de prototipo peligrosas
if (FORBIDDEN_KEYS.has(key)) {
console.warn(`[Security Alert] Intento de Prototype Pollution bloqueado en la clave: "${key}"`);
continue;
}
const sourceVal = source[key];
const targetVal = output[key];
if (isPlainObject(sourceVal)) {
if (isPlainObject(targetVal)) {
output[key] = safeDeepMerge(targetVal, sourceVal);
} else {
// Creación de objeto limpio carente de prototipo
output[key] = safeDeepMerge(Object.create(null), sourceVal);
}
} else {
output[key] = sourceVal;
}
}
return output;
}
// Protección global del runtime al iniciar la aplicación:
// Congelación del prototipo base para impedir modificaciones
Object.freeze(Object.prototype);Comprobar URLs mediante cadenas simples como url.hostname !== 'localhost' resulta insuficiente. Los atacantes eluden estas restricciones empleando notación IPv6 (::1), formatos hexadecimales (0x7f000001), redirecciones HTTP (302) y ataques de DNS Rebinding.
Una defensa robusta exige resolución previa de DNS, verificación de direcciones IP frente a bloques CIDR reservados y anulación de redirecciones automáticas:
import dns from "node:dns/promises";
import net from "node:net";
import { request } from "undici";
// Patrones de subredes privadas, de enlace local y metadatos cloud (RFC 1918, RFC 3927)
const BLOCKED_IP_PATTERNS = [
/^127\./, // IPv4 Loopback
/^10\./, // Privada Clase A
/^172\.(1[6-9]|2[0-9]|3[0-1])\./, // Privada Clase B
/^192\.168\./, // Privada Clase C
/^169\.254\./, // Metadatos de Enlace Local / Cloud (AWS, GCP)
/^0\./, // Red Actual
/^::1$/, // IPv6 Loopback
/^fc00:/i, // IPv6 Única Local
/^fe80:/i, // IPv6 Enlace Local
];
function isPrivateIp(ip: string): boolean {
return BLOCKED_IP_PATTERNS.some((pattern) => pattern.test(ip));
}
/**
* Cliente HTTP protegido para obtener recursos externos de forma segura
*/
export async function ssrfSafeFetch(targetUrl: string): Promise<string> {
const parsed = new URL(targetUrl);
// 1. Limitar protocolos estrictamente a HTTP y HTTPS
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error(`Protocolo de red no admitido: ${parsed.protocol}`);
}
// 2. Realizar resolución DNS previa (Pre-flight DNS check)
const dnsLookupResult = await dns.lookup(parsed.hostname, { all: true });
if (!dnsLookupResult.length) {
throw new Error(`No se pudo resolver el nombre de host: ${parsed.hostname}`);
}
// Inspeccionar todos los registros A y AAAA devueltos
for (const record of dnsLookupResult) {
if (isPrivateIp(record.address)) {
throw new Error(`[SSRF Blocked] El destino resuelve a una IP privada: ${record.address}`);
}
}
// 3. Emitir petición deshabilitando el seguimiento de redirecciones (maxRedirections: 0)
// Las redirecciones deben validarse manualmente de forma explícita
const response = await request(parsed.href, {
method: "GET",
maxRedirections: 0,
headersTimeout: 5000,
bodyTimeout: 5000,
});
if (response.statusCode >= 300 && response.statusCode < 400) {
const location = response.headers["location"];
throw new Error(`Redirección a ${location} bloqueada por la política de seguridad`);
}
return await response.body.text();
}import crypto from "node:crypto";
/**
* Verificación de firmas de webhook inmune a ataques de temporización (Timing Attacks)
*/
export function verifyWebhookSignature(
rawPayload: Buffer,
signatureHeader: string,
secretKey: string
): boolean {
// Cálculo del resumen HMAC SHA-256 de referencia
const hmac = crypto.createHmac("sha256", secretKey);
hmac.update(rawPayload);
const expectedSignatureHex = hmac.digest("hex");
// Transformación de cadenas a búferes de idéntica longitud
const expectedBuffer = Buffer.from(expectedSignatureHex, "utf8");
const receivedBuffer = Buffer.from(signatureHeader, "utf8");
// Si la longitud difiere, la comparación se rechaza de inmediato
if (expectedBuffer.length !== receivedBuffer.length) {
return false;
}
// Comparación en tiempo constante a nivel de bits:
// Erradica cualquier canal lateral basado en tiempos de respuesta de CPU
return crypto.timingSafeEqual(expectedBuffer, receivedBuffer);
}A partir de Node.js 20, el entorno de ejecución incorpora el flag --permission, permitiendo delimitar las capacidades operativas del proceso frente a las llamadas al sistema sin recurrir a entornos de aislamiento externos.
Node.js se inicializa con flags --permission que definen la lista blanca de directorios de lectura y escritura.
Una dependencia npm intenta leer datos protegidos: fs.readFileSync('/etc/shadow').
El interceptor interno de seguridad valida la ruta solicitada frente al árbol de permisos activos.
El runtime emite una excepción ERR_ACCESS_DENIED inmediata, impidiendo la fuga de información.
#!/usr/bin/env bash
set -euo pipefail
# Ejecución del microservicio Node.js bajo aislamiento estricto de privilegios
node \
--permission \
--allow-fs-read="/app/dist,/app/node_modules,/app/package.json" \
--allow-fs-write="/app/logs,/tmp" \
--disable-proto=throw \
--no-addons \
dist/server.js// Comprobación de facultades antes de ejecutar accesos a disco
if (process.permission && process.permission.has("fs.read", "/app/dist")) {
console.log("[Security] Lectura en /app/dist permitida por la política del sistema");
} else {
console.error("[Security] Permisos insuficientes para consultar el directorio");
}Verificar firmas JWT sin restringir de forma explícita los algoritmos aceptados (algorithms: ['RS256']). Si un atacante envía {"alg": "none"}, bibliotecas desactualizadas admiten el token sin comprobar firma. Si el atacante sustituye RS256 por HS256 y firma el token empleando la clave pública del servidor, el validador verificará la firma simétrica usando dicha clave pública como secreto y concederá acceso no autorizado.
Validar tokens de sesión, CSRF o firmas HMAC mediante === o !==. Los operadores convencionales de comparación se interrumpen ante el primer carácter que no coincide. Registrando la latencia de respuesta con precisión de nanosegundos, un atacante puede deducir los bytes válidos de forma incremental. Se debe recurrir obligatoriamente a crypto.timingSafeEqual().
Configurar clientes HTTP que sigan automáticamente redirecciones (maxRedirects > 0). Aunque el dominio inicial supere con éxito las verificaciones de seguridad, el servidor remoto puede responder con Location: http://169.254.169.254/latest/meta-data/. El cliente seguirá la redirección hacia el endpoint de metadatos exponiendo credenciales IAM en la nube.
Ejecutar patrones de expresiones regulares sobre entradas arbitrarias de longitud ilimitada. Procesar cadenas de múltiples megabytes genera picos de consumo de memoria y suspende la ejecución en el Event Loop. Siempre se debe limitar la longitud máxima de las cadenas antes de evaluar cualquier RegExp.
El cuerpo de la petición se almacena como Buffer binario antes del parseo JSON para garantizar la integridad exacta de los bytes en el cálculo HMAC.
Se calcula el digest HMAC SHA-256 y se valida mediante crypto.timingSafeEqual para neutralizar ataques basados en tiempo.
La decodificación JSON se vierte en un objeto libre de prototipo (Object.create(null)) descartando claves como __proto__ y constructor.
Se imponen límites estrictos de longitud sobre los campos textuales y se procesan patrones complejos con el motor lineal RE2.
La lógica de negocio se procesa en un runtime delimitado por el modelo --permission, bloqueando accesos no autorizados al disco.