Polución de prototipos (Prototype Pollution) en JavaScript y Node.js: guía de prevención para 2026
¿Qué es la polución de prototipos (prototype pollution)?
La polución de prototipos (prototype pollution) es una vulnerabilidad exclusiva del modelo de herencia basado en prototipos de JavaScript. Se produce cuando un atacante manipula __proto__, constructor.prototype u otras propiedades similares en un objeto controlado por el usuario para inyectar propiedades en el Object.prototype global. Una vez contaminado, todos los objetos — incluidos los creados después de la polución — heredan la propiedad inyectada.
// A dangerous merge function
function merge(target, source) {
for (const key of Object.keys(source)) {
if (source[key] && typeof source[key] === 'object') {
if (!target[key]) target[key] = {};
merge(target[key], source[key]);
} else {
target[key] = source[key];
}
}
return target;
}
const userInput = JSON.parse('{"__proto__": {"isAdmin": true}}');
const config = merge({}, userInput); // ⚠️ pollutes Object.prototype!
console.log({}.isAdmin); // → true (every object is now "admin")
¿La consecuencia? Una sola propiedad contaminada puede eludir comprobaciones de autorización, desactivar funciones de seguridad o tumbar todo tu servidor — todo a partir de un payload JSON que controla el atacante.
Por qué las startups deberían preocuparse
La polución de prototipos no es una vulnerabilidad de nicho. Ha aparecido en:
- lodash (CVE-2020-8203, CVE-2019-10744 — polución de prototipos vía
_.merge,_.defaultsDeep) - Body-parsers de Express (al procesar objetos JSON anidados sin límites de profundidad)
- Socket.io y otras librerías en tiempo real con gestión de configuración deep-merge
- jQuery (CVE-2019-11358 —
$.extend(true, ...)) - Drivers de Mongoose / MongoDB (expansión automática de objetos a partir de parámetros de consulta)
Para una startup que lanza rápido, la combinación de utilidades de deep-merge, entradas JSON sin sanitizar y procesamiento recursivo de objetos crea una tormenta perfecta. He visto un bug de polución de prototipos escalar de "parseo JSON inofensivo" a "compromiso total de la aplicación" en producción a las pocas horas del despliegue.
Cómo la explotan los atacantes
1. Eludiendo comprobaciones de autorización
// Backend code checking admin status
function isAdmin(user) {
return user.role === 'admin';
}
// Attacker sends: {"__proto__": {"role": "admin"}}
// After a recursive merge, every object has role === 'admin'
// isAdmin({}) → true
2. Denegación de servicio vía __defineGetter__
// Older JavaScript engines allowed getter injection
const payload = '{"__proto__": {"toString": {"__defineGetter__": "..."}}';
// Can hang the process when any object is coerced to string
3. Sombreado de propiedades en renderizado del lado del servidor
// Next.js server component receives polluted query params
// Object.prototype now has .dangerous = "value",
// which shadows expected properties in component props
4. Eludiendo validadores de lista blanca
// A schema validator checks allowed properties
const allowed = ['name', 'email', 'role'];
function sanitize(obj) {
const clean = {};
for (const key of allowed) {
if (key in obj) clean[key] = obj[key];
}
return clean;
}
// After prototype pollution, 'isAdmin' is always "in" obj
// because it's inherited via prototype, even though it's not own
'isAdmin' in {} // → true after pollution
Patrones de prevención (con código)
1. Usa Object.create(null) para diccionarios
La defensa más efectiva con diferencia. Los objetos creados con prototipo null no tienen cadena de prototipos — ni __proto__, ni constructor.prototype, ni toString.
// ✅ Immune to prototype pollution
const config = Object.create(null);
// ✅ Safe for caches, lookup tables, user-data maps
const userCache = Object.create(null);
userCache[sessionId] = userData;
// ✅ Safe for parsed query parameters
const params = Object.create(null);
params[key] = value;
Cuándo usarlo: Cualquier objeto que sirva como mapa, diccionario, tabla de búsqueda o que contenga claves controladas por el usuario. Es decir, la mayor parte del parseo JSON, el parseo de query strings y el manejo del cuerpo de peticiones.
2. Elimina las claves de prototipo antes de fusionar
// ✅ Safe merge with explicit prototype key exclusion
function safeMerge(target, ...sources) {
const PROTOTYPE_KEYS = new Set([
'__proto__',
'constructor',
'prototype',
]);
for (const source of sources) {
for (const key of Object.keys(source)) {
if (PROTOTYPE_KEYS.has(key)) continue; // 🛡️ skip dangerous keys
if (!Object.prototype.hasOwnProperty.call(source, key)) continue;
// ^ also skip inherited properties that might be from a previous pollution
const val = source[key];
if (val && typeof val === 'object' && !Array.isArray(val)) {
target[key] = safeMerge(
Object.prototype.hasOwnProperty.call(target, key)
? target[key]
: Object.create(null),
val
);
} else {
target[key] = val;
}
}
}
return target;
}
3. Usa hasOwnProperty en lugar de in
// ❌ DANGEROUS — 'key in obj' checks the prototype chain
if ('isAdmin' in userData) { /* may be true even without own property */ }
// ✅ SAFE — only checks own properties
if (Object.prototype.hasOwnProperty.call(userData, 'isAdmin')) {
// truly own
}
// ✅ or with Object.hasOwn() (ES2022+)
if (Object.hasOwn(userData, 'isAdmin')) {
// truly own
}
4. Usa Map en lugar de objetos planos
Para el almacenamiento clave-valor, Map es inherentemente inmune a la polución de prototipos — las claves son entradas reales, no propiedades:
// ✅ Map is safe — keys are entries, not properties
const cache = new Map();
cache.set(userInputKey, value);
// No __proto__ attack possible
// ✅ Safer for rate-limiting buckets, session stores, memoization
5. Aplica límites de profundidad en el procesamiento recursivo
// ✅ Limit recursion depth to prevent prototype chain traversal
function safeDeepClone(obj, maxDepth = 5) {
const seen = new WeakMap();
function clone(value, depth) {
if (depth > maxDepth) return Object.create(null); // 🛡️ cap depth
if (value === null || typeof value !== 'object') return value;
if (seen.has(value)) return seen.get(value);
const cloneTarget = Array.isArray(value) ? [] : Object.create(null);
seen.set(value, cloneTarget);
for (const key of Object.keys(value)) {
if (key === '__proto__' || key === 'constructor') continue; // 🛡️
if (Object.prototype.hasOwnProperty.call(value, key)) {
cloneTarget[key] = clone(value[key], depth + 1);
}
}
return cloneTarget;
}
return clone(value, 0);
}
6. Valida los esquemas de entrada antes de cualquier procesamiento
import { z } from 'zod';
// or: ajv, yup, joi
const UserSchema = z.object({
name: z.string().max(100),
email: z.string().email(),
role: z.enum(['user', 'admin']).optional(),
// ⚠️ No __proto__, constructor, or prototype fields allowed
}).strict(); // 🛡️ .strict() rejects unknown keys
app.post('/api/user', (req, res) => {
// Parse BEFORE any merge or spread — rejection if __proto__ present
const result = UserSchema.safeParse(req.body);
if (!result.success) {
return res.status(400).json({ error: 'Invalid payload' });
}
// Now it's safe to use the parsed data
});
Protecciones específicas de Next.js
Parseo seguro de query strings
// apps/my-app/src/lib/query-parser.ts
// ❌ DANGEROUS — Object.assign or spread inherits prototype
// const params = { ...req.query };
// ✅ SAFE — extract only expected keys
export function parseSafeQuery(query: Record<string, string>, allowed: string[]) {
const params: Record<string, string> = Object.create(null);
for (const key of allowed) {
if (typeof query[key] === 'string') {
params[key] = query[key];
}
}
return params;
}
Validación de entradas en Server Actions
// apps/my-app/src/actions/user.ts
'use server';
export async function updateProfile(formData: FormData) {
// FormData entries are already safe (they don't carry __proto__),
// but if you convert to JSON, be careful
const raw = Object.fromEntries(formData.entries()); // ⚡ safe — entries are strings
const data = ProfileSchema.parse(raw); // 🛡️ validate!
}
Protección de rutas API con middleware
// apps/my-app/src/middleware.ts
export function middleware(request: NextRequest) {
const contentType = request.headers.get('content-type');
if (contentType?.includes('application/json')) {
// Next.js will parse the JSON body — ensure depth limits
// by configuring bodyParser in next.config or using a custom parser
}
}
Configura los límites de profundidad en next.config.ts:
// next.config.ts
import type { NextConfig } from 'next';
const nextConfig: NextConfig = {
experimental: {
serverComponentsBodySizeLimit: '1mb',
},
// Body parser depth limit for API routes
// (configure via custom middleware if needed)
};
Lista de verificación de auditoría
Úsala en tu próxima revisión de código o auditoría de seguridad:
| Comprobación | Qué buscar |
|---|---|
| Merge/clonado recursivo | _.merge, _.defaultsDeep, $.extend(true) o merges recursivos personalizados — deben eliminar __proto__/constructor |
| Spread de objetos sobre datos de usuario | { ...req.body }, Object.assign({}, userInput) — usa un destino Object.create(null) o parsea con esquema primero |
| Comprobaciones con el operador in | Sustitúyelas por Object.hasOwn() o Object.prototype.hasOwnProperty.call() |
| Objetos planos como mapas | Migra a Map, WeakMap o Object.create(null) |
| Profundidad del parseo JSON | Asegúrate de que JSON.parse no recorra más de 5–10 niveles |
| Validación de esquemas | Todas las entradas de usuario deberían pasar un parser de esquemas estricto (Zod, Ajv, Yup) antes de cualquier operación con objetos |
| Profundidad de express.json() | El parser JSON por defecto de Express no tiene límite de profundidad — configura { limit: '1mb', strict: true } |
| Rutas API de Next.js | Si usas un middleware de parseo JSON personalizado, asegúrate de que elimina las claves de prototipo |
Soporte de herramientas
- Regla de Semgrep:
js/express-prototype-pollution— detecta patrones de merge inseguros en CI - eslint-plugin-security: La regla
no-prototype-builtinsdetecta comprobacioneshasOwnPropertyausentes - Socket.dev: Detección en tiempo de ejecución de polución de prototipos en tu árbol de dependencias
--frozen-intrinsicsde Node.js (experimental): CongelaObject.prototype,Array.prototypey otros prototipos integrados para prevenir cualquier polución en tiempo de ejecución
# Run Node with frozen intrinsics to prevent prototype mutation
node --frozen-intrinsics server.js
Resumen
La polución de prototipos es una vulnerabilidad nativa de JavaScript que no aparece en la mayoría de los escáneres SAST y es invisible para las bases de datos de vulnerabilidades tradicionales. Además, es trivialmente fácil de introducir — un merge defectuoso, un clonado recursivo, una clave __proto__ sin comprobar.
La corrección no es complicada:
Object.create(null)para cualquier contenedor de diccionarios o datos de usuario- Elimina las claves de prototipo (
__proto__,constructor,prototype) en cada operación recursiva Mappara el almacenamiento clave-valor- Validación de esquemas antes de cualquier manipulación de objetos
- Límites de profundidad en el procesamiento recursivo
Aplica estos cinco patrones de forma consistente y eliminarás toda una clase de vulnerabilidades que la mayoría de las bases de código JavaScript dejan completamente abiertas.
JS Security Audit
Auditorías dirigidas por un ingeniero de seguridad JavaScript senior con más de 10 años de experiencia.