Vulnerabilidades web (OWASP Top 10)

"Toda input es maliciosa hasta que se demuestre lo contrario." — regla de oro del desarrollo web seguro.

Qué vas a aprender en este capítulo

OWASP (Open Web Application Security Project) publica cada pocos años el Top 10 de las vulnerabilidades más críticas en aplicaciones web. Este capítulo cubre cada una con ejemplos reales de ataque y cómo prevenirla.


3.1 OWASP Top 10 (2021)

📐 Fundamento

# Categoría Ejemplos
A01 Broken Access Control IDOR, bypass de autorización
A02 Cryptographic Failures Datos sensibles sin cifrar, TLS débil
A03 Injection SQLi, XSS, command injection
A04 Insecure Design Falla en el diseño, no en la implementación
A05 Security Misconfiguration Config por defecto, pantallas de error verbosas
A06 Vulnerable Components Librerías con CVEs conocidas (log4j)
A07 Identification & Authentication Failures Credential stuffing, sesiones débiles
A08 Software & Data Integrity Failures Dependencias sin verificar, deserialización insegura
A09 Security Logging & Monitoring No detectar ataques, logs ausentes
A10 Server-Side Request Forgery (SSRF) El servidor hace requests no validados

3.2 SQL Injection (A03)

💡 Intuición

Si concatenás input del usuario en una query SQL, el atacante puede "escapar" del input y agregar SQL propio.

SELECT * FROM users WHERE email = 'ana@example.com'
                                       ↓ atacante escribe:
SELECT * FROM users WHERE email = '' OR '1'='1' --'
                                  → siempre verdadero → devuelve TODOS los usuarios

📐 Fundamento

Ataque clásico — bypass de login:

# Código vulnerable
def login(email, password):
    query = f"SELECT * FROM users WHERE email='{email}' AND password='{password}'"
    return db.execute(query)

# Atacante ingresa:
# email: admin@example.com'--
# password: cualquier_cosa
# 
# Query resultante:
# SELECT * FROM users WHERE email='admin@example.com'--' AND password='cualquier_cosa'
#                                                  ↑ comentario, ignora el resto
# → entra como admin sin contraseña

Ataque de extracción de datos (UNION):

GET /platillos?categoria=pupusa' UNION SELECT email, password_hash FROM users--

→ devuelve los emails y hashes de todos los usuarios mezclados con los platillos

Solución 1 — Prepared Statements (la única solución correcta):

# BIEN — el motor de BD trata los parámetros como datos, nunca como código
def login(email, password):
    query = "SELECT * FROM users WHERE email = %s AND password = %s"
    return db.execute(query, (email, password))

# Aunque el atacante envíe "admin'--", se busca literalmente ese texto en la BD

Solución 2 — ORM (también seguro si se usa correctamente):

from sqlalchemy.orm import Session

def login(email, password):
    user = session.query(User).filter(User.email == email).first()
    # SQLAlchemy parametriza automáticamente

Cuidado — el ORM puede ser inseguro si usás raw SQL:

# MAL — concatenación dentro del ORM
session.execute(f"SELECT * FROM users WHERE email = '{email}'")  # vulnerable

# BIEN
session.execute("SELECT * FROM users WHERE email = :email", {"email": email})

Defensa en profundidad:

  • Validar inputs (formato esperado).
  • Mínimo privilegio en BD: el usuario de la app no debe tener permisos de DROP TABLE.
  • WAF (Web Application Firewall) detecta patrones de SQLi conocidos.

3.3 Cross-Site Scripting (XSS)

💡 Intuición

XSS: inyectar JavaScript en una página que será ejecutado por otros usuarios.

Si el sistema permite que un usuario malicioso publique en el chat: <script>fetch('/api/borrar-cuenta', {method:'POST'})</script> y otros usuarios ven ese mensaje, su navegador ejecuta ese JavaScript y borra su propia cuenta.

📐 Fundamento

Tipos de XSS:

Reflected XSS: el script viene en la URL/request y se refleja en la respuesta.

URL: https://la-esquina.com/buscar?q=<script>alert('XSS')</script>

Si la página muestra: "Resultados para: <script>alert('XSS')</script>"
→ el navegador ejecuta el script

Stored XSS: el script se guarda en la BD y se ejecuta cada vez que alguien lo ve.

Comentario en una reseña de pupusa:
<img src=x onerror="fetch('/api/admin/borrar-todo', {method:'POST'})">

→ Si un admin ve el comentario, se borra todo

DOM-based XSS: el script se ejecuta por código JavaScript del cliente sin pasar por el servidor.

// Código vulnerable en el cliente
document.getElementById('saludo').innerHTML = "Hola " + getParameterByName('nombre');
// Si la URL es ?nombre=<img src=x onerror=alert(1)> → XSS

Prevención: codificar (escape) outputs según el contexto:

# Backend Python con Jinja2 (Flask, Django) — escape automático
{{ comentario.texto }}     # automáticamente escapa <, >, ", ', &

# Si necesitás HTML raw, ser muy explícito:
{{ comentario.texto | safe }}     # PELIGROSO si el contenido viene de usuarios
// Frontend — usar APIs seguras
element.textContent = userInput;     // SEGURO
element.innerHTML = userInput;       // PELIGROSO

// Si necesitás HTML, sanitizar con DOMPurify
const limpio = DOMPurify.sanitize(userInput);
element.innerHTML = limpio;

Content Security Policy (CSP): instrucción al navegador de qué scripts puede ejecutar.

Content-Security-Policy: default-src 'self'; script-src 'self' https://cdn.la-esquina.com

Esto le dice al navegador: "Solo ejecutá scripts del mismo origen o del CDN. Bloqueá todo inline." → la mayoría de XSS ya no funciona aunque exista la inyección.


3.4 CSRF — Cross-Site Request Forgery

💡 Intuición

CSRF: hacer que el navegador del usuario haga una acción sin que él lo sepa, aprovechando que está logueado.

Estás logueado en la-esquina.com. Visitás un sitio malicioso que tiene oculto:

<img src="https://la-esquina.com/api/transferir?monto=10000&destino=atacante">

Tu navegador hace el request, incluyendo tu cookie de sesión → la transferencia se ejecuta como si fueras vos.

📐 Fundamento

Defensas contra CSRF:

1. Tokens CSRF:

El servidor genera un token aleatorio por sesión. Cada formulario debe incluirlo. El servidor verifica que el token coincida.

<form action="/transferir" method="POST">
    <input type="hidden" name="csrf_token" value="aleatorio_único_por_sesión">
    <input name="monto"><input name="destino">
    <button>Transferir</button>
</form>
# Django/Flask manejan esto automáticamente
@csrf_protect
def transferir(request):
    # Verifica el token automáticamente; rechaza si no coincide
    pass

El sitio malicioso no conoce el token (no puede leer la cookie por la same-origin policy) → no puede falsificar el form.

2. Cookies SameSite:

# En el backend al setear la cookie
response.set_cookie(
    'sesion_id', token,
    httponly=True,        # JS no puede leerla → previene XSS robarla
    secure=True,          # solo HTTPS
    samesite='Strict'     # NO se envía en requests cross-origin → previene CSRF
)

SameSite=Strict significa: la cookie solo se envía si el request viene del mismo sitio. El sitio malicioso no puede usar tu sesión.

SameSite=Lax (default moderno): se envía en navegación de nivel superior (clics en links) pero no en requests automáticos (img, fetch).

3. Verificar Origin o Referer header:

@app.before_request
def check_origin():
    if request.method == 'POST':
        origin = request.headers.get('Origin') or request.headers.get('Referer', '')
        if not origin.startswith('https://la-esquina.com'):
            abort(403)

3.5 SSRF — Server-Side Request Forgery (A10)

📐 Fundamento

SSRF: el atacante hace que el servidor haga requests a URLs que el atacante elige.

# Endpoint vulnerable: descargar imagen de URL
@app.route('/descargar')
def descargar():
    url = request.args.get('url')
    return requests.get(url).content

El atacante puede usar esto para:

?url=http://169.254.169.254/latest/meta-data/iam/security-credentials/
→ AWS metadata endpoint, devuelve credenciales del servidor

?url=http://localhost:8080/admin
→ acceder a servicios internos no expuestos a internet

?url=file:///etc/passwd
→ leer archivos del servidor

Prevención:

import ipaddress
from urllib.parse import urlparse
import socket

ALLOWED_HOSTS = ['cdn.la-esquina.com', 'images.example.com']

def url_segura(url: str) -> bool:
    parsed = urlparse(url)
    
    # 1. Solo HTTPS
    if parsed.scheme not in ('https',):
        return False
    
    # 2. Whitelist de hosts permitidos
    if parsed.hostname not in ALLOWED_HOSTS:
        return False
    
    # 3. Resolver el hostname y verificar que NO sea IP privada
    try:
        ip = ipaddress.ip_address(socket.gethostbyname(parsed.hostname))
        if ip.is_private or ip.is_loopback or ip.is_link_local:
            return False
    except (socket.gaierror, ValueError):
        return False
    
    return True

@app.route('/descargar')
def descargar():
    url = request.args.get('url')
    if not url_segura(url):
        abort(400, 'URL no permitida')
    return requests.get(url, timeout=5).content

3.6 IDOR — Insecure Direct Object Reference (A01)

📐 Fundamento

IDOR: acceder a recursos cambiando un ID en la URL sin verificación de autorización.

Usuario ana visita: /api/factura/12345  ← su factura
Atacante prueba:    /api/factura/12346  ← factura de otro cliente
                    /api/factura/12347
                    ...

Si el endpoint solo verifica login pero no que el recurso pertenezca al usuario: el atacante descarga todas las facturas.

Prevención:

@app.route('/api/factura/<int:id>')
@requiere_login
def ver_factura(id):
    factura = db.get_factura(id)
    
    # SIEMPRE verificar que el recurso pertenezca al usuario actual
    if factura.cliente_id != g.user.id and not g.user.is_admin:
        abort(403)  # NO 404 — eso confirmaría que existe; mejor 403 genérico
    
    return factura

Mejor aún — IDs no enumerables (UUIDs):

# En lugar de IDs secuenciales (1, 2, 3...) que se pueden adivinar:
factura.id = uuid.uuid4()  # ej: f47ac10b-58cc-4372-a567-0e02b2c3d479
# El atacante no puede simplemente probar /api/factura/1, /api/factura/2...

Pero atención: UUIDs no reemplazan la verificación de autorización. Son una capa adicional de defensa, no la principal.


3.7 Otros ataques importantes

📐 Fundamento

Command Injection:

# MAL
import os
def imprimir_archivo(nombre):
    os.system(f"lp {nombre}")

# Atacante: nombre = "factura.pdf; rm -rf /"
# → ejecuta el comando malicioso

# BIEN — pasar args como lista, sin shell
import subprocess
subprocess.run(['lp', nombre])  # nombre tratado literalmente

Path Traversal:

# MAL — el atacante puede usar "../" para salir del directorio
def descargar_factura(nombre):
    with open(f"/var/facturas/{nombre}") as f:
        return f.read()
# Atacante: nombre = "../../etc/passwd"

# BIEN — validar que el path resuelto esté dentro del directorio permitido
import os
def descargar_factura(nombre):
    base = "/var/facturas"
    path = os.path.normpath(os.path.join(base, nombre))
    if not path.startswith(base + os.sep):
        abort(400)
    with open(path) as f:
        return f.read()

Insecure Deserialization (A08):

Pickle, YAML, etc. pueden ejecutar código arbitrario al deserializar.

import pickle
# MAL — pickle ejecuta código durante load
data = pickle.loads(input_del_usuario)  # ¡EJECUCIÓN DE CÓDIGO ARBITRARIO!

# BIEN — usar formatos seguros
import json
data = json.loads(input_del_usuario)

Vulnerable Components (A06):

Log4Shell (CVE-2021-44228), 2021: una vulnerabilidad en log4j (librería de logging de Java) permitía ejecución remota de código. Afectó miles de empresas.

Mitigación:

  • Mantener dependencias actualizadas.
  • Escanear vulnerabilidades automáticamente: npm audit, pip-audit, Snyk, Dependabot.
  • Tener un proceso de respuesta rápida cuando aparece un CVE crítico.
# Python
pip install pip-audit
pip-audit

# JavaScript
npm audit
npm audit fix

3.8 Ejercicios

✏️ Ejercicio 3.1 — Encontrar vulnerabilidades

Para cada fragmento de código, identificá la vulnerabilidad OWASP y propone la corrección:

# A
@app.route('/buscar')
def buscar():
    q = request.args.get('q')
    return render_template('resultados.html', termino=q)
    # En el template: <h1>Resultados para: {{ termino|safe }}</h1>

# B
@app.route('/api/usuario/<int:id>')
@requiere_login
def get_usuario(id):
    return jsonify(db.get_user(id))

# C
@app.route('/login', methods=['POST'])
def login():
    email = request.form['email']
    pwd = request.form['password']
    user = db.execute(f"SELECT * FROM users WHERE email='{email}' AND pwd='{pwd}'")
    if user:
        session['user_id'] = user.id

3.9 Para profundizar

3.X Errores comunes

⚠️ Trampa común

Escapar HTML donde el contexto no es HTML. htmlspecialchars() o escape() previene XSS solo en contexto HTML body. Dentro de un <script>, dentro de un atributo onerror=, o dentro de una URL en href=javascript:, escapar HTML no es suficiente: necesitás escape específico del contexto (JS string escape, URL encode).

Tip: la regla es "output encoding by context". Para frontend, no escapes a mano: usá frameworks (React, Vue) que lo hacen por vos. Si tenés que generar HTML server-side, usá un templating engine que sepa el contexto (Jinja2 con autoescape, etc.).

⚠️ Trampa común

Sanitizar input en lugar de validar/escapar al output. "Voy a remover los <script> del input para prevenir XSS." Mal: el atacante usa <scr<script>ipt> (después de remover el inner, queda <script>). O codifica: &lt;script&gt; que después de un htmlentities_decode vuelve a ser activo.

Tip: valid input, escape output — guardá el input tal cual (después de validar tipo y largo), y escapá al renderizar según el contexto. Sanitizar es válido para Rich Text (DOMPurify), no para evitar inyecciones.


Definiciones nuevas: OWASP Top 10, SQL Injection, Prepared Statement, XSS (reflected/stored/DOM), CSP, CSRF, SameSite cookies, SSRF, IDOR, Command Injection, Path Traversal, Insecure Deserialization, CVE, Log4Shell, dependency scanning.