Skip to content

🎯 Meta: proteger tu backend de los ataques más comunes. No necesitas ser hacker: necesitas conocer los errores típicos y cómo evitarlos. Este apéndice es el lado constructor de la seguridad.

🔗 Complementa tu Manual de Ciberseguridad — Purple Team. Allí ves el lado atacante (cómo se rompe); aquí, el lado defensor (cómo se construye seguro). Juntos = purple team.


C.1 · La mentalidad de seguridad

🧠 Regla nº1: nunca confíes en la entrada del usuario. Todo lo que llega de fuera (formularios, URLs, cabeceras, cookies, JSON, archivos) puede ser malicioso. Valida, sanea y escapa siempre. El backend es la última línea de defensa: el frontend se puede saltar (un atacante usa curl directo, sin tu formulario).

Defensa en profundidad: varias capas de protección. Si una falla, otra te cubre (Nginx + validación + permisos + BD). Nunca dependas de una sola.


C.2 · Inyección SQL (y cómo evitarla al 100%)

El ataque clásico: meter SQL malicioso donde esperabas un dato.

python
# ❌ VULNERABLE: concatenar la entrada del usuario en el SQL
email = request.args.get("email")
query = f"SELECT * FROM usuarios WHERE email = '{email}'"
# Si email = "' OR '1'='1"  →  devuelve TODOS los usuarios
# Si email = "'; DROP TABLE usuarios; --"  →  💀 borra la tabla

# ✅ SEGURO: consultas parametrizadas (el motor separa código de datos)
cursor.execute("SELECT * FROM usuarios WHERE email = %s", (email,))

🧠 La regla absoluta: nunca construyas SQL concatenando strings con datos del usuario. Usa siempre consultas parametrizadas (?, $1, %s) o el ORM. El ORM (Eloquent, Prisma, SQLAlchemy, Drizzle) parametriza por ti — por eso usarlo bien te protege gratis.

php
// Los ORMs del libro ya lo hacen seguro:
Producto::where('email', $email)->get();          // Laravel: parametrizado
prisma.usuario.findMany({ where: { email } })      // Prisma: parametrizado
db.select().from(u).where(eq(u.email, email))      // Drizzle: parametrizado

⚠️ El agujero traicionero: algunos ORMs permiten "raw queries" (DB::raw, queryRaw). Ahí vuelves a ser responsable de parametrizar. Si usas SQL crudo, parametriza. Nunca interpoles.


C.3 · Contraseñas: hashing correcto

Nunca guardes contraseñas en texto plano ni con hashes débiles (MD5, SHA1). Usa un algoritmo diseñado para contraseñas: bcrypt, argon2 o scrypt (lentos a propósito, con salt).

python
# ✅ Con passlib (Python)
from passlib.hash import argon2

hash = argon2.hash("contraseña_del_usuario")   # al registrar: guarda el HASH
argon2.verify("intento", hash)                  # al login: verifica

# Laravel: Hash::make($password) / Hash::check($intento, $hash)  — bcrypt por defecto
# NestJS: bcrypt.hash(pw, 10) / bcrypt.compare(intento, hash)

🧠 Por qué bcrypt/argon2 y no SHA256: SHA256 es rápido, y eso es MALO para contraseñas — un atacante prueba millones por segundo. bcrypt/argon2 son lentos a propósito (y con "salt" único por usuario), haciendo la fuerza bruta inviable. Nunca inventes tu propio esquema.

⚠️ Si tu BD se filtra (pasa), con hashes fuertes las contraseñas siguen protegidas. Con texto plano o MD5, es un desastre inmediato. El hashing correcto es tu seguro contra filtraciones.


C.4 · Autenticación y JWT seguros

Repaso de los caps. 00, 03, 05, 06, 07, ahora con foco en seguridad:

✅ Contraseñas con bcrypt/argon2 (nunca texto plano).
✅ Tokens JWT firmados con secreto FUERTE (largo, aleatorio, en variable de entorno).
✅ Expiración corta en access tokens (15 min) + refresh token para renovar.
✅ HTTPS obligatorio (un token viaja en cada petición: sin TLS, se roba).
✅ Rate limiting en /login (frena fuerza bruta).

⚠️ Errores de JWT que se ven en producción real:

  • Secreto débil o hardcodeado en el código (cualquiera que vea el repo forja tokens).
  • No verificar la firma o aceptar el algoritmo none (el atacante manda un token sin firmar).
  • Meter datos sensibles en el payload (el payload NO está cifrado, solo firmado — es Base64 legible por cualquiera, cap. 00).
  • Tokens que no expiran (uno robado sirve para siempre).

🔗 La anatomía de ataques a JWT la ves en detalle en tu Manual de Ciberseguridad (Web Hacking).


C.5 · Autorización: el fallo más común (IDOR)

Autenticación (¿quién eres?) ≠ autorización (¿puedes hacer esto?). El fallo estrella: IDOR (Insecure Direct Object Reference) — acceder a datos de otro cambiando un id.

python
# ❌ VULNERABLE: cualquier usuario logueado puede ver la factura de OTRO
@app.get("/facturas/{id}")
def ver_factura(id: int, usuario = Depends(usuario_actual)):
    return db.get(Factura, id)        # ¡no comprueba que sea SUYA!
    # El atacante prueba /facturas/1, /facturas/2... y ve las de todos

# ✅ SEGURO: comprueba la pertenencia
@app.get("/facturas/{id}")
def ver_factura(id: int, usuario = Depends(usuario_actual)):
    factura = db.get(Factura, id)
    if factura.usuario_id != usuario.id:
        raise HTTPException(403, "No autorizado")     # 403, no 404
    return factura

🧠 Comprueba SIEMPRE la pertenencia/permiso en el backend, para cada recurso. No basta con que el usuario esté logueado. "¿Este usuario puede tocar ESTE dato concreto?" — esa pregunta va en cada endpoint que accede a datos de alguien. Es el fallo nº1 de las APIs modernas.

🔗 A nivel de BD, PostgreSQL RLS (cap. 02) fuerza esto automáticamente — por eso tu CLAINEV ERP lo usa para el multi-inquilino.


C.6 · XSS y validación de salida

XSS (Cross-Site Scripting): un atacante inyecta JavaScript que se ejecuta en el navegador de otros usuarios. Aunque es más de frontend, el backend ayuda a prevenirlo:

✅ Escapa/sanea el contenido generado por usuarios antes de guardarlo o al mostrarlo.
✅ Content-Security-Policy (cabecera de Nginx, cap. 13) limita qué scripts corren.
✅ Cookies con flags HttpOnly (JS no las lee) y Secure (solo HTTPS) y SameSite.
✅ Devuelve JSON con Content-Type correcto (application/json), no HTML.
python
# Cookies seguras
response.set_cookie("sesion", token, httponly=True, secure=True, samesite="lax")

C.7 · Validación de entrada (recordatorio unificado)

Ya lo viste en cada framework; aquí la regla general:

ReglaCómo
Valida tipo y formatoPydantic, class-validator, esquemas t.*, Form Requests
Whitelist, no blacklistDefine lo permitido, no intentes listar lo prohibido
Rechaza campos de máswhitelist:true (Nest), modelos separados (FastAPI) — evita mass assignment
Límites de tamañoMáximo de longitud, tamaño de archivos, profundidad de JSON
Valida en el backendEl frontend se salta con curl; el backend es la verdad

⚠️ Mass assignment: si aceptas todo el JSON tal cual para crear/actualizar, un atacante manda { "es_admin": true } y se hace admin. Por eso $fillable en Laravel, DTOs con whitelist en Nest y modelos de entrada separados en FastAPI. Nunca vuelques el body entero en tu modelo.


C.8 · Secretos y configuración

✅ Secretos en variables de entorno / gestor de secretos, NUNCA en el código ni en git.
✅ .env en .gitignore (cap. 00). Sube .env.example sin valores.
✅ NO en la imagen Docker (se pueden extraer de las capas, cap. 12).
✅ APP_ENV=production con debug OFF (un error con debug ON filtra código y config).
✅ Rota los secretos si se filtran. Usa secretos distintos por entorno.

⚠️ El clásico: subir el .env a GitHub por error. Bots escanean GitHub en segundos buscando claves de AWS, Stripe, etc. Si pasa: rota la clave inmediatamente (borrarla del repo no basta, ya está indexada). Configura secret scanning en tu repo para que te avise.

Más allá del .env: gestores de secretos reales

Un .env en el servidor resuelve el desarrollo local, pero en producción tiene tres problemas: no audita quién leyó qué secreto, no rota automáticamente, y suele acabar copiado a mano en varios sitios. Las opciones estándar en 2026, de más simple a más completa:

HerramientaQué resuelveCuándo usarla
SOPS (Mozilla)Cifra el .env/YAML con una clave (KMS, PGP, age) y lo guardas cifrado EN gitEquipos pequeños, sin infraestructura extra
Vault (HashiCorp/OpenBao)Secretos dinámicos (credenciales de BD que expiran solas), auditoría, rotaciónProducción seria, multi-equipo
AWS Secrets Manager / GCP Secret ManagerIgual que Vault, gestionado por el cloudYa estás en ese cloud, quieres cero operación propia
Sealed Secrets / External Secrets (Kubernetes)Secretos cifrados en el repo, descifrados solo dentro del clusterDespliegas con K8s (apéndice G)
bash
# SOPS: cifra el archivo completo, lo commiteas cifrado, se descifra en el despliegue
sops --encrypt --age $(cat clave_publica.txt) .env > .env.enc
sops --decrypt .env.enc > .env    # solo quien tiene la clave privada puede hacerlo

💡 Por dónde empezar: si tu equipo es pequeño y no tienes Vault, SOPS te da 80% del beneficio (secretos versionados de forma segura, sin copiarlos a mano) con una curva mínima. Sube a Vault cuando necesites secretos que expiran solos (credenciales de BD temporales) o auditoría de quién accedió a qué, cuándo — típicamente cuando ya tienes varios equipos y entornos que gestionar.


C.9 · Cabeceras y superficie de ataque

✅ Cabeceras de seguridad en Nginx (cap. 13): HSTS, X-Frame-Options, CSP, nosniff.
✅ CORS configurado con lista de orígenes permitidos (no "*" con credenciales).
✅ Oculta versiones (server_tokens off) y mensajes de error genéricos al cliente.
✅ Deshabilita métodos HTTP que no uses.
✅ Actualiza dependencias (npm audit, composer audit, pip-audit, govulncheck).

CORS — el que confunde a todos:

python
# Configura CORS con orígenes explícitos (no comodín con credenciales)
app.add_middleware(CORSMiddleware,
    allow_origins=["https://miapp.com"],   # ✅ lista concreta
    allow_credentials=True,
)
# ❌ allow_origins=["*"] con allow_credentials=True es inseguro (y el navegador lo bloquea)

🧠 CORS no es una defensa, es un permiso. CORS le dice al navegador qué webs pueden llamar a tu API. No protege contra curl ni scripts. La seguridad real es la autenticación/autorización, no CORS.


C.10 · Dependencias y cadena de suministro

Gran parte de tu código son librerías de terceros. Una con una vulnerabilidad = tu app vulnerable.

bash
npm audit                    # Node / Bun
composer audit               # PHP
pip-audit                    # Python
govulncheck ./...            # Go

💡 Automatízalo: activa Dependabot (GitHub) o Renovate para que te avisen y hagan PRs con actualizaciones de seguridad automáticamente. Corre audit en tu CI (cap. 09) para que un build falle si entra una dependencia vulnerable.


C.11 · Checklist de seguridad backend

Entrada
  ☐ Validación estricta (whitelist) en cada endpoint
  ☐ Consultas parametrizadas / ORM (nunca SQL concatenado)
  ☐ Sin mass assignment (fillable/DTOs/modelos de entrada)
  ☐ Límites de tamaño (body, archivos, rate limit)

Autenticación / Autorización
  ☐ Contraseñas con bcrypt/argon2
  ☐ JWT: secreto fuerte en env, firma verificada, expiración corta, HTTPS
  ☐ Comprobación de pertenencia/permiso por recurso (anti-IDOR)
  ☐ Rate limiting en login

Transporte / cabeceras
  ☐ HTTPS forzado + HSTS
  ☐ Cabeceras de seguridad (CSP, X-Frame-Options, nosniff)
  ☐ Cookies HttpOnly + Secure + SameSite
  ☐ CORS con orígenes explícitos

Operación
  ☐ Secretos en env / gestor, NUNCA en git ni en la imagen
  ☐ APP_ENV=production, debug OFF, errores genéricos al cliente
  ☐ Dependencias auditadas (audit en CI) + Dependabot
  ☐ Logs SIN datos sensibles (no loguees contraseñas ni tokens)
  ☐ Backups cifrados (cap. 14)

C.12 · SSRF y CSRF: los dos que faltan en el checklist

SSRF (Server-Side Request Forgery): tu backend hace una petición HTTP a una URL que controla (parcialmente) el usuario (ej. "descargar imagen desde esta URL", webhooks salientes, generadores de PDF que cargan una URL). Un atacante te hace pedir a tu propia red interna:

python
# ❌ VULNERABLE: el backend pide lo que el usuario diga, sin restricción
@app.post("/importar-imagen")
def importar(url: str):
    return requests.get(url).content
    # url = "http://169.254.169.254/latest/meta-data/iam/security-credentials/"
    #   → filtra credenciales del proveedor cloud (AWS/GCP/Azure)
    # url = "http://localhost:6379/"  → toca tu Redis interno, sin pasar por el firewall
python
# ✅ Defensa por capas: allowlist de dominios/esquemas + bloqueo de IPs internas
import ipaddress, socket
from urllib.parse import urlparse

def url_segura(url: str) -> bool:
    partes = urlparse(url)
    if partes.scheme not in ("http", "https"):
        return False
    ip = socket.gethostbyname(partes.hostname)
    return not ipaddress.ip_address(ip).is_private   # bloquea 10.x, 172.16.x, 127.x, 169.254.x...

⚠️ No confíes en un blacklist de strings ("bloquear si contiene 'localhost'"): un atacante lo rodea con 127.0.0.1, notación decimal de IP, redirecciones o DNS rebinding. Resuelve el DNS tú mismo y valida la IP resultante (allowlist de lo permitido, no blacklist de lo prohibido — la misma regla de C.7). En la nube, fuerza IMDSv2 (con token) para que el endpoint de metadata no responda a peticiones SSRF simples.

CSRF (Cross-Site Request Forgery): una web maliciosa hace que el navegador de un usuario ya logueado en tu app envíe una petición no deseada (ej. <img src="https://tuapp.com/borrar-cuenta">).

✅ En 2026, la mayoría de navegadores ya bloquean esto por defecto: las cookies son
   SameSite=Lax salvo que las marques explícitamente SameSite=None.
✅ Si usas SameSite=None (necesario para integraciones cross-site legítimas) o cookies de
   terceros, añade el método moderno recomendado por OWASP: cabeceras Fetch Metadata.
python
# Fetch Metadata: el navegador manda estas cabeceras automáticamente, sin que el atacante
# pueda falsificarlas desde otra web — el backend las lee y decide
def es_peticion_same_site(request) -> bool:
    sitio = request.headers.get("Sec-Fetch-Site")       # "same-origin" | "same-site" | "cross-site" | None
    return sitio in ("same-origin", "same-site", None)  # None = navegador viejo, cae a tokens CSRF

💡 Prioriza la protección nativa de tu framework. Laravel (VerifyCsrfToken), NestJS (csurf/framework moderno) y FastAPI con formularios tradicionales ya traen protección CSRF madura — actívala en vez de reinventar tokens a mano. Fetch Metadata es el complemento cuando tu API se consume desde JavaScript cross-site y las cookies SameSite=Lax no bastan por sí solas.


✅ Ejercicio del apéndice

Audita una de tus APIs del blog contra este apéndice:

1. Revisa cada endpoint: ¿comprueba pertenencia? Provoca un IDOR a propósito
   (entra como usuario A, pide un recurso de B) y arréglalo.
2. Verifica que las contraseñas usan bcrypt/argon2.
3. Comprueba que ningún endpoint concatena SQL. Si usas raw queries, parametriza.
4. Añade rate limiting a /login.
5. Configura CORS con tu dominio (no "*").
6. Corre el audit de dependencias de tu stack y arregla lo crítico.
7. Repasa el checklist y tacha todo. Lo que no puedas tachar, es tu tarea.
8. Cifra tu `.env` de producción con SOPS y verifica que puedes descifrarlo
   solo con la clave correcta.

🔗 Para practicar ataques (y entender de qué te defiendes) en entornos legales, ve a tu Manual de Ciberseguridad — Purple Team, Fase 4 (Labs/CTF). Construir seguro y atacar seguro son dos caras de la misma moneda.

💡 Pistas
  • Para provocar el IDOR del punto 1: crea dos usuarios, guarda el id de un recurso del primero, y pide ese id autenticado como el segundo. Si te lo devuelve, ahí está el bug.
  • El rate limiting del punto 4 puede ser tan simple como el patrón INCR + EXPIRE de Redis del apéndice B.3 — no necesitas una librería nueva si ya tienes Redis en el proyecto.
  • Al revisar CORS (punto 5), recuerda que allow_origins=["*"] con allow_credentials=True ni siquiera lo permite el navegador — si tu app necesita cookies entre dominios, la lista de orígenes tiene que ser explícita sí o sí.

🧠 Autoevaluación

Cualquiera puede saltarse tu formulario y llamar directamente a tu API con curl o Postman, sin pasar por tu JavaScript. El frontend es UX; el backend es la única barrera que un atacante no puede evitar, por eso toda validación de seguridad debe repetirse ahí.

SHA256 está diseñado para ser rápido — justo lo contrario de lo que quieres para contraseñas, porque permite a un atacante probar millones de combinaciones por segundo. bcrypt/argon2 son deliberadamente lentos y usan salt único por usuario, haciendo la fuerza bruta inviable en la práctica.

No — eso es autenticación (¿quién eres?), no autorización (¿puedes ver ESTE recurso concreto?). Sin comprobar que la factura pertenece al usuario autenticado, cualquiera logueado puede leer facturas ajenas cambiando el id en la URL (IDOR).

Si aceptas el JSON completo del usuario tal cual para crear/actualizar un registro, un atacante puede añadir campos que no debería controlar (ej. es_admin: true) y el ORM los guardará igual. Se evita con listas explícitas de campos permitidos ($fillable en Laravel, DTOs con whitelist en Nest, modelos de entrada separados en FastAPI).

Con texto plano, el atacante tiene acceso inmediato a todas las cuentas. Con argon2 (lento, con salt), reventar cada contraseña por fuerza bruta es computacionalmente inviable a escala — el hashing correcto convierte una filtración catastrófica en un incidente serio pero contenido.

Porque es un blacklist de texto, fácil de rodear: 127.0.0.1, la notación decimal de esa IP, una redirección HTTP a una URL interna, o DNS rebinding evitan la palabra "localhost" sin problema. La defensa correcta resuelve el DNS tú mismo y valida que la IP resultante no sea privada/interna (allowlist de lo permitido), no intenta enumerar lo prohibido como texto.

Auditoría (quién leyó qué secreto y cuándo), secretos dinámicos que expiran solos (una credencial de BD válida por horas en vez de una contraseña fija) y rotación automática sin coordinar manualmente con cada servicio que la usa. Para equipos pequeños sin esa necesidad, SOPS cifrando el .env en git ya cubre el problema de "secretos versionados de forma segura".


Volver al: README.md · Relacionado: 13-nginx-apache.md, 14-servidores-devops.md