StackPractices
advanced Por Mathias Paulenko

Guía completa del OWASP Top 10 2025

Mitiga cada riesgo de la lista OWASP Top 10 2025 con ejemplos de código prácticos. Cubre las nuevas categorías de cadena de suministro y gestión de excepciones, además de las clásicas reordenadas.

El OWASP Top 10 es el documento de referencia para la seguridad de aplicaciones web: una clasificación consensuada de los riesgos más críticos, reconstruida a partir de datos reales cada pocos años. La edición 2025 es la primera actualización desde 2021, y va más allá de reordenar categorías: dos son nuevas, una se amplió hasta convertirse en algo mayor, y SSRF perdió su categoría propia por completo.

La lista se construye desde dos fuentes: datos de vulnerabilidades anonimizados aportados por organizaciones de testing (cientos de miles de aplicaciones reales analizadas) y una encuesta a la comunidad que cubre los riesgos que los datos brutos infravaloran, como el diseño inseguro. Es decir, la clasificación refleja lo que realmente se rompe en producción, no lo que suena aterrador sobre el papel.

Si construiste tu checklist de seguridad sobre la lista de 2021, varias de tus prioridades necesitan renumeración antes que otra cosa.

Qué cambió de 2021 a 2025

Posición 2025CategoríaQué pasó
A01Broken Access ControlSe mantiene en el #1 — SSRF pasó a integrarse aquí
A02Security MisconfigurationSaltó del #5 al #2, empujada por la nube y los defaults de los frameworks
A03Software Supply Chain FailuresNueva — amplía los antiguos “componentes vulnerables” a toda la cadena: dependencias, sistemas de build, distribución
A04Cryptographic FailuresBajó desde el #2
A05InjectionBajó desde el #3
A06Insecure DesignBajó desde el #4
A07Authentication FailuresMisma posición, nombre más corto (antes “Identification and Authentication Failures”)
A08Software or Data Integrity FailuresSin cambios
A09Security Logging & Alerting FailuresRenombrada — “Monitoring” pasó a “Alerting”, porque los logs que nadie revisa no cuentan
A10Mishandling of Exceptional ConditionsNueva — rutas de error, defaults que fallan abiertos, casos límite sin gestionar

El resto de la guía recorre cada categoría 2025 con código vulnerable y mitigaciones funcionales en Python, JavaScript y Docker.

Un aviso antes de planificar por el ranking: no tomes el orden A01-A10 como el orden de tu propio backlog. La posición refleja prevalencia en cientos de miles de aplicaciones. Tu exposición depende de tu stack y tu arquitectura. Un equipo con un SPA detrás de un WAF se expone a la inyección de forma distinta que uno que opera veinte servicios internos. Usa la lista como checklist de cobertura y ordena tus arreglos por lo que un atacante puede alcanzar de verdad.

A01: Broken Access Control

Broken access control mantuvo el primer puesto, y los datos que lo sostienen son contundentes: la inmensa mayoría de aplicaciones analizadas presenta al menos un CWE en esta categoría. Cubre dos modos de fallo: escalada horizontal, donde un usuario lee o modifica datos de otro, y escalada vertical, donde un usuario normal alcanza funciones de administrador.

El código vulnerable

# VULNERABLE: Sin chequeo de control de acceso
@app.get("/api/users/{user_id}")
def get_user(user_id: int):
    user = db.query(User).filter(User.id == user_id).first()
    return user

# VULNERABLE: Confiar en el rol enviado por el cliente
@app.put("/api/users/{user_id}/role")
def update_role(user_id: int, role: str):
    user = db.query(User).filter(User.id == user_id).first()
    user.role = role  # Cualquiera puede asignar cualquier rol
    db.commit()

Mitigación

from fastapi import Depends, HTTPException

def get_current_user(token: str = Depends(oauth2_scheme)):
    # Validar el token y devolver el usuario
    user = decode_jwt(token)
    if not user:
        raise HTTPException(status_code=401, detail="Invalid token")
    return user

def require_role(required_role: str):
    def role_checker(current_user: User = Depends(get_current_user)):
        if current_user.role != required_role:
            raise HTTPException(status_code=403, detail="Insufficient permissions")
        return current_user
    return role_checker

@app.get("/api/users/{user_id}")
def get_user(
    user_id: int,
    current_user: User = Depends(get_current_user)
):
    # Control de acceso horizontal: cada usuario solo ve sus propios datos
    if current_user.id != user_id and current_user.role != "admin":
        raise HTTPException(status_code=403, detail="Access denied")

    user = db.query(User).filter(User.id == user_id).first()
    if not user:
        raise HTTPException(status_code=404, detail="User not found")
    return user

@app.put("/api/users/{user_id}/role")
def update_role(
    user_id: int,
    role: str,
    current_user: User = Depends(require_role("admin"))
):
    user = db.query(User).filter(User.id == user_id).first()
    user.role = role
    db.commit()
    return {"status": "updated"}

Mitigación en JavaScript

// Middleware Express para control de acceso
function requireOwnership(paramName = 'userId') {
    return (req, res, next) => {
        const resourceId = parseInt(req.params[paramName]);
        if (req.user.id !== resourceId && req.user.role !== 'admin') {
            return res.status(403).json({ error: 'Access denied' });
        }
        next();
    };
}

function requireRole(role) {
    return (req, res, next) => {
        if (req.user.role !== role) {
            return res.status(403).json({ error: 'Insufficient permissions' });
        }
        next();
    };
}

// Aplicar el middleware
app.get('/api/users/:userId', authenticate, requireOwnership(), getUser);
app.put('/api/users/:userId/role', authenticate, requireRole('admin'), updateRole);

Dónde queda SSRF ahora

En 2021, Server-Side Request Forgery tenía su propia categoría A10. La edición 2025 lo integró dentro de Broken Access Control — que además es el modelo mental correcto: un ataque SSRF es tu servidor accediendo a un recurso que no debería, por cuenta del atacante. Misma causa raíz (una comprobación de autorización ausente) aplicada a un actor distinto.

# VULNERABLE: Petición a una URL suministrada por el usuario sin validación
import requests

def fetch_url(url: str) -> str:
    response = requests.get(url)
    return response.text
    # url = "http://169.254.169.254/latest/meta-data/" (metadata de AWS)
    # url = "http://localhost:8080/admin"
    # url = "http://10.0.0.1/servicio-interno"
import ipaddress
import socket
import requests
from urllib.parse import urlparse

class SSRFProtector:
    BLOCKED_IP_RANGES = [
        ipaddress.ip_network("10.0.0.0/8"),
        ipaddress.ip_network("172.16.0.0/12"),
        ipaddress.ip_network("192.168.0.0/16"),
        ipaddress.ip_network("169.254.0.0/16"),  # Link-local / metadata de nube
        ipaddress.ip_network("127.0.0.0/8"),     # Loopback
        ipaddress.ip_network("0.0.0.0/8"),
        ipaddress.ip_network("::1/128"),         # Loopback IPv6
        ipaddress.ip_network("fc00::/7"),        # ULA IPv6
    ]

    ALLOWED_SCHEMES = {"http", "https"}
    ALLOWED_PORTS = {80, 443, 8080, 8443}

    def validate_url(self, url: str) -> bool:
        parsed = urlparse(url)

        if parsed.scheme not in self.ALLOWED_SCHEMES:
            return False

        port = parsed.port or (443 if parsed.scheme == "https" else 80)
        if port not in self.ALLOWED_PORTS:
            return False

        # Resolver el hostname — lo que importa es la respuesta DNS, no el texto
        try:
            ips = socket.getaddrinfo(parsed.hostname, port)
            for ip_info in ips:
                ip = ipaddress.ip_address(ip_info[4][0])
                for blocked in self.BLOCKED_IP_RANGES:
                    if ip in blocked:
                        return False
        except socket.gaierror:
            return False

        return True

    def safe_fetch(self, url: str, timeout: int = 10) -> str:
        if not self.validate_url(url):
            raise ValueError("URL validation failed")

        # Redirects desactivados: cada salto requeriría revalidación de todos modos
        response = requests.get(url, timeout=timeout, allow_redirects=False)

        if response.status_code in (301, 302, 307, 308):
            location = response.headers.get("Location", "")
            if not self.validate_url(location):
                raise ValueError("Redirect target validation failed")

        return response.text

protector = SSRFProtector()

def fetch_url(url: str) -> str:
    return protector.safe_fetch(url)

La parte sutil es socket.getaddrinfo. Validar el texto del hostname no sirve de nada si evil.com resuelve a 169.254.169.254. Comprueba las IPs resueltas, no la cadena de la URL. Y revalida cada destino de redirección, porque el primer salto puede apuntar a una URL inocua que devuelve un 302 directo a tu VPC.

A02: Security Misconfiguration

La mala configuración saltó tres puestos hasta el #2, y el motivo se ve venir: cada vez más superficie de ataque vive en la configuración, no en el código. Defaults de frameworks, buckets de nube abiertos, páginas de error verbosas, credenciales de fábrica, modo debug olvidado en producción. Nada de esto son bugs que hayas escrito, y justo por eso se cuelan entre las revisiones.

Mitigación

# Configuración segura de FastAPI
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.middleware.httpsredirect import HTTPSRedirectMiddleware
from fastapi.responses import JSONResponse

app = FastAPI(
    docs_url=None,        # Desactivar docs en producción
    redoc_url=None,
    openapi_url=None
)

# CORS estricto
app.add_middleware(
    CORSMiddleware,
    allow_origins=["https://stackpractices.com"],  # Solo orígenes concretos
    allow_credentials=True,
    allow_methods=["GET", "POST", "PUT", "DELETE"],
    allow_headers=["Authorization", "Content-Type"],
)

# Redirección a HTTPS
app.add_middleware(HTTPSRedirectMiddleware)

# Middleware de cabeceras de seguridad
@app.middleware("http")
async def add_security_headers(request, call_next):
    response = await call_next(request)
    response.headers["X-Content-Type-Options"] = "nosniff"
    response.headers["X-Frame-Options"] = "DENY"
    response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
    response.headers["Content-Security-Policy"] = "default-src 'self'"
    response.headers["Referrer-Policy"] = "strict-origin-when-cross-origin"
    response.headers["Permissions-Policy"] = "geolocation=(), microphone=(), camera=()"
    return response

# Manejador genérico de errores — sin stack traces
@app.exception_handler(Exception)
async def global_exception_handler(request, exc):
    return JSONResponse(
        status_code=500,
        content={"detail": "Internal server error"}
    )

Configuración segura en Docker

# Dockerfile seguro
FROM python:3.12-slim AS base

# Ejecutar como usuario no-root
RUN useradd -m -u 1001 appuser
USER appuser

WORKDIR /app

# Copiar solo lo necesario
COPY --chown=appuser:appuser requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY --chown=appuser:appuser . .

CMD ["python", "-m", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

HEALTHCHECK --interval=30s --timeout=3s CMD curl -f http://localhost:8000/health || exit 1

El patrón a interiorizar: cada default es una decisión que alguien tomó por ti, y los atacantes conocen los defaults mejor que tú. Desactiva lo que no uses, cierra lo que conserves, y trata X-XSS-Protection como legado: los navegadores modernos la eliminaron porque causaba más daño que beneficio, así que quitarla de tu lista de cabeceras está bien.

A03: Software Supply Chain Failures

Este es el cambio estructural más grande de la lista 2025. Los antiguos “componentes vulnerables y desactualizados” planteaban una pregunta estrecha: ¿tus dependencias están parcheadas? La nueva categoría pregunta algo más amplio: ¿puedes confiar en el código que no escribiste ni en la cadena que te lo pone en manos? Eso cubre paquetes comprometidos, typosquatting, actualizaciones maliciosas, sistemas de build alterados, secretos de CI filtrados y artefactos sin firmar.

flowchart diagram: Código fuente

Cada flecha es una frontera de confianza donde código que no escribiste se convierte en código que publicas.

Mitigación

# Python: comprobar vulnerabilidades conocidas
pip install pip-audit
pip-audit

# Node.js: comprobar vulnerabilidades
npm audit
npm audit fix

# Java: comprobar dependencias
./gradlew dependencyCheck
# Escaneo automatizado de dependencias en CI/CD — .github/workflows/security.yml
name: Security Scanning
on: [push, pull_request]

jobs:
  security:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: Python dependency check
        run: |
          pip install pip-audit
          pip-audit --strict
      - name: npm audit
        run: |
          npm audit --audit-level=high
      - name: Trivy filesystem scan
        run: |
          trivy fs --severity HIGH,CRITICAL .

El escaneo cubre la mitad “vulnerable”. Para la mitad “failures” (un atacante enviándote una versión maliciosa en lugar de una desactualizada), fija las dependencias con lockfiles y hashes (pip install --require-hashes, npm ci en lugar de npm install), genera un SBOM para poder responder “¿nos afecta?” en minutos en vez de días, y firma o verifica los artefactos de build antes de desplegarlos. Herramientas como sigstore/cosign hacen la verificación de artefactos viable incluso para equipos pequeños.

A04: Cryptographic Failures

Esta categoría bajó dos posiciones pero sus modos de fallo no han cambiado: algoritmos débiles o rotos, claves hardcodeadas o commiteadas al repositorio, contraseñas en texto plano o hasheadas con MD5/SHA-1, y datos sensibles en tránsito sin TLS.

El código vulnerable

# VULNERABLE: Guardar contraseñas en texto plano
def register_user(username, password):
    user = User(username=username, password=password)  # ¡Texto plano!
    db.add(user)
    db.commit()

# VULNERABLE: Hash débil
import hashlib
def hash_password(password):
    return hashlib.md5(password.encode()).hexdigest()  # MD5 está roto

# VULNERABLE: Clave de cifrado hardcodeada
ENCRYPTION_KEY = "my_secret_key_123"

Mitigación

import bcrypt
import os
import secrets
from cryptography.fernet import Fernet

# Hash seguro de contraseñas con bcrypt
def hash_password(password: str) -> str:
    salt = bcrypt.gensalt(rounds=12)
    return bcrypt.hashpw(password.encode(), salt).decode()

def verify_password(password: str, hashed: str) -> bool:
    return bcrypt.checkpw(password.encode(), hashed.encode())

def register_user(username: str, password: str):
    hashed = hash_password(password)
    user = User(username=username, password_hash=hashed)
    db.add(user)
    db.commit()

# Cifrado con claves desde el entorno
def get_encryption_key() -> bytes:
    key = os.environ.get("ENCRYPTION_KEY")
    if not key:
        raise RuntimeError("ENCRYPTION_KEY not set")
    return key.encode()

def encrypt_data(plaintext: str) -> str:
    f = Fernet(get_encryption_key())
    return f.encrypt(plaintext.encode()).decode()

def decrypt_data(ciphertext: str) -> str:
    f = Fernet(get_encryption_key())
    return f.decrypt(ciphertext.encode()).decode()

# Generación segura de tokens
def generate_api_token() -> str:
    return secrets.token_urlsafe(32)  # Token de 256 bits

Dos reglas cubren la mayor parte de esta categoría: nunca diseñes tu propia criptografía, y nunca guardes un secreto en el control de versiones. bcrypt para contraseñas, Fernet/AES-GCM para datos en reposo, TLS para datos en tránsito, y un gestor de secretos o variables de entorno para las claves. Para la gestión de claves en detalle, consulta la Guía completa de gestión de secretos.

A05: Injection

La inyección cayó al puesto #5. Las consultas parametrizadas y los ORM empujaron la SQLi clásica hacia abajo, pero no ha desaparecido. La categoría cubre inyección SQL, NoSQL, de comandos y LDAP, y los proyectos modernos siguen encontrando nuevos intérpretes donde inyectar datos no confiables: motores de plantillas, llamadas a shell, incluso prompts de LLM.

El código vulnerable

# VULNERABLE: Inyección SQL
def get_user_by_email(email):
    query = f"SELECT * FROM users WHERE email = '{email}'"
    return db.execute(query)
    # Ataque: email = "' OR 1=1 --" devuelve todos los usuarios

# VULNERABLE: Inyección de comandos
import subprocess
def ping_host(host):
    result = subprocess.run(f"ping -c 1 {host}", shell=True)
    return result.stdout
    # Ataque: host = "localhost; rm -rf /"

Mitigación

# SEGURO: Consultas parametrizadas
def get_user_by_email(email: str):
    query = "SELECT * FROM users WHERE email = :email"
    return db.execute(query, {"email": email})

# SEGURO: ORM con parametrización integrada
def get_user_by_email(email: str):
    return db.query(User).filter(User.email == email).first()

# SEGURO: Ejecución de comandos sin shell
import re
import subprocess

def ping_host(host: str):
    if not re.match(r'^[a-zA-Z0-9.\-]+$', host):
        raise ValueError("Invalid hostname")

    result = subprocess.run(
        ["ping", "-c", "1", host],
        capture_output=True,
        text=True,
        shell=False  # Nunca shell=True con entrada de usuario
    )
    return result.stdout

# SEGURO: Prevención de inyección NoSQL
def find_user(query_dict: dict):
    sanitized = {}
    for key, value in query_dict.items():
        if isinstance(value, dict) and any(k.startswith('$') for k in value):
            raise ValueError("Operators not allowed")
        sanitized[key] = value
    return db.users.find_one(sanitized)

La defensa siempre tiene la misma forma: mantener datos y código en canales separados. Consultas parametrizadas para SQL, arrays de argumentos en lugar de shell=True para subprocess, listas de permitidos para lo que no se puede parametrizar. El ejemplo NoSQL importa más de lo que parece. Los operadores de MongoDB como $where o $gt son vectores de inyección que los filtros clásicos de SQLi ni tocan.

A06: Insecure Design

El diseño inseguro es la única categoría que no se puede parchear: habla de decisiones tomadas antes de que existiera el código. Un registro sin rate limit, una API que confía en el rol que envía el cliente, un reset de contraseña sin verificación de email: funcionan exactamente como se diseñaron, y ese es el problema.

Mitigación: patrones de diseño que cierran el hueco

# Patrón de diseño: Rate limiting en el endpoint de registro
from collections import defaultdict
from datetime import datetime, timedelta

class RegistrationRateLimiter:
    def __init__(self):
        self.attempts: dict[str, list[datetime]] = defaultdict(list)
        self.max_attempts = 3
        self.window_hours = 24

    def can_register(self, ip: str, email: str) -> bool:
        now = datetime.now()
        cutoff = now - timedelta(hours=self.window_hours)

        key = f"{ip}:{email}"
        self.attempts[key] = [t for t in self.attempts[key] if t > cutoff]

        if len(self.attempts[key]) >= self.max_attempts:
            return False

        self.attempts[key].append(now)
        return True

# Patrón de diseño: Defaults seguros
class UserAccount:
    def __init__(self, username: str, email: str):
        self.username = username
        self.email = email
        self.is_verified = False  # Default: sin verificar
        self.is_active = False    # Default: inactivo hasta verificar
        self.mfa_enabled = False
        self.failed_login_count = 0
        self.locked_until = None

    def verify_email(self, token: str) -> bool:
        if self._validate_token(token):
            self.is_verified = True
            self.is_active = True
            return True
        return False

# Patrón de diseño: Principio de mínimo privilegio
class Permission:
    READ = "read"
    WRITE = "write"
    DELETE = "delete"
    ADMIN = "admin"

class Role:
    VIEWER = {Permission.READ}
    EDITOR = {Permission.READ, Permission.WRITE}
    ADMIN = {Permission.READ, Permission.WRITE, Permission.DELETE, Permission.ADMIN}

def check_permission(user_role: set, required: str) -> bool:
    return required in user_role

El arreglo para el diseño inseguro no es una librería: es hacer threat modeling antes de construir. Pregunta qué gana un atacante con cada endpoint, diseña casos de abuso junto a los casos de uso, y configúralo todo por defecto al estado más restrictivo (is_active = False hasta demostrar lo contrario). Rehacer estas decisiones tras el lanzamiento cuesta diez veces lo que cuesta diseñarlas.

A07: Authentication Failures

Misma posición que en 2021, nombre más corto. Los fallos son los de siempre: contraseñas débiles permitidas, login sin rate limiting, tokens de sesión que nunca caducan, credential stuffing que funciona porque no hay bloqueo, y flujos de reset de contraseña que filtran si un email existe.

Mitigación

import secrets
from datetime import datetime, timedelta

import bcrypt

class AuthService:
    MAX_LOGIN_ATTEMPTS = 5
    LOCKOUT_DURATION = timedelta(minutes=15)

    def __init__(self, db, email_service):
        self.db = db
        self.email_service = email_service

    def login(self, username: str, password: str) -> dict:
        user = self.db.query(User).filter(User.username == username).first()

        # Ejecutar bcrypt siempre — evita timing attacks que revelan usuarios válidos
        if not user:
            bcrypt.checkpw(password.encode(), b"$2b$12$dummyhash")
            raise AuthError("Invalid credentials")

        if user.locked_until and user.locked_until > datetime.now():
            raise AuthError(f"Account locked until {user.locked_until}")

        if not bcrypt.checkpw(password.encode(), user.password_hash.encode()):
            user.failed_login_count += 1
            if user.failed_login_count >= self.MAX_LOGIN_ATTEMPTS:
                user.locked_until = datetime.now() + self.LOCKOUT_DURATION
                user.failed_login_count = 0
            self.db.commit()
            raise AuthError("Invalid credentials")

        # Reset tras login correcto
        user.failed_login_count = 0
        user.locked_until = None

        token = secrets.token_urlsafe(32)
        user.session_token = token
        user.session_expires = datetime.now() + timedelta(hours=1)
        self.db.commit()

        return {"token": token, "expires_in": 3600}

    def logout(self, token: str):
        user = self.db.query(User).filter(User.session_token == token).first()
        if user:
            user.session_token = None
            user.session_expires = None
            self.db.commit()

    def initiate_password_reset(self, email: str):
        user = self.db.query(User).filter(User.email == email).first()
        if not user:
            # No revelar si el email existe
            return

        reset_token = secrets.token_urlsafe(32)
        user.reset_token = reset_token
        user.reset_expires = datetime.now() + timedelta(hours=1)
        self.db.commit()

        self.email_service.send(user.email, "Password Reset", f"Token: {reset_token}")

La línea anti-timing-attack se pasa por alto con facilidad: si el código omite bcrypt.checkpw cuando el usuario no existe, el tiempo de respuesta le dice al atacante qué nombres de usuario son válidos. Ejecutar el hash sobre un valor dummy mantiene el timing idéntico en ambos casos. Para el panorama completo (OAuth, JWT, sesiones, MFA), consulta la Guía completa de patrones de autenticación.

A08: Software or Data Integrity Failures

Los fallos de integridad conviven con los de cadena de suministro pero responden a otra pregunta. A03 pregunta “¿puedes confiar en el origen de tu código?” A08 pregunta “¿puedes confiar en lo que vas a ejecutar?”. Deserialización insegura, actualizaciones sin firmar, y confiar en payloads de webhooks o artefactos de CI sin verificar, todo vive aquí.

Mitigación

import hashlib
import hmac

import jwt

# Verificar firmas de webhooks
def verify_webhook(payload: bytes, signature: str, secret: str) -> bool:
    expected = hmac.new(
        secret.encode(),
        payload,
        hashlib.sha256
    ).hexdigest()

    # Comparación en tiempo constante — == filtra información de timing
    return hmac.compare_digest(expected, signature)

# Verificar artefactos descargados
def verify_download(content: bytes, expected_sha256: str) -> bool:
    actual = hashlib.sha256(content).hexdigest()
    return hmac.compare_digest(actual, expected_sha256)

# Verificación de JWT con algoritmo fijado
def verify_jwt(token: str, public_key: str) -> dict:
    try:
        payload = jwt.decode(
            token,
            public_key,
            algorithms=["RS256"],  # Fijar el algoritmo — nunca confiar en el header "alg" del token
            issuer="https://auth.stackpractices.com",
            audience="stackpractices-api"
        )
        return payload
    except jwt.ExpiredSignatureError:
        raise AuthError("Token expired")
    except jwt.InvalidTokenError:
        raise AuthError("Invalid token")

El error clásico aquí es aceptar el header alg del propio JWT. Eso es lo que habilitó los ataques de confusión de algoritmo, donde un token RS256 se refirma como HS256 usando la clave pública como secreto HMAC. Fija el algoritmo en el servidor. La misma idea en el resto: firmas, checksums y hmac.compare_digest en lugar de ==, porque una comparación de strings normal filtra timing byte a byte.

A09: Security Logging & Alerting Failures

El cambio de nombre de “Monitoring” a “Alerting” lo dice todo. La mayoría de brechas permanecen en los logs durante meses antes de que alguien las note. El punto de la lista 2025 es que el logging sin alerting es solo almacenamiento caro. Necesitas tanto los eventos como una vía que despierte a alguien cuando importa.

Mitigación

import json
import logging
from datetime import datetime

class SecurityLogger:
    def __init__(self):
        self.logger = logging.getLogger("security")
        handler = logging.FileHandler("/var/log/app/security.log")
        handler.setFormatter(logging.Formatter('%(message)s'))
        self.logger.addHandler(handler)
        self.logger.setLevel(logging.INFO)

    def log_auth_event(self, event_type: str, user_id: str, ip: str, success: bool):
        self.logger.info(json.dumps({
            "timestamp": datetime.now().isoformat(),
            "event": event_type,
            "user_id": user_id,
            "ip": ip,
            "success": success,
            "type": "authentication"
        }))

    def log_access_denied(self, user_id: str, resource: str, ip: str):
        self.logger.warning(json.dumps({
            "timestamp": datetime.now().isoformat(),
            "event": "access_denied",
            "user_id": user_id,
            "resource": resource,
            "ip": ip,
            "type": "authorization"
        }))

    def log_security_event(self, event: str, details: dict):
        self.logger.warning(json.dumps({
            "timestamp": datetime.now().isoformat(),
            "event": event,
            "details": details,
            "type": "security"
        }))

# Uso
sec_logger = SecurityLogger()

sec_logger.log_auth_event("login", user.id, request.client.host, True)
sec_logger.log_auth_event("login_failed", username, request.client.host, False)

sec_logger.log_access_denied(user.id, f"/api/users/{target_id}", request.client.host)

sec_logger.log_security_event("sql_injection_attempt", {
    "ip": request.client.host,
    "input": user_input[:200]  # Truncar — no dejes que el atacante llene tu disco
})

Los logs estructurados son la mitad fácil. La mitad de alertas es: contar login_failed por IP y avisar a alguien al superar un umbral, alertar sobre cualquier access_denied en rutas de administración, y alarmar cuando se dispare la tasa de errores. Un atacante probando tu aplicación se ve exactamente como una ráfaga repentina de 4xx. Si solo puedes hacer una cosa, conecta los logins fallidos por IP a una alerta; el credential stuffing aparece ahí primero.

A10: Mishandling of Exceptional Conditions

La segunda categoría nueva, y la que la mayoría de equipos aún no conocen. Cubre lo que ocurre cuando las cosas fallan: excepciones sin gestionar, rutas de error que saltan los chequeos de seguridad, defaults que fallan abiertos, stack traces filtrados al usuario, y agotamiento de recursos cuando un atacante provoca errores a propósito. Las condiciones excepcionales son donde el código cuidadosamente escrito baja la guardia.

El código vulnerable

# VULNERABLE: Fallar abierto ante errores inesperados
def transfer_funds(from_id: int, to_id: int, amount: float):
    try:
        sender = db.query(Account).get(from_id)
        if sender.balance < amount:
            raise InsufficientFunds()
        sender.balance -= amount
        receiver = db.query(Account).get(to_id)
        receiver.balance += amount
        db.commit()
    except Exception as e:
        # Error tragado — ¿sender ya debitado, commit a medias?
        logger.error(f"transfer failed: {e}")
        return {"status": "ok"}  # ¡El cliente cree que funcionó!

# VULNERABLE: Stack trace filtrado al cliente
@app.get("/api/order/{order_id}")
def get_order(order_id: str):
    order = db.execute(f"SELECT * FROM orders WHERE id = '{order_id}'")
    return order  # ValueError/DBError escala a un 500 con detalles internos

Mitigación

class TransferError(Exception):
    pass

def transfer_funds(from_id: int, to_id: int, amount: float) -> dict:
    try:
        with db.transaction():  # Atómica — rollback ante cualquier fallo
            sender = db.query(Account).with_for_update().get(from_id)
            if sender is None:
                raise TransferError("sender not found")
            if sender.balance < amount:
                raise TransferError("insufficient funds")
            sender.balance -= amount

            receiver = db.query(Account).with_for_update().get(to_id)
            if receiver is None:
                raise TransferError("receiver not found")
            receiver.balance += amount
    except TransferError as e:
        # Fallo esperado — se puede exponer con seguridad
        raise HTTPException(status_code=422, detail=str(e))
    # Las excepciones inesperadas propagan al manejador global → 500 genérico
    return {"status": "ok"}

@app.get("/api/order/{order_id}")
def get_order(order_id: int):  # la validación int falla cerrada en el router
    order = db.query(Order).get(order_id)
    if not order:
        raise HTTPException(status_code=404, detail="Order not found")
    return order

El principio es fallar cerrado, recuperar explícitamente. Un chequeo de seguridad que lanza excepción debe denegar por defecto, no pasar por accidente; por eso la transferencia usa una transacción atómica en lugar de esperar que el except haga lo correcto. Los fallos esperados reciben excepciones tipadas y códigos de estado propios; los inesperados caen a un 500 genérico que loguea internamente y no dice nada al cliente. El manejador global de excepciones de A02 es la red de seguridad que hace que esto funcione.

Errores que atraviesan las diez categorías

Algunos patrones de fallo aparecen en todas las categorías:

  • Confiar en cualquier cosa del lado del cliente. Campos ocultos de formulario, roles en claims de JWT, flags de “admin” en localStorage: si lo envía el cliente, trátalo como entrada del atacante.
  • Seguridad añadida a posteriori. Atornillar un WAF o una auditoría sobre un diseño sin rate limits ni chequeos de propiedad arregla los síntomas, no el diseño.
  • Defaults en producción. Flags de debug, CORS abierto, endpoints documentados. Lo que el framework te da por defecto se eligió para comodidad del desarrollador, no para tu modelo de amenazas.
  • El cumplimiento como sustituto de la seguridad. Pasar una auditoría anual de checklist no dice casi nada sobre tu exposición diaria; las categorías de arriba son las que los atacantes reales usan.

Ver También

Preguntas frecuentes

¿Qué cambió realmente entre OWASP Top 10 2021 y 2025?

Dos categorías son nuevas: Software Supply Chain Failures (A03) y Mishandling of Exceptional Conditions (A10). "Vulnerable and Outdated Components" quedó absorbida por la categoría más amplia de cadena de suministro, y SSRF perdió su categoría propia al integrarse en Broken Access Control. Security Misconfiguration saltó del #5 al #2, y Logging pasó a llamarse Logging & Alerting para remarcar que monitorizar lo que nadie revisa no sirve.

¿Sigue siendo SSRF un riesgo si ya no es categoría propia?

Sí, se movió. No desapareció. OWASP lo integró en A01 porque un exploit SSRF es en el fondo un fallo de control de acceso: tu servidor pide un recurso que no debería por cuenta del atacante. Sigues necesitando validación de URLs, controles de egress y bloqueo del endpoint de metadata. El código de protección de arriba sigue aplicando, solo archivado bajo otra letra.

¿Con qué frecuencia debería revisar mi aplicación contra el OWASP Top 10?

Al menos trimestralmente con herramientas automatizadas (OWASP ZAP, Burp Suite), más un pentest manual anual y una revisión tras cambios grandes de arquitectura. Integra SAST y DAST en CI para que cada pull request quede cubierto. La lista es un documento de concienciación, pero funciona como checklist mínimo de auditoría.

¿Cuál es la diferencia entre SAST y DAST?

SAST analiza el código fuente sin ejecutarlo y detecta patrones como sumideros de inyección SQL y secretos hardcodeados. DAST ataca la aplicación en ejecución desde fuera y encuentra problemas en tiempo de ejecución como cabeceras mal configuradas o bypasses de autenticación. Quieres ambos: SAST pronto en desarrollo, DAST contra staging.

¿Debería usar un WAF (Web Application Firewall)?

Sí, como una capa más. Los WAF de nube (AWS WAF, Cloudflare, Azure Front Door) bloquean ataques de commodities como escáneres de SQLi antes de que lleguen a tu aplicación. Pero un WAF es defensa en profundidad, no una solución: te compra tiempo mientras parcheas, no parchea por ti.

¿Qué categoría del OWASP 2025 subestiman más los equipos?

En la práctica, son las dos categorías nuevas las que se saltan. Cadena de suministro porque "ejecutar npm audit" parece suficiente mientras lockfiles, SBOMs y builds firmadas siguen sin implementarse. Y Mishandling of Exceptional Conditions porque las rutas de error casi no reciben revisión, y son justo donde los chequeos de seguridad fallan abiertos en silencio.

¿Tengo que rehacer mi checklist de seguridad de 2021?

Renuméralo, no lo reconstruyas. La mayor parte del trabajo de mitigación sigue siendo válido. Al código de control de acceso, criptografía, inyección y autenticación le da igual qué letra le asigne OWASP. El trabajo real es añadir las dos categorías nuevas a tu proceso de revisión: verificación de cadena de suministro (lockfiles, SBOM, artefactos firmados) y revisión de rutas de error (defaults cerrados, nada de excepciones tragadas).

¿Por dónde empiezo si solo puedo arreglar una cosa?

Broken Access Control. Es el #1 por cuarta lista consecutiva porque es el más común y el más impactante. Un atacante no necesita herramientas exóticas para cambiar un userId en una petición. Audita cada endpoint que toque datos de usuario buscando el chequeo de propiedad, y pon autorización a nivel middleware delante de todo lo que sea de administración.