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
| Rango | Categoría | Descripción |
|---|---|---|
| A01 | Control de Acceso Deficiente | Falta o deficiencia en verificaciones de autorización |
| A02 | Fallas Criptográficas | Protección inadecuada de datos sensibles |
| A03 | Inyección | SQL, XSS, inyección de comandos |
| A04 | Diseño Inseguro | Diseño sin considerar la seguridad |
| A05 | Configuración de Seguridad Incorrecta | Configuraciones por defecto, funciones innecesarias |
| A06 | Componentes Vulnerables | Bibliotecas con vulnerabilidades conocidas |
| A07 | Fallas de Autenticación | Deficiencias en mecanismos de autenticación |
| A08 | Fallas de Integridad | Actualizaciones inseguras, CI/CD |
| A09 | Fallas de Registro y Monitoreo | Falla en detección de ataques |
| A10 | SSRF | Server-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),
});
}
Cookie SameSite
// 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
- Validación de entrada: Validar y sanitizar toda entrada de usuario
- Autenticación y autorización: Implementar control de acceso adecuado
- Cifrado: Cifrar datos sensibles, forzar HTTPS
- Gestión de sesiones: Configuración segura de cookies
- Dependencias: Escaneo regular de vulnerabilidades
Lista de Verificación para Operaciones
- Registro y monitoreo: Mecanismos de detección de anomalías
- Respuesta a incidentes: Planificación de respuesta
- Auditorías regulares: Pruebas de penetración
- 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.