↓Ir al contenido

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 404 en vez de 403 cuando 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:

VulnerabilidadSíntoma típicoMitigación principal
Broken Object Level AuthorizationUn usuario accede a datos de otro cambiando el IDVerificar recurso.usuarioId === req.usuarioId
Broken AuthenticationTokens sin expiración, secretos débilesJWT con exp, SECRET larga y aleatoria
Broken Object Property Auth.El cliente puede modificar campos que no deberíaDTOs estrictos con class-transformer
Unrestricted Resource ConsumptionSin límite de requests, archivos gigantesRate limiting, límites de tamaño
Broken Function Level Auth.Endpoints admin accesibles sin rol adecuadoGuard de roles en cada ruta protegida
Sensitive Data ExposureContraseñas, tokens o secretos en respuestasselect en Prisma, desestructurar antes de res.json
Security MisconfigurationHeaders que revelan tecnología, CORS abiertohelmet, lista blanca de orígenes
InjectionSQL construido con strings, comandos del sistemaPará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 #

VulnerabilidadCausa raízFix
SQL InjectionInput concatenado en SQLParámetros posicionales / Prisma / $queryRaw tagged template
IDORNo verificar recurso.usuarioId === req.usuarioIdIncluir el usuarioId en el where de Prisma
Hash en respuestares.json(usuario) sin excluir contrasenaselect en Prisma o desestructurar antes de responder
Secreto hardcodeadojwt.sign(payload, "secreto")Variable de entorno validada al arrancar
Token sin expjwt.sign(payload, SECRET) sin expiresIn{ expiresIn: "24h" } siempre
Fuerza brutaSin rate limiting en loginexpress-rate-limit / @nestjs/throttler
CORS abiertocors({ origin: "*" })Lista blanca explícita de orígenes
Headers que revelan stackX-Powered-By: Expresshelmet() o app.disable("x-powered-by")
Crash por env faltanteprocess.env.SECRET sin validarVerificar al arrancar con process.exit(1) si falta