Fundamentos de Seguridad Web - OWASP Top 10 y Medidas de Defensa Prácticas

Intermedio | 2025.12.02

Importancia de la Seguridad Web

Las aplicaciones web están constantemente expuestas a riesgos de ataques. OWASP (Open Web Application Security Project) publica el Top 10, que resume los riesgos de seguridad más críticos.

flowchart TB
    subgraph OWASP["OWASP Top 10 2021"]
        A1["1. Broken Access Control<br/>(Control de Acceso Deficiente)"]
        A2["2. Cryptographic Failures<br/>(Fallas Criptográficas)"]
        A3["3. Injection<br/>(Inyección)"]
        A4["4. Insecure Design<br/>(Diseño Inseguro)"]
        A5["5. Security Misconfiguration<br/>(Configuración Incorrecta)"]
        A6["6. Vulnerable Components<br/>(Componentes Vulnerables)"]
        A7["7. Authentication Failures<br/>(Fallas de Autenticación)"]
        A8["8. Software Integrity Failures<br/>(Fallas de Integridad)"]
        A9["9. Logging Failures<br/>(Fallas de Registro y Monitoreo)"]
        A10["10. SSRF<br/>(Server-Side Request Forgery)"]
    end

XSS (Cross-Site Scripting)

Tipos de Ataques

flowchart TB
    subgraph XSS["Tipos de XSS"]
        subgraph Reflected["1. Reflected XSS (Reflejado)"]
            R1["URL: /search?q=script"] --> R2["HTML: Script insertado en resultados de búsqueda"]
        end

        subgraph Stored["2. Stored XSS (Almacenado)"]
            S1["Publicar comentario"] --> S2["Guardado en BD"] --> S3["Mostrado a otros usuarios"]
            Note1["El código de ataque permanece de forma persistente"]
        end

        subgraph DOM["3. DOM-based XSS"]
            D1["JavaScript del lado cliente<br/>inserta parámetros URL directamente en el DOM"]
        end
    end

Medidas de Defensa

// 1. Escape HTML
function escapeHtml(text: string): string {
  const map: Record<string, string> = {
    '&': '&amp;',
    '<': '&lt;',
    '>': '&gt;',
    '"': '&quot;',
    "'": '&#039;',
  };
  return text.replace(/[&<>"']/g, (m) => map[m]);
}

// 2. Usar escape automático del motor de plantillas
// React: JSX escapa automáticamente
<div>{userInput}</div> // Seguro

// Peligroso: evitar dangerouslySetInnerHTML
<div dangerouslySetInnerHTML={{ __html: userInput }} /> // ¡Peligroso!

// 3. Content Security Policy (CSP)
// next.config.js
const securityHeaders = [
  {
    key: 'Content-Security-Policy',
    value: `
      default-src 'self';
      script-src 'self' 'unsafe-eval';
      style-src 'self' 'unsafe-inline';
      img-src 'self' data: https:;
      font-src 'self';
      connect-src 'self' https://api.example.com;
    `.replace(/\n/g, ''),
  },
];

// 4. Uso de Cookie HttpOnly
res.cookie('session', token, {
  httpOnly: true,  // Inaccesible desde JavaScript
  secure: true,    // Requiere HTTPS
  sameSite: 'strict',
  maxAge: 3600000,
});

CSRF (Cross-Site Request Forgery)

sequenceDiagram
    participant U as Usuario
    participant B as Sitio del Banco
    participant A as Sitio del Atacante

    Note over U,B: 1. Usuario inicia sesión en sitio legítimo
    U->>B: Login
    B->>U: Cookie de Sesión

    Note over U,A: 2. Usuario accede al sitio trampa del atacante
    U->>A: Acceso
    A->>U: Formulario oculto<br/>(POST a bank.com/transfer)

    Note over U,B: 3. Solicitud maliciosa ejecutada con sesión del usuario
    U->>B: POST /transfer<br/>(to=attacker, amount=10000)
    B-->>U: Transferencia completada

Medidas de Defensa

// 1. Token CSRF
import { randomBytes } from 'crypto';

function generateCsrfToken(): string {
  return randomBytes(32).toString('hex');
}

// Guardar token en sesión
app.use((req, res, next) => {
  if (!req.session.csrfToken) {
    req.session.csrfToken = generateCsrfToken();
  }
  res.locals.csrfToken = req.session.csrfToken;
  next();
});

// Incluir token en formulario
<form action="/transfer" method="POST">
  <input type="hidden" name="_csrf" value={csrfToken} />
  ...
</form>

// Verificar solicitud
app.post('/transfer', (req, res) => {
  if (req.body._csrf !== req.session.csrfToken) {
    return res.status(403).json({ error: 'Invalid CSRF token' });
  }
  // Ejecutar procesamiento
});

// 2. Cookie SameSite
res.cookie('session', token, {
  sameSite: 'strict', // o 'lax'
  httpOnly: true,
  secure: true,
});

// 3. Verificación del encabezado Origin
app.use((req, res, next) => {
  const origin = req.headers.origin;
  const allowedOrigins = ['https://myapp.com'];

  if (req.method !== 'GET' && !allowedOrigins.includes(origin)) {
    return res.status(403).json({ error: 'Forbidden' });
  }
  next();
});

Inyección SQL

Código Vulnerable

// Ejemplo peligroso
app.get('/users', async (req, res) => {
  const { name } = req.query;
  // ¡Posible inyección SQL!
  const result = await db.query(`SELECT * FROM users WHERE name = '${name}'`);
  res.json(result);
});

// Ejemplo de ataque: ?name=' OR '1'='1
// SELECT * FROM users WHERE name = '' OR '1'='1'
// → Se obtienen todos los usuarios

Medidas de Defensa

// 1. Prepared Statements (Consultas Parametrizadas)
app.get('/users', async (req, res) => {
  const { name } = req.query;
  const result = await db.query(
    'SELECT * FROM users WHERE name = $1',
    [name]
  );
  res.json(result);
});

// 2. Uso de ORM
// Prisma
const users = await prisma.user.findMany({
  where: { name },
});

// Drizzle
const users = await db.select().from(usersTable).where(eq(usersTable.name, name));

// 3. Validación de entrada
import { z } from 'zod';

const QuerySchema = z.object({
  name: z.string().max(100).regex(/^[a-zA-Z0-9\s]+$/),
});

app.get('/users', async (req, res) => {
  const result = QuerySchema.safeParse(req.query);
  if (!result.success) {
    return res.status(400).json({ error: 'Invalid input' });
  }
  // ...
});

Seguridad de Autenticación

// Hash seguro de contraseñas
import bcrypt from 'bcrypt';

const SALT_ROUNDS = 12;

async function hashPassword(password: string): Promise<string> {
  return bcrypt.hash(password, SALT_ROUNDS);
}

async function verifyPassword(password: string, hash: string): Promise<boolean> {
  return bcrypt.compare(password, hash);
}

// Uso seguro de JWT
import jwt from 'jsonwebtoken';

const JWT_SECRET = process.env.JWT_SECRET!;
const JWT_EXPIRES_IN = '15m';
const REFRESH_TOKEN_EXPIRES_IN = '7d';

function generateTokens(userId: string) {
  const accessToken = jwt.sign({ userId }, JWT_SECRET, {
    expiresIn: JWT_EXPIRES_IN,
    algorithm: 'HS256',
  });

  const refreshToken = jwt.sign({ userId, type: 'refresh' }, JWT_SECRET, {
    expiresIn: REFRESH_TOKEN_EXPIRES_IN,
    algorithm: 'HS256',
  });

  return { accessToken, refreshToken };
}

// Protección contra fuerza bruta
import rateLimit from 'express-rate-limit';

const loginLimiter = rateLimit({
  windowMs: 15 * 60 * 1000, // 15 minutos
  max: 5, // hasta 5 intentos
  message: { error: 'Too many login attempts' },
  standardHeaders: true,
  legacyHeaders: false,
});

app.post('/login', loginLimiter, async (req, res) => {
  // Procesamiento de login
});

Encabezados de Seguridad

// middleware/security.ts
import helmet from 'helmet';

app.use(helmet());

// O configuración individual
app.use((req, res, next) => {
  // Filtro XSS
  res.setHeader('X-XSS-Protection', '1; mode=block');

  // Prevenir Content-Type sniffing
  res.setHeader('X-Content-Type-Options', 'nosniff');

  // Prevenir clickjacking
  res.setHeader('X-Frame-Options', 'DENY');

  // Forzar HTTPS
  res.setHeader('Strict-Transport-Security', 'max-age=31536000; includeSubDomains');

  // Política de referrer
  res.setHeader('Referrer-Policy', 'strict-origin-when-cross-origin');

  // Política de permisos
  res.setHeader('Permissions-Policy', 'camera=(), microphone=(), geolocation=()');

  next();
});

Validación de Entrada

import { z } from 'zod';
import DOMPurify from 'dompurify';

// Definición de esquema
const UserInputSchema = z.object({
  email: z.string().email().max(255),
  name: z.string().min(2).max(100).regex(/^[\p{L}\s]+$/u),
  bio: z.string().max(500).optional(),
  website: z.string().url().optional(),
});

// Sanitización
function sanitizeHtml(dirty: string): string {
  return DOMPurify.sanitize(dirty, {
    ALLOWED_TAGS: ['b', 'i', 'em', 'strong', 'a', 'p', 'br'],
    ALLOWED_ATTR: ['href'],
  });
}

// Validación de carga de archivos
const MAX_FILE_SIZE = 5 * 1024 * 1024; // 5MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp'];

function validateFile(file: File): boolean {
  if (file.size > MAX_FILE_SIZE) {
    throw new Error('File too large');
  }
  if (!ALLOWED_TYPES.includes(file.type)) {
    throw new Error('Invalid file type');
  }
  return true;
}

Lista de Verificación de Seguridad

CategoríaElemento a Verificar
AutenticaciónHash de contraseñas (bcrypt/Argon2)
AutenticaciónProtección contra fijación de sesión
AutenticaciónSoporte MFA
EntradaValidación de todas las entradas
EntradaConsultas SQL parametrizadas
SalidaEscape HTML
ComunicaciónHTTPS obligatorio
CookieHttpOnly, Secure, SameSite
EncabezadosConfiguración CSP, HSTS
DependenciasEscaneo de vulnerabilidades (npm audit)

Enlaces de Referencia

← Volver a la lista