Mejores Prácticas de Seguridad Web - Contramedidas OWASP Top 10

20 min de lectura | 2025.12.02

La seguridad de las aplicaciones web es un elemento importante que los desarrolladores deben considerar desde el principio. El Top 10 publicado por OWASP (Open Web Application Security Project) es una lista de las vulnerabilidades más comunes y peligrosas que todos los desarrolladores deben comprender. En este artículo, explicamos cada vulnerabilidad del OWASP Top 10 y las contramedidas prácticas.

OWASP Top 10 2021

Lista de Vulnerabilidades

RangoCategoríaDescripción
A01Control de Acceso DeficienteFalta o deficiencia en verificaciones de autorización
A02Fallas CriptográficasProtección inadecuada de datos sensibles
A03InyecciónSQL, XSS, inyección de comandos
A04Diseño InseguroDiseño sin considerar la seguridad
A05Configuración de Seguridad IncorrectaConfiguraciones por defecto, funciones innecesarias
A06Componentes VulnerablesBibliotecas con vulnerabilidades conocidas
A07Fallas de AutenticaciónDeficiencias en mecanismos de autenticación
A08Fallas de IntegridadActualizaciones inseguras, CI/CD
A09Fallas de Registro y MonitoreoFalla en detección de ataques
A10SSRFServer-Side Request Forgery

A03: Ataques de Inyección

Inyección SQL

// Código vulnerable
async function getUser(userId: string) {
  const query = `SELECT * FROM users WHERE id = '${userId}'`;
  return db.query(query);
}
// userId = "1' OR '1'='1" → Se obtienen todos los usuarios

// Código seguro (consulta parametrizada)
async function getUser(userId: string) {
  const query = 'SELECT * FROM users WHERE id = $1';
  return db.query(query, [userId]);
}

// Usando ORM (Prisma)
async function getUser(userId: string) {
  return prisma.user.findUnique({
    where: { id: userId }
  });
}

XSS (Cross-Site Scripting)

// Código vulnerable
function renderComment(comment: string) {
  document.getElementById('comments').innerHTML = comment;
}
// comment = "<script>alert('XSS')</script>" → Se ejecuta el script

// Código seguro (escape)
function escapeHtml(text: string): string {
  const div = document.createElement('div');
  div.textContent = text;
  return div.innerHTML;
}

function renderComment(comment: string) {
  const escaped = escapeHtml(comment);
  document.getElementById('comments').innerHTML = escaped;
}

// Más seguro: usar textContent
function renderComment(comment: string) {
  document.getElementById('comments').textContent = comment;
}

// React/Vue escapan automáticamente
function CommentList({ comments }: { comments: string[] }) {
  return (
    <ul>
      {comments.map((c, i) => <li key={i}>{c}</li>)}
    </ul>
  );
}

Content Security Policy (CSP)

// Configuración CSP en Express.js
import helmet from 'helmet';

app.use(helmet.contentSecurityPolicy({
  directives: {
    defaultSrc: ["'self'"],
    scriptSrc: ["'self'", "'strict-dynamic'"],
    styleSrc: ["'self'", "'unsafe-inline'"],
    imgSrc: ["'self'", "data:", "https:"],
    connectSrc: ["'self'", "https://api.example.com"],
    fontSrc: ["'self'", "https://fonts.gstatic.com"],
    objectSrc: ["'none'"],
    baseUri: ["'self'"],
    frameAncestors: ["'none'"],
    upgradeInsecureRequests: [],
  },
}));

A01: Control de Acceso Deficiente

Implementación de Verificación de Autorización

// Código vulnerable
app.get('/api/users/:id', async (req, res) => {
  const user = await db.user.findUnique({ where: { id: req.params.id } });
  res.json(user);  // Cualquiera puede acceder
});

// Código seguro
app.get('/api/users/:id', authenticate, async (req, res) => {
  const userId = req.params.id;
  const currentUser = req.user;

  // Solo el propio usuario o administrador pueden acceder
  if (userId !== currentUser.id && currentUser.role !== 'admin') {
    return res.status(403).json({ error: 'Forbidden' });
  }

  const user = await db.user.findUnique({ where: { id: userId } });
  res.json(user);
});

Contramedidas IDOR (Insecure Direct Object Reference)

// Código vulnerable
app.get('/api/orders/:orderId', async (req, res) => {
  const order = await db.order.findUnique({
    where: { id: req.params.orderId }
  });
  res.json(order);  // Se pueden ver pedidos de otros
});

// Código seguro
app.get('/api/orders/:orderId', authenticate, async (req, res) => {
  const order = await db.order.findFirst({
    where: {
      id: req.params.orderId,
      userId: req.user.id  // Verificación de propietario
    }
  });

  if (!order) {
    return res.status(404).json({ error: 'Order not found' });
  }

  res.json(order);
});

RBAC (Control de Acceso Basado en Roles)

// Definición de roles
const ROLES = {
  admin: ['read', 'write', 'delete', 'manage_users'],
  editor: ['read', 'write'],
  viewer: ['read'],
} as const;

// Middleware de autorización
function authorize(...requiredPermissions: string[]) {
  return (req: Request, res: Response, next: NextFunction) => {
    const userRole = req.user?.role;
    const userPermissions = ROLES[userRole] || [];

    const hasPermission = requiredPermissions.every(
      p => userPermissions.includes(p)
    );

    if (!hasPermission) {
      return res.status(403).json({ error: 'Insufficient permissions' });
    }

    next();
  };
}

// Ejemplo de uso
app.delete('/api/posts/:id',
  authenticate,
  authorize('delete'),
  deletePost
);

A07: Fallas de Autenticación

Procesamiento Seguro de Contraseñas

import bcrypt from 'bcrypt';
import crypto from 'crypto';

// Hash de contraseña
async function hashPassword(password: string): Promise<string> {
  const saltRounds = 12;
  return bcrypt.hash(password, saltRounds);
}

// Verificación de contraseña
async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

// Validación de fortaleza de contraseña
function validatePasswordStrength(password: string): { valid: boolean; errors: string[] } {
  const errors: string[] = [];

  if (password.length < 12) {
    errors.push('La contraseña debe tener al menos 12 caracteres');
  }
  if (!/[A-Z]/.test(password)) {
    errors.push('Debe incluir mayúsculas');
  }
  if (!/[a-z]/.test(password)) {
    errors.push('Debe incluir minúsculas');
  }
  if (!/[0-9]/.test(password)) {
    errors.push('Debe incluir números');
  }
  if (!/[!@#$%^&*]/.test(password)) {
    errors.push('Debe incluir caracteres especiales');
  }

  return { valid: errors.length === 0, errors };
}

Protección contra Fuerza Bruta

import rateLimit from 'express-rate-limit';
import RedisStore from 'rate-limit-redis';

// Límite de tasa (endpoint de login)
const loginLimiter = rateLimit({
  store: new RedisStore({
    client: redisClient,
    prefix: 'rl:login:'
  }),
  windowMs: 15 * 60 * 1000,  // 15 minutos
  max: 5,  // Máximo 5 intentos
  skipSuccessfulRequests: true,  // No contar solicitudes exitosas
  message: {
    error: 'Demasiados intentos de inicio de sesión. Por favor, intente de nuevo en 15 minutos.'
  }
});

app.post('/api/auth/login', loginLimiter, loginHandler);

// Función de bloqueo de cuenta
async function checkAccountLock(userId: string): Promise<boolean> {
  const lockKey = `lock:${userId}`;
  const failKey = `fail:${userId}`;

  const lockUntil = await redis.get(lockKey);
  if (lockUntil && Date.now() < parseInt(lockUntil)) {
    return true;  // Bloqueado
  }

  return false;
}

async function recordFailedAttempt(userId: string): Promise<void> {
  const failKey = `fail:${userId}`;
  const lockKey = `lock:${userId}`;

  const attempts = await redis.incr(failKey);
  await redis.expire(failKey, 3600);  // Reiniciar en 1 hora

  if (attempts >= 5) {
    // Bloquear por 30 minutos
    await redis.set(lockKey, Date.now() + 30 * 60 * 1000);
    await redis.expire(lockKey, 1800);
  }
}

Gestión de Sesiones

import session from 'express-session';
import RedisStore from 'connect-redis';

app.use(session({
  store: new RedisStore({ client: redisClient }),
  secret: process.env.SESSION_SECRET!,
  name: 'sessionId',  // Cambiar el predeterminado 'connect.sid'
  resave: false,
  saveUninitialized: false,
  cookie: {
    httpOnly: true,     // Inaccesible desde JavaScript
    secure: true,       // Requiere HTTPS
    sameSite: 'lax',    // Protección CSRF
    maxAge: 24 * 60 * 60 * 1000,  // 24 horas
  }
}));

// Invalidación de sesión al cerrar sesión
app.post('/api/auth/logout', (req, res) => {
  const sessionId = req.sessionID;

  req.session.destroy((err) => {
    if (err) {
      return res.status(500).json({ error: 'Error al cerrar sesión' });
    }

    // También eliminar la cookie
    res.clearCookie('sessionId');
    res.json({ message: 'Sesión cerrada' });
  });
});

Protección CSRF (Cross-Site Request Forgery)

Token CSRF

import csrf from 'csurf';

// Middleware CSRF
const csrfProtection = csrf({
  cookie: {
    httpOnly: true,
    secure: true,
    sameSite: 'strict'
  }
});

// Endpoint para obtener el token
app.get('/api/csrf-token', csrfProtection, (req, res) => {
  res.json({ csrfToken: req.csrfToken() });
});

// Endpoint protegido
app.post('/api/transfer', csrfProtection, (req, res) => {
  // El token CSRF se verifica automáticamente
  // ...
});
// Uso en el frontend
async function makeRequest(url: string, data: object) {
  // Obtener token CSRF
  const { csrfToken } = await fetch('/api/csrf-token').then(r => r.json());

  return fetch(url, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-CSRF-Token': csrfToken,
    },
    credentials: 'include',
    body: JSON.stringify(data),
  });
}
// Protección CSRF moderna
res.cookie('session', sessionId, {
  httpOnly: true,
  secure: true,
  sameSite: 'strict',  // o 'lax'
});

A02: Fallas Criptográficas

Cifrado de Datos

import crypto from 'crypto';

const ALGORITHM = 'aes-256-gcm';
const KEY = Buffer.from(process.env.ENCRYPTION_KEY!, 'hex');  // 32 bytes

// Cifrado
function encrypt(plaintext: string): string {
  const iv = crypto.randomBytes(12);  // GCM usa IV de 12 bytes
  const cipher = crypto.createCipheriv(ALGORITHM, KEY, iv);

  let encrypted = cipher.update(plaintext, 'utf8', 'hex');
  encrypted += cipher.final('hex');

  const authTag = cipher.getAuthTag();

  // Combinar IV + AuthTag + texto cifrado
  return iv.toString('hex') + authTag.toString('hex') + encrypted;
}

// Descifrado
function decrypt(ciphertext: string): string {
  const iv = Buffer.from(ciphertext.slice(0, 24), 'hex');
  const authTag = Buffer.from(ciphertext.slice(24, 56), 'hex');
  const encrypted = ciphertext.slice(56);

  const decipher = crypto.createDecipheriv(ALGORITHM, KEY, iv);
  decipher.setAuthTag(authTag);

  let decrypted = decipher.update(encrypted, 'hex', 'utf8');
  decrypted += decipher.final('utf8');

  return decrypted;
}

Forzar HTTPS

// Encabezado HSTS
app.use(helmet.hsts({
  maxAge: 31536000,  // 1 año
  includeSubDomains: true,
  preload: true,
}));

// Redirección de HTTP a HTTPS
app.use((req, res, next) => {
  if (req.headers['x-forwarded-proto'] !== 'https') {
    return res.redirect(301, `https://${req.hostname}${req.url}`);
  }
  next();
});

A10: SSRF (Server-Side Request Forgery)

Contramedidas SSRF

import { URL } from 'url';
import dns from 'dns/promises';

// Lista de hosts permitidos
const ALLOWED_HOSTS = ['api.example.com', 'cdn.example.com'];

async function fetchUrl(urlString: string): Promise<Response> {
  const url = new URL(urlString);

  // Verificación de protocolo
  if (!['http:', 'https:'].includes(url.protocol)) {
    throw new Error('Invalid protocol');
  }

  // Verificación de nombre de host
  if (!ALLOWED_HOSTS.includes(url.hostname)) {
    throw new Error('Host not allowed');
  }

  // Prevenir acceso a IPs privadas
  const addresses = await dns.resolve4(url.hostname);
  for (const addr of addresses) {
    if (isPrivateIP(addr)) {
      throw new Error('Private IP not allowed');
    }
  }

  return fetch(url.toString());
}

function isPrivateIP(ip: string): boolean {
  const parts = ip.split('.').map(Number);

  // Localhost
  if (parts[0] === 127) return true;

  // Direcciones privadas
  if (parts[0] === 10) return true;
  if (parts[0] === 172 && parts[1] >= 16 && parts[1] <= 31) return true;
  if (parts[0] === 192 && parts[1] === 168) return true;

  // Link-local
  if (parts[0] === 169 && parts[1] === 254) return true;

  return false;
}

Encabezados de Seguridad

Configuración de Encabezados Recomendados

import helmet from 'helmet';

app.use(helmet());

// Configuración individual
app.use(helmet.frameguard({ action: 'deny' }));  // Protección contra clickjacking
app.use(helmet.noSniff());  // Protección contra MIME type sniffing
app.use(helmet.xssFilter());  // Filtro XSS
app.use(helmet.referrerPolicy({ policy: 'strict-origin-when-cross-origin' }));

// Encabezados personalizados
app.use((req, res, next) => {
  res.setHeader('Permissions-Policy', 'geolocation=(), microphone=(), camera=()');
  next();
});

Lista de Verificación de Encabezados de Respuesta

Lista de verificación de encabezados de seguridad:

□ Content-Security-Policy
□ Strict-Transport-Security
□ X-Frame-Options
□ X-Content-Type-Options
□ Referrer-Policy
□ Permissions-Policy
□ X-XSS-Protection (legacy)

Seguridad de Dependencias

Escaneo de Vulnerabilidades

# npm audit
npm audit
npm audit fix

# Snyk
npx snyk test
npx snyk monitor

# GitHub Dependabot
# Configurar en .github/dependabot.yml
# .github/dependabot.yml
version: 2
updates:
  - package-ecosystem: "npm"
    directory: "/"
    schedule:
      interval: "weekly"
    open-pull-requests-limit: 10

Resumen

La seguridad web requiere un esfuerzo continuo.

Lista de Verificación para Desarrollo

  1. Validación de entrada: Validar y sanitizar toda entrada de usuario
  2. Autenticación y autorización: Implementar control de acceso adecuado
  3. Cifrado: Cifrar datos sensibles, forzar HTTPS
  4. Gestión de sesiones: Configuración segura de cookies
  5. Dependencias: Escaneo regular de vulnerabilidades

Lista de Verificación para Operaciones

  1. Registro y monitoreo: Mecanismos de detección de anomalías
  2. Respuesta a incidentes: Planificación de respuesta
  3. Auditorías regulares: Pruebas de penetración
  4. Capacitación: Mejora de la conciencia de seguridad de los desarrolladores

La seguridad no es una “función que se añade después”, sino un elemento que debe incorporarse desde la etapa de diseño.

Enlaces de Referencia

← Volver a la lista