Vulnerabilidades en APIs REST
La validación con class-validator que usaste hoy en clase protege contra entradas malformadas — pero eso es solo la primera capa. Una API puede rechazar perfectamente un body con campos inválidos y aun así tener vulnerabilidades graves. Este material cubre las fallas de seguridad más frecuentes en APIs REST y cómo se ven en código real.
1. SQL Injection #
SQL injection ocurre cuando el input del usuario se concatena directamente en una query SQL, permitiendo al atacante modificar la lógica de la consulta.
Código vulnerable #
// ❌ Concatenación directa — nunca hagas esto
app.get("/usuarios", async (req, res) => {
const nombre = req.query.nombre as string;
const result = await pool.query(
`SELECT * FROM usuarios WHERE nombre = '${nombre}'`
);
res.json(result.rows);
});
Si el cliente envía ?nombre=admin' OR '1'='1, la query que llega a PostgreSQL es:
SELECT * FROM usuarios WHERE nombre = 'admin' OR '1'='1'
La condición '1'='1' siempre es verdadera — devuelve todos los registros de la tabla. Con variantes más agresivas el atacante puede eliminar tablas, leer datos de otras tablas o ejecutar comandos del sistema.
Cómo protegerse #
Parámetros posicionales (semana 4 con pg):
// ✅ El valor nunca toca el SQL — llega como parámetro
const result = await pool.query(
"SELECT * FROM usuarios WHERE nombre = $1",
[nombre] // PostgreSQL trata esto como valor, no como SQL
);
Prisma (semana 5):
// ✅ Prisma siempre usa parámetros internamente
await prisma.usuario.findMany({ where: { nombre } });
$queryRaw con tagged template (semana 5):
// ✅ Las interpolaciones en tagged templates son parámetros seguros
await prisma.$queryRaw`SELECT * FROM usuarios WHERE nombre = ${nombre}`;
// ❌ $queryRawUnsafe con interpolación — misma vulnerabilidad que concatenar
await prisma.$queryRawUnsafe(`SELECT * FROM usuarios WHERE nombre = '${nombre}'`);
ORM o no ORM, la regla es la misma: los valores del usuario nunca deben aparecer concatenados en un string SQL.
2. IDOR — Insecure Direct Object Reference #
IDOR (Referencia Directa Insegura a Objetos) ocurre cuando un endpoint permite acceder a recursos usando un identificador enviado por el cliente, sin verificar si ese cliente tiene permiso para ver ese recurso.
Es la vulnerabilidad más frecuente en APIs REST y la más fácil de ignorar al programar.
Código vulnerable #
// ❌ Asume que el cliente solo pedirá sus propios datos
app.get("/pedidos/:id", autenticar, async (req, res) => {
const id = Number(req.params.id);
const pedido = await prisma.pedido.findUnique({ where: { id } });
if (!pedido) return res.status(404).json({ error: "No encontrado" });
res.json(pedido);
});
El middleware autenticar verifica que el JWT sea válido — el usuario está autenticado. Pero no verifica si pedido.usuarioId coincide con el ID del usuario del token.
Un usuario autenticado como id: 5 puede hacer GET /pedidos/12 y ver el pedido de id: 2 sin problema.
Cómo protegerse #
// ✅ Verificar que el recurso pertenece al usuario del token
app.get("/pedidos/:id", autenticar, async (req, res) => {
const id = Number(req.params.id);
const usuarioId = req.usuarioId; // extraído del JWT por el middleware
const pedido = await prisma.pedido.findUnique({ where: { id } });
if (!pedido) {
return res.status(404).json({ error: "No encontrado" });
}
// El pedido existe pero no le pertenece a este usuario
if (pedido.usuarioId !== usuarioId) {
return res.status(403).json({ error: "Acceso denegado" });
}
res.json(pedido);
});
O más limpio — incluir el usuarioId en el where directamente:
// ✅ Si el id no pertenece al usuario, findUnique devuelve null → 404
const pedido = await prisma.pedido.findUnique({
where: { id, usuarioId }, // solo encuentra el pedido si es del usuario correcto
});
if (!pedido) return res.status(404).json({ error: "No encontrado" });
Retornar
404en vez de403cuando el recurso existe pero no pertenece al usuario es una práctica común — no confirma al atacante que el recurso existe.
3. Exposición de Datos Sensibles #
Datos que nunca deberían salir del servidor aparecen en respuestas JSON, logs o mensajes de error.
Contraseña en la respuesta #
// ❌ Devuelve el hash de la contraseña al cliente
const usuario = await prisma.usuario.findUnique({ where: { id } });
res.json(usuario);
// { id: 1, email: "user@example.com", contrasena: "$2b$10$KIXy..." }
// ✅ Excluir antes de responder
const { contrasena, ...usuarioPublico } = usuario;
res.json(usuarioPublico);
// { id: 1, email: "user@example.com" }
Con Prisma, puedes excluirlo directamente en el query:
// ✅ select excluye el campo desde la consulta
const usuario = await prisma.usuario.findUnique({
where: { id },
select: { id: true, email: true, creadoEn: true },
// contrasena no está en select → no se consulta ni se devuelve
});
Secretos en el código #
// ❌ Hardcodeado — si el repositorio es público, el secreto está expuesto
const token = jwt.sign(payload, "mi-secreto-super-secreto");
// ❌ En comentarios o console.log
console.log("JWT_SECRET:", process.env.JWT_SECRET);
// ✅ Siempre desde variables de entorno, nunca loggeadas
const token = jwt.sign(payload, process.env.JWT_SECRET!);
Errores con stack trace en producción #
// ❌ Expone rutas internas, versiones de librerías y lógica del servidor
app.use((err, req, res, next) => {
res.status(500).json({ error: err.message, stack: err.stack });
});
// ✅ Mensajes genéricos en producción
app.use((err, req, res, next) => {
console.error(err); // loggear internamente para debugging
res.status(500).json({ error: "Error interno del servidor" });
});
4. Autenticación Rota #
Fallas en el sistema de autenticación que permiten acceder como otro usuario o mantener sesiones que deberían estar cerradas.
JWT con secreto débil o hardcodeado #
// ❌ Secreto corto y predecible — vulnerable a fuerza bruta
jwt.sign(payload, "secret");
jwt.sign(payload, "123456");
jwt.sign(payload, process.env.APP_NAME!); // string conocida
// ✅ Mínimo 32 bytes aleatorios generados con crypto
// node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
jwt.sign(payload, process.env.JWT_SECRET!);
Token sin expiración #
// ❌ Un token sin exp es válido para siempre
jwt.sign({ sub: userId }, SECRET);
// ✅ Siempre definir expiración
jwt.sign({ sub: userId }, SECRET, { expiresIn: "24h" });
Un token sin expiración filtrado es una puerta de entrada permanente a la cuenta.
Algoritmo “none” — una vulnerabilidad histórica de JWT #
Algunas implementaciones antiguas aceptaban un token con "alg": "none" — sin firma. Un atacante podía construir cualquier payload y el servidor lo aceptaba.
// ❌ No pasar algorithms al verificar — podría aceptar "alg": "none"
jwt.verify(token, SECRET);
// ✅ Especificar explícitamente los algoritmos aceptados
jwt.verify(token, SECRET, { algorithms: ["HS256"] });
Las librerías modernas de JWT ya no aceptan "alg": "none" por defecto, pero es buena práctica ser explícito.
5. Fuerza Bruta — Sin Rate Limiting #
Un endpoint de login sin límite de intentos permite que un atacante pruebe millones de combinaciones de contraseña automáticamente.
El problema #
// ❌ Sin límite de intentos — un script puede probar 10,000 contraseñas por segundo
app.post("/auth/login", async (req, res) => {
const { email, contrasena } = req.body;
const usuario = await prisma.usuario.findUnique({ where: { email } });
const valida = await bcrypt.compare(contrasena, usuario?.contrasena ?? "");
if (!valida) return res.status(401).json({ error: "Credenciales incorrectas" });
res.json({ token: generarToken(usuario.id) });
});
La solución — rate limiting por IP #
npm install express-rate-limit
import rateLimit from "express-rate-limit";
const limitarLogin = rateLimit({
windowMs: 15 * 60 * 1000, // ventana de 15 minutos
max: 10, // máximo 10 intentos por IP
message: { error: "Demasiados intentos. Espera 15 minutos." },
standardHeaders: true,
legacyHeaders: false,
});
app.post("/auth/login", limitarLogin, async (req, res) => { ... });
En NestJS existe @nestjs/throttler como la solución equivalente integrada con el sistema de módulos.
El rate limiting protege el endpoint de login. También es recomendable en endpoints de registro y de recuperación de contraseña.
6. CORS Mal Configurado #
CORS (Cross-Origin Resource Sharing) controla qué dominios pueden hacer peticiones a tu API desde el navegador. Una configuración permisiva abre la API a sitios maliciosos.
// ❌ Permite cualquier origen — cualquier sitio puede hacer peticiones a tu API
app.use(cors());
app.use(cors({ origin: "*" }));
// ❌ Reflejar el origen del request sin validar — tan peligroso como "*"
app.use(cors({
origin: (origin, callback) => callback(null, origin), // acepta todo
}));
// ✅ Lista explícita de orígenes permitidos
const ORIGENES_PERMITIDOS = [
"https://mi-frontend.com",
"https://staging.mi-frontend.com",
// en desarrollo:
"http://localhost:3000",
];
app.use(cors({
origin: (origin, callback) => {
if (!origin || ORIGENES_PERMITIDOS.includes(origin)) {
callback(null, true);
} else {
callback(new Error("Origen no permitido"));
}
},
credentials: true, // necesario si usas cookies con el JWT
}));
CORS solo protege peticiones desde navegadores — no bloquea herramientas como
curl, Postman o scripts de servidor. Es una capa de protección para el cliente web, no para el servidor.
7. Fallos de Configuración #
Configuraciones por defecto o descuidos que exponen información del servidor.
Headers que revelan tecnología #
Por defecto, Express añade X-Powered-By: Express a cada respuesta — le dice a cualquier atacante qué framework estás usando y qué vulnerabilidades conocidas buscar.
// ✅ Deshabilitar el header
app.disable("x-powered-by");
// ✅ O usar helmet — agrega varios headers de seguridad de una vez
npm install helmet
app.use(helmet());
helmet también configura headers como Content-Security-Policy, X-Frame-Options, Strict-Transport-Security y otros que protegen contra categorías enteras de ataques.
Variables de entorno que faltan — crash en producción #
// ❌ Falla en runtime si JWT_SECRET no está definida
const SECRET = process.env.JWT_SECRET;
jwt.sign(payload, SECRET); // SECRET podría ser undefined
// ✅ Validar al arrancar — fail fast con mensaje claro
if (!process.env.JWT_SECRET) {
console.error("FATAL: JWT_SECRET no está definida");
process.exit(1);
}
const SECRET = process.env.JWT_SECRET;
Modo debug activo en producción #
// ❌ Expone información interna en producción
app.set("env", "development"); // activa stack traces en Express
// ✅ Usar NODE_ENV y verificarlo
if (process.env.NODE_ENV !== "production") {
// logs detallados, swagger, etc.
}
8. Referencia Rápida — OWASP Top 10 para APIs #
OWASP (Open Worldwide Application Security Project) publica una lista actualizada de las vulnerabilidades más críticas. Para APIs REST, las más relevantes son:
| Vulnerabilidad | Síntoma típico | Mitigación principal |
|---|---|---|
| Broken Object Level Authorization | Un usuario accede a datos de otro cambiando el ID | Verificar recurso.usuarioId === req.usuarioId |
| Broken Authentication | Tokens sin expiración, secretos débiles | JWT con exp, SECRET larga y aleatoria |
| Broken Object Property Auth. | El cliente puede modificar campos que no debería | DTOs estrictos con class-transformer |
| Unrestricted Resource Consumption | Sin límite de requests, archivos gigantes | Rate limiting, límites de tamaño |
| Broken Function Level Auth. | Endpoints admin accesibles sin rol adecuado | Guard de roles en cada ruta protegida |
| Sensitive Data Exposure | Contraseñas, tokens o secretos en respuestas | select en Prisma, desestructurar antes de res.json |
| Security Misconfiguration | Headers que revelan tecnología, CORS abierto | helmet, lista blanca de orígenes |
| Injection | SQL construido con strings, comandos del sistema | Parámetros posicionales, Prisma, validación estricta |
Preguntas Frecuentes #
¿Prisma me protege completamente de SQL injection? #
Para findMany, create, update y similares, sí — Prisma siempre usa parámetros internamente. Para $queryRaw, solo estás protegido si usas la sintaxis de tagged template literal. $queryRawUnsafe con interpolación de strings no está protegido.
¿IDOR es lo mismo que falta de autenticación? #
No. IDOR ocurre cuando el usuario está autenticado pero puede acceder a recursos que no son suyos. La autenticación verifica identidad; la autorización verifica permisos. Un sistema puede tener autenticación perfecta y IDOR si no verifica que el recurso pertenece al usuario autenticado.
¿Rate limiting resuelve el problema de fuerza bruta completamente? #
Reduce significativamente el riesgo pero no es la única capa. Complementa con: contraseñas con requisitos mínimos de complejidad, bcrypt con saltRounds adecuado (hace lento cada intento), bloqueo temporal de cuenta después de N intentos fallidos, y notificaciones de intentos sospechosos al usuario.
¿Debo validar tanto en el DTO como en el modelo de Prisma? #
Son capas distintas. El DTO (con class-validator) valida que el input del cliente tiene la forma esperada — antes de que llegue a la base de datos. Prisma valida los tipos y constraints a nivel de schema. Ambas capas son necesarias: el DTO da mensajes de error amigables al cliente; Prisma es la última línea de defensa contra datos inválidos.
¿helmet es suficiente para seguridad de headers? #
helmet cubre bien los headers más importantes, pero no reemplaza una auditoría de seguridad. Puedes revisar tu configuración actual con herramientas como securityheaders.com o el panel de seguridad de las DevTools del navegador.
Resumen #
| Vulnerabilidad | Causa raíz | Fix |
|---|---|---|
| SQL Injection | Input concatenado en SQL | Parámetros posicionales / Prisma / $queryRaw tagged template |
| IDOR | No verificar recurso.usuarioId === req.usuarioId | Incluir el usuarioId en el where de Prisma |
| Hash en respuesta | res.json(usuario) sin excluir contrasena | select en Prisma o desestructurar antes de responder |
| Secreto hardcodeado | jwt.sign(payload, "secreto") | Variable de entorno validada al arrancar |
| Token sin exp | jwt.sign(payload, SECRET) sin expiresIn | { expiresIn: "24h" } siempre |
| Fuerza bruta | Sin rate limiting en login | express-rate-limit / @nestjs/throttler |
| CORS abierto | cors({ origin: "*" }) | Lista blanca explícita de orígenes |
| Headers que revelan stack | X-Powered-By: Express | helmet() o app.disable("x-powered-by") |
| Crash por env faltante | process.env.SECRET sin validar | Verificar al arrancar con process.exit(1) si falta |