La mayoría de las vulnerabilidades que comprometen aplicaciones web reales no son ataques sofisticados de película. Son errores conocidos, catalogados desde hace años, que se siguen repitiendo porque nadie los revisó a tiempo. El OWASP Top 10 es precisamente eso: una lista, mantenida por la comunidad de seguridad web, de los riesgos más críticos y más frecuentes en aplicaciones web.

Entender por qué ocurre cada fallo y cómo se ve en código real sirve mucho más que memorizar la lista de memoria o intentar convertirte en pentester de la noche a la mañana. Con eso ya evitas el 90% de los problemas que aparecen en aplicaciones hechas por equipos que "ya lo arreglarán más adelante". Esto aplica igual si construyes una API REST que si montas un servidor con Node.js y Express: los mismos fallos se repiten en cualquier stack.

¿Por qué merece la pena conocer el OWASP Top 10?

Porque la seguridad no se añade al final. Un fallo de control de acceso o una inyección SQL casi nunca aparecen por una decisión consciente de "no proteger esto"; aparecen porque nadie pensó en el caso de un usuario malicioso mientras escribía la función. Conocer estos patrones te permite reconocerlos mientras escribes el código, no después de que alguien los explote.

A continuación tienes las vulnerabilidades más comunes, cada una con un ejemplo de código vulnerable y su corrección. Están simplificadas para que se entienda el problema de raíz; en una aplicación real conviene combinar varias de estas defensas a la vez.

Las vulnerabilidades más comunes, explicadas con código

Ocurre cuando el texto que escribe un usuario se concatena directamente en una consulta a la base de datos. Un atacante puede escribir código SQL dentro de un campo de formulario y alterar la consulta original para leer o borrar datos que no debería.

javascript — vulnerable
// El usuario controla "email" directamente
const query = `SELECT * FROM usuarios WHERE email = '${email}'`;
db.query(query);
// Si email = "' OR '1'='1", la consulta devuelve TODOS los usuarios
javascript — corregido
// Consulta parametrizada: el valor nunca se interpreta como SQL
const query = 'SELECT * FROM usuarios WHERE email = ?';
db.query(query, [email]);

Sucede cuando insertas contenido escrito por un usuario directamente en el HTML de la página sin filtrarlo. Si ese contenido incluye una etiqueta <script>, el navegador de otro usuario la ejecutará como si fuera código legítimo de tu sitio.

javascript — vulnerable
// comentario.texto viene de un formulario público
div.innerHTML = comentario.texto;
// Un comentario como "<img src=x onerror=robarCookies()>" se ejecuta
javascript — corregido
// textContent inserta texto plano, nunca lo interpreta como HTML
div.textContent = comentario.texto;

Pasa cuando el sistema de login tiene fallos: contraseñas guardadas sin cifrar, sesiones que no caducan, o ningún límite de intentos que permita probar contraseñas por fuerza bruta sin restricción.

javascript — vulnerable
// La contraseña se guarda tal cual, en texto plano
usuario.password = passwordFormulario;
db.guardar(usuario);
javascript — corregido
const bcrypt = require('bcrypt');
usuario.password = await bcrypt.hash(passwordFormulario, 12);
db.guardar(usuario);
// Añade además un límite de intentos de login por IP/usuario

Ocurre cuando la aplicación confía en el cliente para decidir qué puede ver o modificar un usuario, en lugar de comprobarlo en el servidor. El caso típico es el IDOR: cambiar un ID en la URL y acceder a datos de otra persona.

javascript — vulnerable
// Cualquier usuario autenticado puede pedir el pedido de cualquiera
app.get('/pedidos/:id', async (req, res) => {
  const pedido = await Pedido.findById(req.params.id);
  res.json(pedido);
});
javascript — corregido
app.get('/pedidos/:id', async (req, res) => {
  const pedido = await Pedido.findById(req.params.id);
  if (!pedido || pedido.usuarioId !== req.usuario.id) {
    return res.status(403).json({ error: 'No autorizado' });
  }
  res.json(pedido);
});

Aparece cuando la aplicación se despliega con ajustes pensados para desarrollo: modo debug activo, mensajes de error con detalles internos, credenciales por defecto sin cambiar, o cabeceras de seguridad ausentes.

javascript — vulnerable
// Los errores en producción muestran la traza completa y la query SQL
app.use((err, req, res, next) => {
  res.status(500).json({ error: err.stack });
});
javascript — corregido
app.use((err, req, res, next) => {
  console.error(err); // el detalle va al log del servidor, no al cliente
  const mensaje = process.env.NODE_ENV === 'production'
    ? 'Ha ocurrido un error'
    : err.message;
  res.status(500).json({ error: mensaje });
});

Cada dependencia de tu package.json es código que no escribiste tú pero que se ejecuta con los mismos permisos que el tuyo. Una librería con una vulnerabilidad conocida y sin actualizar es una puerta abierta, aunque tu propio código sea impecable.

bash — diagnóstico y corrección
# Revisa qué dependencias tienen vulnerabilidades conocidas
npm audit

# Aplica las correcciones automáticas disponibles
npm audit fix

# Revisa manualmente las que requieren un cambio de versión mayor
npm outdated

Sucede cuando se usan algoritmos débiles u obsoletos para proteger datos sensibles —contraseñas, tokens, datos personales— o directamente no se cifran datos que deberían estarlo, tanto en la base de datos como en tránsito por la red.

javascript — vulnerable
const crypto = require('crypto');
// MD5 es rápido de romper y no está pensado para contraseñas
const hash = crypto.createHash('md5').update(password).digest('hex');
javascript — corregido
const bcrypt = require('bcrypt');
// bcrypt añade "salt" automáticamente y está diseñado para ser lento
const hash = await bcrypt.hash(password, 12);

Ocurre cuando el código confía en datos o paquetes sin verificar su origen: deserializar contenido de una fuente no confiable ejecutando código a partir de él, o instalar dependencias sin comprobar que no han sido manipuladas. El caso más común en el día a día es usar eval() o equivalentes para "parsear" datos externos en lugar de un parser seguro.

javascript — vulnerable
// Ejecuta cualquier código que venga en "datos", no solo JSON
const config = eval('(' + datosExternos + ')');
javascript — corregido
// JSON.parse solo interpreta datos, nunca ejecuta código
const config = JSON.parse(datosExternos);

Lo mismo aplica a dependencias: usar un package-lock.json o pnpm-lock.yaml versionado evita que una actualización silenciosa de una librería introduzca código malicioso sin que te des cuenta.

Sin registros, un ataque puede pasar semanas sin detectarse. El error típico es capturar un error y no dejar rastro de él, sobre todo en eventos sensibles como intentos de login fallidos, cambios de permisos o accesos denegados.

javascript — vulnerable
try {
  await autenticar(usuario, password);
} catch (err) {
  // el fallo desaparece, nadie se entera de los intentos de login
}
javascript — corregido
try {
  await autenticar(usuario, password);
} catch (err) {
  logger.warn('login_fallido', { usuario, ip: req.ip, motivo: err.message });
  throw err;
}

No hace falta un sistema complejo desde el primer día: basta con registrar los eventos de seguridad con suficiente contexto (quién, cuándo, desde dónde) para poder reconstruir qué pasó si algo falla.

Pasa cuando el servidor hace una petición a una URL que controla el usuario sin validarla. Un atacante puede aprovechar esto para hacer que tu propio servidor acceda a servicios internos que no deberían ser públicos, como metadatos de la nube o servicios en localhost.

javascript — vulnerable
// El usuario controla qué URL consulta tu servidor
app.post('/preview', async (req, res) => {
  const respuesta = await fetch(req.body.url);
  res.send(await respuesta.text());
});
javascript — corregido
const DOMINIOS_PERMITIDOS = ['api.miproveedor.com'];

app.post('/preview', async (req, res) => {
  const url = new URL(req.body.url);
  if (!DOMINIOS_PERMITIDOS.includes(url.hostname)) {
    return res.status(400).send('Dominio no permitido');
  }
  const respuesta = await fetch(url);
  res.send(await respuesta.text());
});

Cómo priorizar la seguridad sin frenar el desarrollo

No todo el mundo tiene tiempo de auditar cada línea contra los diez puntos de la lista. Un criterio práctico: revisa siempre los puntos de entrada de datos del usuario (formularios, parámetros de URL, cabeceras, ficheros subidos), porque ahí es donde nacen la mayoría de estos fallos. Y trata cualquier dato que venga del cliente como potencialmente hostil, aunque tu propio frontend lo valide primero —esa validación se puede saltar con una simple petición manual a tu API.

Esto conecta directamente con escribir código limpio y legible: cuanto más clara es la lógica de validación y control de acceso, más fácil es detectar en una revisión de código el punto exacto donde falta una comprobación.

La seguridad no es un extra, es parte de programar bien

Ninguna de las correcciones anteriores exige herramientas caras ni conocimientos avanzados de criptografía. Son hábitos: parametrizar consultas, escapar salida, hashear contraseñas con el algoritmo correcto, comprobar permisos en el servidor y mantener dependencias al día. Incorporarlos desde el primer proyecto es mucho más barato que corregirlos después de una brecha real.

JR

Javier Rodríguez Fernández

Programador con Grado Superior en Desarrollo de Aplicaciones Multiplataforma (DAM). Escribe en DevPulso sobre programación aplicada, sin relleno.