StackPractices
intermediate Por Mathias Paulenko

Seguridad de Aplicaciones Web (OWASP Top 10)

Un recorrido enfocado en desarrolladores por el OWASP Top 10 — inyección, XSS, control de acceso roto, SSRF y el resto — con código de prevención y un flujo de threat modeling.

Introducción

El OWASP Top 10 es el documento estándar de concienciación sobre riesgos de seguridad en aplicaciones web — una lista de consenso de lo que realmente compromete aplicaciones. La edición 2021 reordenó la lista alrededor de tres grandes cambios: el control de acceso roto saltó al puesto #1, las fallas de integridad de software y datos debutaron, y XSS pasó a englobarse en la categoría más amplia de Inyección.

Esta guía recorre cada riesgo con prevención enfocada en desarrolladores: cómo se ve el fallo, el patrón de código que lo corrige y un checklist que puedes ejecutar sobre tu propio servicio. Combina bien con la Checklist de Seguridad de APIs para revisión endpoint por endpoint, y con los archivos del repo companion de seguridad si quieres configuraciones listas para usar.

Cómo se Apilan los Riesgos

No todos los diez riesgos merecen el mismo esfuerzo. El orden pragmático para la mayoría de equipos:

flowchart diagram: Superficie de amenaza

Las secciones 1–10 siguen el orden oficial, para que los números coincidan con el documento OWASP que probablemente estés citando.

1. Control de Acceso Roto

Riesgo: Los usuarios acceden a recursos o ejecutan acciones fuera de sus permisos. Los fallos de control de acceso son el hallazgo OWASP más común — IDOR (insecure direct object reference), escalada de privilegios a nivel de función, forced browsing hacia rutas de admin y políticas CORS que confían en *.

Prevención

# Malo: confiar en un user ID proporcionado por el cliente
@app.route("/api/orders/<order_id>")
def get_order(order_id):
    order = db.query(f"SELECT * FROM orders WHERE id = {order_id}")
    return jsonify(order)  # ¡cualquier usuario puede ver cualquier orden!

# Bueno: verificar que el usuario autenticado es dueño del recurso
@app.route("/api/orders/<order_id>")
@login_required
def get_order(order_id):
    order = Order.query.filter_by(
        id=order_id,
        user_id=current_user.id  # exige propiedad
    ).first_or_404()
    return jsonify(order.to_dict())

Los dos trucos que vale la pena recordar: chequea autorización después de autenticación en cada request (no solo en login), y acota cada query al llamante — WHERE id = ? AND user_id = ?, nunca solo WHERE id = ?.

Vigila también el acceso a nivel de función: una ruta como /admin/users protegida solo por un link oculto en el frontend no está protegida — la ruta es adivinable, y “security through obscurity” falla en cuanto alguien fuzzee /admin, /internal, /api/v1/users. Exige el chequeo de rol dentro del handler, no en la UI.

Checklist

  • Deniega por defecto — devuelve 403 a menos que se permita explícitamente
  • Valida propiedad del recurso en cada request, incluyendo PUT/PATCH/DELETE
  • Desactiva listado de directorios y path traversal del lado del servidor
  • Rate limit en el acceso a la API para frenar la enumeración automatizada
  • Acota CORS a los orígenes que realmente lo necesitan — nada de Access-Control-Allow-Origin: * en endpoints autenticados

2. Fallas Criptográficas

Riesgo: Datos sensibles expuestos por cifrado débil, ausente o casero. Esta categoría reemplazó a “Exposición de Datos Sensibles” en 2021 — el énfasis pasó de la fuga a las decisiones criptográficas que la causaron.

Prevención

# Malo: almacenamiento en texto plano
user = {"ssn": "123-45-6789", "password": "abc123"}

# Bueno: hashear contraseñas, cifrar PII en reposo
from bcrypt import hashpw, gensalt
from cryptography.fernet import Fernet

hashed_password = hashpw("user_password".encode(), gensalt())

cipher = Fernet(ENCRYPTION_KEY)  # clave desde un gestor de secretos, no del repo
encrypted_ssn = cipher.encrypt("123-45-6789".encode())

Dos reglas que cubren la mayoría de esta categoría: nunca inventes tu propio cifrado, y hashea contraseñas — no las cifres. El hash es unidireccional por diseño; el cifrado es reversible, que es justo lo que no quieres para contraseñas.

Checklist

  • Hashea contraseñas con bcrypt, Argon2id o PBKDF2 (nunca MD5/SHA-1)
  • Cifra datos sensibles en reposo (PII, datos de salud, financieros) con claves gestionadas — KMS, Vault o HSM
  • Usa TLS 1.2+ para todos los datos en tránsito; redirige HTTP a HTTPS
  • No almacenes datos sensibles en el storage del cliente (localStorage para tokens)
  • Clasifica tus datos primero — no puedes proteger lo que no has etiquetado

3. Inyección

Riesgo: Datos no confiables son enviados a un intérprete como parte de un comando o consulta. SQLi es la famosa, pero este grupo también cubre inyección de comandos, LDAP/XML — y, desde 2021, XSS.

Prevención de Inyección SQL

# Malo: interpolación de strings
query = f"SELECT * FROM users WHERE name = '{user_input}'"

# Bueno: consultas parametrizadas — el valor nunca se convierte en SQL
cursor.execute("SELECT * FROM users WHERE name = %s", (user_input,))

Prevención de Cross-Site Scripting (XSS)

XSS es inyección donde el intérprete es el navegador de la víctima. Tres variantes que vale la pena conocer: reflejado (payload en la URL, devuelto en la respuesta), almacenado (payload persistido en la base de datos, servido a otros usuarios) y basado en DOM (el payload solo se manipula del lado del cliente).

// Malo: input de usuario directo al DOM
el.innerHTML = comment;              // <script> se ejecuta

// Bueno: contenido de texto, nunca HTML
el.textContent = comment;
# Del lado del servidor: usa siempre el auto-escape del motor de plantillas.
# Jinja2 escapa por defecto — esto es lo que le hace daño a la gente:
{{ user_comment | safe }}     # <-- 'safe' desactiva el escape, no lo hagas
{{ user_comment }}            # escapado, correcto

Una Content Security Policy estricta es la red de seguridad: default-src 'self' sin unsafe-inline mata la mayoría de payloads de script inline aunque falte un escape en la salida.

Prevención de Inyección de Comandos

# Malo
os.system(f"convert {user_filename} output.png")

# Bueno: validar y restringir a una lista permitida
import subprocess
allowed_extensions = {".png", ".jpg", ".jpeg"}
if not any(user_filename.endswith(ext) for ext in allowed_extensions):
    raise ValueError("Tipo de archivo inválido")
subprocess.run(["convert", user_filename, "output.png"], check=True)

Checklist

  • Usa consultas parametrizadas para todo acceso a base de datos
  • Escapa/codifica la salida para su contexto — HTML, atributo, JS y URL necesitan codificadores distintos
  • Valida y restringe la entrada de usuario antes de usarla en comandos
  • Despliega una CSP estricta; evita innerHTML y eval en el código cliente
  • Escapa caracteres especiales en LDAP, XML y comandos del SO

4. Diseño Inseguro

Riesgo: Controles de seguridad ausentes o deficientes en la arquitectura de la aplicación — los fallos que no puedes parchear porque el diseño en sí está mal. OWASP lo separa porque los diseños inseguros “perfectamente implementados” siguen siendo inseguros.

Prevención

  • Ejecuta threat modeling sobre los flujos sensibles antes de construirlos — la pregunta STRIDE-lite “¿qué puede salir mal aquí?” es suficiente para la mayoría de equipos
  • Valida lógica de negocio, no solo sintaxis (“un usuario puede reembolsar una vez” es una regla que el código puede imponer; “los pedidos llegan en orden correcto” no lo es)
  • Añade anti-automatización a los flujos que valen la pena atacar: registro, reset de contraseña, cupones, scrapers de búsqueda
  • Mantén un ciclo de desarrollo seguro: revisión de diseño para cambios riesgosos, casos de seguridad en code review
# Anti-automatización en reset de contraseña
def request_password_reset(email):
    if rate_limiter.is_limited(f"reset:{email}"):
        raise TooManyRequests("Intenta más tarde")
    send_reset_email(email)
    rate_limiter.increment(f"reset:{email}")

Una Sesión Mínima de Threat Modeling

No necesitas un framework formal para empezar. Para cada flujo sensible:

  1. Dibuja el flujo de datos — ¿dónde entra el input, qué confía en él, a dónde va?
  2. Pregunta por cada límite de confianza: ¿qué pasa si una parte maliciosa controla el input?
  3. Prioriza por radio de explosión y probabilidad — una ruta sin autenticación hacia datos de usuario es prioridad máxima
  4. Convierte los controles en tickets. Si la lista queda vacía tras 30 minutos, no has buscado lo suficiente.

5. Configuración de Seguridad Incorrecta

Riesgo: Configuraciones incompletas o ad-hoc, cuentas por defecto, capacidades innecesarias. Esta es la categoría del “error no forzado” — la app es segura pero el stack está blando.

Checklist de Prevención

  • Elimina cuentas, credenciales y aplicaciones de ejemplo por defecto
  • Desactiva capacidades, puertos y métodos HTTP innecesarios (OPTIONS, TRACE apagados salvo necesidad)
  • Envía headers de seguridad — mira la receta de security headers para el set completo: Strict-Transport-Security, Content-Security-Policy, X-Frame-Options/frame-ancestors, X-Content-Type-Options, Referrer-Policy, Permissions-Policy
  • Mantén frameworks, librerías y parches del SO al día
  • Ejecuta en modo de mínimo privilegio (no root)
  • Apaga el manejo de errores verbose en producción — los stack traces filtran internals
  • Separa configuración de producción y staging; los secretos viven en un gestor de secretos, no en archivos .env dentro del repo

6. Componentes Vulnerables y Desactualizados

Riesgo: Ejecutar componentes con vulnerabilidades conocidas. Cada dependencia que empaquetas es código que no revisaste — y los atacantes comparan CVEs contra tu package.json más rápido de lo que parcheas.

Prevención

# Escanea dependencias por CVEs conocidos
npm audit
pip-audit
snyk test

El flujo importa más que la herramienta: escanea en cada CI, fija versiones (lockfiles por encima de rangos), y trata “hay actualizaciones” como señal de seguridad, no como mantenimiento rutinario. Si una dependencia está abandonada, migra fuera — una dependencia transitiva sin parchear es como Log4Shell llegó a media industria.

Checklist

  • Mantén un software bill of materials (SBOM) — no puedes parchear lo que no sabes que empaquetas
  • Suscríbete a los avisos de seguridad de tus dependencias críticas
  • Elimina dependencias sin usar — menos superficie, menos ruido en los escaneos
  • Prueba actualizaciones en staging antes de producción
  • No ejecutes latest ni semver suelto en artefactos de producción

7. Fallas de Identificación y Autenticación

Riesgo: Debilidades de autenticación permiten credential stuffing, fuerza bruta o secuestro de sesión. Rate limits, MFA y un manejo de sesión sensato matan la mayor parte de esta categoría.

Prevención

# Flujo de login: MFA + errores genéricos + sesiones de corta duración
@app.route("/login", methods=["POST"])
def login():
    user = authenticate(request.json)
    if not user:
        # Error genérico para prevenir enumeración de usuarios
        raise Unauthorized("Credenciales inválidas")

    if user.mfa_enabled:
        session["pending_user_id"] = user.id
        return {"mfa_required": True}

    create_session(user)
    return {"token": generate_jwt(user)}

Decisiones pequeñas con resultado real: el mensaje de “credenciales inválidas” que no revela si el email existe; el flag pending_user_id en sesión en vez de “logueado” antes de completar MFA; sesiones invalidadas al cambiar la contraseña.

Checklist

  • Implementa autenticación multi-factor (MFA) — incluso TOTP mueve la aguja masivamente
  • Aplica políticas de contraseñas fuertes (longitud > 12 le gana a las reglas de complejidad)
  • Rate limit en intentos de login — backoff exponencial o bloqueo
  • Usa tokens de sesión seguros (aleatorios, largos, cookies HttpOnly + Secure + SameSite)
  • Invalida sesiones al cambiar la contraseña y en logout
  • Registra los fallos de autenticación para detectar patrones (ver §9)

8. Fallas de Integridad de Software y Datos

Riesgo: Deserialización insegura, pipelines CI/CD no confiables y auto-actualizaciones sin verificación. Esta entró en la lista en 2021 por algo — los ataques a la cadena de suministro (SolarWinds, el intento de backdoor en xz) la hicieron real.

Prevención

# Malo: deserializar datos no confiables con pickle
import pickle
data = pickle.loads(user_input)  # ¡ejecución de código arbitrario!

# Bueno: usa JSON con validación de esquema
import json
from marshmallow import Schema, fields

data = json.loads(user_input)
schema = UserSchema()
result = schema.load(data)  # valida estructura

La integridad del pipeline es el mismo problema aguas arriba: artefactos sin firmar, tags mutables, pasos de build que descargan y ejecutan scripts arbitrarios. Fija actions e imágenes por digest, verifica firmas donde existan y nunca confíes en un paso de build que no puedas auditar.

Checklist

  • Firma y verifica la integridad de datos serializados (HMAC o firmas asimétricas)
  • Verifica la integridad del pipeline CI/CD — commits firmados, tags inmutables, digests fijados en actions
  • Usa Subresource Integrity (integrity= con hashes) para scripts de CDN en HTML
  • No auto-actualices sin verificación criptográfica

9. Fallas de Logging y Monitoreo de Seguridad

Riesgo: Logging insuficiente deja a los atacantes invisibles — la brecha mediana pasa semanas sin detectarse, y “no teníamos logs” es el postmortem que nadie quiere escribir.

Prevención

import structlog

logger = structlog.get_logger()

# Registra eventos de seguridad con contexto — nunca la contraseña, nunca el token
logger.info(
    "user_login",
    user_id=user.id,
    ip_address=request.remote_addr,
    user_agent=request.headers.get("User-Agent"),
    success=True
)

Qué registrar: éxito/fallo de autenticación, denegaciones de control de acceso, errores de validación de entrada, y cualquier cosa que parezca sondeo (404s en rutas de admin, payloads malformados). Qué no registrar: contraseñas, tokens completos, IDs de sesión o PII cruda — los logs se convierten ellos mismos en superficie de ataque.

Checklist

  • Registra todos los eventos de autenticación (éxito y fallo)
  • Registra fallos de control de acceso y errores de validación
  • Lanza alertas sobre patrones sospechosos — fallos repetidos desde una IP, viajes imposibles, enumeración masiva
  • Mantén los logs resistentes a manipulación: append-only, centralizados, con retención definida
  • Alerta por patrones, no solo por volumen — un burst de credential stuffing parece un blip en un gráfico plano

10. Server-Side Request Forgery (SSRF)

Riesgo: El servidor hace requests a destinos no previstos basándose en entrada de usuario. La víctima clásica es una feature de “trae esta URL” — webhooks, importación por URL, generadores de PDF, previews de URL — que puede apuntarse a servicios internos, endpoints de metadata de la nube o cualquier cosa en la red.

Prevención

# Malo: el usuario controla la URL
url = request.json.get("webhook_url")
requests.post(url, data=sensitive_data)

# Bueno: allowlist de scheme + host, y bloquea rangos IP internos
from urllib.parse import urlparse
import ipaddress
import socket

ALLOWED_HOSTS = {"api.example.com", "hooks.slack.com"}
ALLOWED_SCHEMES = {"https"}

def safe_webhook_call(user_url, data):
    parsed = urlparse(user_url)
    if parsed.scheme not in ALLOWED_SCHEMES:
        raise ValueError("Solo URLs https permitidas")
    if parsed.hostname not in ALLOWED_HOSTS:
        raise ValueError("URL no está en la allowlist")

    # Resuelve y rechaza IPs internas (metadata, loopback, RFC 1918)
    try:
        ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
        if not ip.is_global:
            raise ValueError("IP interna no permitida")
    except socket.gaierror:
        raise ValueError("No se puede resolver el host")

    return requests.post(user_url, json=data, timeout=5)

Dos trucos que el ejemplo básico no cubre: validar solo el hostname es bypasseable (evil.com resolviendo a 169.254.169.254 — la IP de metadata de AWS — o DNS rebinding), y los esquemas file:///gopher:///ftp:// leen archivos locales o tocan protocolos internos. Si el endpoint es público, un firewall de egreso sobre el tráfico saliente del servicio es la mejor segunda capa.

Checklist

  • Restringe scheme, host y IP resuelta para los requests salientes
  • Bloquea rangos internos explícitamente: 169.254.169.254 (metadata cloud), loopback, RFC 1918, link-local
  • Desactiva esquemas de URL que no necesitas (file://, ftp://, gopher://)
  • Pon un firewall de egreso o proxy entre la app e internet para endpoints que traen URLs
  • No devuelvas respuestas crudas — reenvía solo lo necesario, para que el endpoint no sirva como SSRF de lectura ciega

Verifica lo que Desplegaste

Los checklists sin verificación son deseos. Tres chequeos baratos que atrapan huecos reales:

# 1. ¿Tus security headers se sirven de verdad? (A05)
curl -sI https://your-app.example/ | grep -iE "strict-transport|content-security|x-frame|x-content|referrer-policy"

# 2. ¿Tu ruta "protegida" responde a un request sin autenticar? (A01/A07)
curl -s -o /dev/null -w "%{http_code}" https://your-app.example/api/admin/users
# cualquier cosa que no sea 401/403 es un hallazgo
# 3. Escaneo DAST de baseline contra staging — OWASP ZAP, Docker, ~2 minutos
docker run -t zaproxy/zap-stable zap-baseline.py -t https://staging.your-app.example

El baseline de ZAP es pasivo por defecto — rastrea y marca los problemas de clase inyección/configuración sin explotación activa, así que es seguro como smoke check. Guarda el modo active-scan para una ventana dedicada sobre un entorno desechable.

Una Base para un Proyecto Nuevo

Si arrancas un servicio hoy y solo haces cinco cosas, haz estas — cubren la mitad superior de la lista:

  1. Parametriza todo — ORM o parámetros vinculados, cero queries construidas con strings.
  2. Chequeo de propiedad en cada handler — WHERE user_id = ? como reflejo, más chequeo de rol en rutas de admin.
  3. bcrypt + HTTPS + security headers — contraseñas hasheadas, TLS terminado correctamente, el set estándar de headers desplegado.
  4. MFA en el login — incluso TOTP mueve la aguja masivamente.
  5. Una línea de log por evento de seguridad — intentos de auth, denegaciones, fallos de validación, y una alerta sobre bursts.

Todo lo demás en esta página se apoya sobre esa base.

Errores Comunes

  • “Está listo tras una sola auditoría”: la postura de seguridad decae; ejecuta los checklists por release, no una vez
  • Confiar en input del usuario donde se construye una URL o ruta de archivo: el objeto request es el atacante
  • Guardar secretos en el código fuente o en logs: rótalos primero, muévelos a un gestor de secretos después
  • “Solo son headers”: una CSP sola previene la mayoría de payloads XSS aunque falte un escape
  • Loggear de todo menos los eventos de seguridad: la fuerza bruta parece ruido de fondo hasta que graficas fallos por IP

Lecturas Adicionales

Preguntas frecuentes

¿Debería corregir todos los items del OWASP Top 10 antes de lanzar?

Ataca primero los explotables por defecto: control de acceso roto, inyección (incluido XSS), fallas criptográficas y SSRF si traes URLs del usuario. El resto se incorpora por fases según tu threat model — un blog no necesita la misma postura que una API de pagos.

¿Con qué frecuencia debo revisar el OWASP Top 10?

La lista se actualiza cada 3–4 años, pero las amenazas se mueven más rápido. Revisa tu postura trimestralmente y trata cada actualización de dependencias como una mini-revisión — el riesgo de componentes vulnerables cambia a diario.

¿Es el OWASP Top 10 suficiente para compliance?

Es un punto de partida, no un programa. Para compliance de verdad necesitas el nivel apropiado de OWASP ASVS para tu riesgo, threat modeling en diseños nuevos y penetration tests periódicos.

¿Qué items son más explotables en la práctica?

Control de acceso roto e inyección — están arriba de la lista por algo. Los IDOR son trivialmente enumerables y la inyección no requiere herramientas especializadas. SSRF es el sleeper: cualquier feature que traiga URLs del usuario merece una segunda mirada.

¿Qué herramientas automatizan partes de esto?

Escáneres DAST como OWASP ZAP prueban la app corriendo para los problemas de inyección/configuración; herramientas SCA (npm audit, pip-audit, Snyk, Dependabot) cubren §6; semgrep captura patrones inseguros en code review. Ninguno reemplaza la revisión de diseño de §4.