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 2025 | Categoría | Qué pasó |
|---|---|---|
| A01 | Broken Access Control | Se mantiene en el #1 — SSRF pasó a integrarse aquí |
| A02 | Security Misconfiguration | Saltó del #5 al #2, empujada por la nube y los defaults de los frameworks |
| A03 | Software Supply Chain Failures | Nueva — amplía los antiguos “componentes vulnerables” a toda la cadena: dependencias, sistemas de build, distribución |
| A04 | Cryptographic Failures | Bajó desde el #2 |
| A05 | Injection | Bajó desde el #3 |
| A06 | Insecure Design | Bajó desde el #4 |
| A07 | Authentication Failures | Misma posición, nombre más corto (antes “Identification and Authentication Failures”) |
| A08 | Software or Data Integrity Failures | Sin cambios |
| A09 | Security Logging & Alerting Failures | Renombrada — “Monitoring” pasó a “Alerting”, porque los logs que nadie revisa no cuentan |
| A10 | Mishandling of Exceptional Conditions | Nueva — 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.
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.
Recursos Relacionados
Patrones de Autenticación
Implementar autenticacion en produccion. Cubre JWT, OAuth2, session-based auth, passkeys, MFA, refresh tokens, token rotation, RBAC, ABAC, SSO con SAML y OpenID Connect, y patrones de logout seguro con ejemplos de codigo.
GuideAPI Security
Asegurar tus APIs end-to-end. Cubre rate limiting, autenticacion, input validation, CORS, prevencion de SQL injection, API gateway patterns, limites de request size, pagination security, mass assignment, versioning, audit logging y API security testing con ejemplos de codigo.
GuideGestión de Secrets
Gestionar secrets de aplicaciones de forma segura en produccion. Cubre HashiCorp Vault, AWS Secrets Manager, Azure Key Vault, GCP Secret Manager, Doppler, rotacion de secrets, environment variables, rotacion zero-downtime y secrets en CI/CD pipelines con ejemplos de codigo.
GuideSupply Chain Security
Asegurar tu software supply chain end-to-end. Cubre generacion de SBOM, dependency scanning, Sigstore, SLSA framework, provenance attestation, package registries, typosquatting, dependency confusion y hardening de CI/CD pipelines con ejemplos de codigo.
GuideOWASP Top 10: Explicado con Mitigaciones
Guía orientada a desarrolladores sobre los 10 riesgos de seguridad más críticos de OWASP: cómo funciona cada vulnerabilidad, ejemplos del mundo real y mitigaciones prácticas.