La lección anterior cerró la red, pero dejó una puerta que tiene que seguir abierta: el puerto 443 de la API. Ahí vive el producto de Nimbus, y ninguna regla de cortafuegos sabe distinguir una reserva legítima de un intento de leer los datos de otra clínica. Esa distinción solo puede hacerla la aplicación. Esta lección entra en el código: cómo se integra la seguridad en el ciclo de desarrollo, cómo se corrigen de verdad los ataques del catálogo de 02-02, cómo se generaliza la lección del IDOR para que no vuelva a ocurrir por olvido, y cómo se automatiza todo eso en un pipeline que rompe el build cuando debe.
Contenido
- El ciclo de desarrollo seguro (SSDLC)
- Validación de entrada: permitir en lugar de bloquear
- Inyección: consultas parametrizadas y el ORM mal usado
- XSS, CSRF y el navegador como frontera
- Control de acceso: del IDOR a la autorización centralizada
- SSRF, deserialización y carga de ficheros
- Seguridad de APIs
- Gestión de secretos en el código y en el despliegue
- Cabeceras de seguridad y despliegue de una CSP
- Dependencias y cadena de suministro del producto
- Pruebas de seguridad en el CI y revisión de código
- Errores, frontend, móvil y métricas
- El ciclo de desarrollo seguro (SSDLC)
La seguridad no es una fase antes del lanzamiento: es una propiedad que se decide en cada etapa. El dato que justifica el enfoque es conocido y consistente en la industria: corregir un fallo en diseño cuesta una unidad; en desarrollo, unas cinco; en pruebas, unas quince; y en producción, entre treinta y cien —porque incluye rediseño, despliegue urgente, comunicación al cliente y, a veces, notificación de brecha—. El IDOR de Nimbus lo ilustra: detectado en diseño habría sido una decisión de arquitectura de diez minutos; detectado en el pentest fueron cuatro horas de desarrollo, dos de pruebas, un retest y una entrada en el registro de riesgos.
flowchart LR
R["REQUISITOS\nSeguridad como criterio\nde aceptacion, no deseo"] --> D["DISENO\nModelado de amenazas\nSTRIDE + DFD (01-04)"]
D --> C["DESARROLLO\nPatrones seguros, gestor de\nsecretos, gitleaks pre-commit"]
C --> P["PRUEBAS\nSAST + SCA + DAST\nen el CI (11)"]
P --> DE["DESPLIEGUE\nFirma de artefactos (03-07),\nsecretos inyectados"]
DE --> O["OPERACION\nDeteccion (05-02), vulns\n(05-01), respuesta (04-05)"]
O -->|"lo aprendido vuelve al diseno"| D
Lo que hace que esto funcione en una empresa de cinco personas técnicas no es un proceso pesado: son tres puntos de control baratos. Un requisito de seguridad explícito en las historias que tocan datos personales; media hora de modelado de amenazas al diseñar un módulo nuevo; y un CI que dice que no. Todo lo demás es refinamiento.
- Validación de entrada: permitir en lugar de bloquear
Toda entrada es hostil hasta que se demuestre lo contrario, y «entrada» no es solo el formulario: son parámetros de URL, cabeceras, cookies, ficheros, mensajes de cola y respuestas de terceros.
| Lista de bloqueo (denylist) | Lista de permitidos (allowlist) | |
|---|---|---|
| Define | Lo que está prohibido | Lo que está permitido |
| Falla en | Todo lo que no se imaginó | Nada: lo desconocido se rechaza |
| Mantenimiento y ejemplo | Constante, siempre por detrás: «rechazar si contiene <script>» |
Estable: «el nombre son 1-80 caracteres de este conjunto» |
La lista de bloqueo pierde siempre, porque el atacante solo necesita una codificación que no estuviera en la lista. La validación correcta define la forma esperada del dato: tipo, longitud, rango, formato y conjunto de valores admitidos.
from pydantic import BaseModel, Field, EmailStr, field_validator
from datetime import datetime
class CrearReserva(BaseModel):
# Cada campo declara su forma (tipo, longitud, patron). Lo que no encaja se
# rechaza con un 422 ANTES de tocar la logica de negocio.
nombre: str = Field(min_length=1, max_length=80, pattern=r"^[\w\s'\-\.áéíóúñÁÉÍÓÚÑ]+$")
email: EmailStr
telefono: str = Field(pattern=r"^\+?[0-9]{9,15}$")
inicio: datetime
notas: str = Field(default="", max_length=500)
@field_validator("inicio")
@classmethod
def no_en_pasado(cls, v: datetime) -> datetime:
# Regla de NEGOCIO, no de formato: sin ella el tipo es correcto y la
# aplicacion acepta reservas en 1970. Lo sintactico no basta.
if v < datetime.now(v.tzinfo):
raise ValueError("La fecha de inicio no puede estar en el pasado")
return vDos precisiones que evitan una confusión frecuente. La validación no sustituye a la protección específica de cada destino: un nombre validado sigue necesitando consulta parametrizada al ir a SQL y codificación al ir a HTML, porque el peligro no está en el dato sino en el contexto donde se usa. Y la validación se hace siempre en el servidor; la del navegador es usabilidad, y saltársela es tan fácil como usar curl.
- Inyección: consultas parametrizadas y el ORM mal usado
# MAL — concatenacion. El texto del usuario se convierte en codigo SQL.
db.execute(f"SELECT * FROM reservas WHERE cliente = '{nombre}' AND tenant_id = {tid}")
# MAL TAMBIEN — el ORM no protege si le pasas SQL construido a mano. Este es
# el error real: el equipo cree estar protegido "porque usa el ORM".
session.execute(text(f"SELECT * FROM reservas WHERE cliente = '{nombre}'"))
# BIEN — parametrizada: la estructura viaja por un canal y los datos por otro.
# El motor NUNCA interpreta el parametro como SQL, contenga lo que contenga.
session.execute(
text("SELECT * FROM reservas WHERE cliente = :nombre AND tenant_id = :tid"),
{"nombre": nombre, "tid": tenant_actual})
# BIEN — API del ORM, que parametriza por construccion.
session.query(Reserva).filter(Reserva.cliente == nombre,
Reserva.tenant_id == tenant_actual).all()Por qué la parametrización funciona y el escapado no: al parametrizar, el motor recibe primero la estructura de la consulta, la compila y solo después inserta los valores como datos. No hay ninguna cadena que interpretar. El escapado manual, en cambio, depende de acertar con cada codificación y con cada juego de caracteres, y basta un caso no contemplado para que se rompa.
Lo que no se puede parametrizar son los identificadores: nombres de tabla, de columna y la dirección de ordenación. Ahí solo hay una solución correcta, y es traducir contra una lista cerrada: un diccionario {"fecha": "inicio", "cliente": "cliente"} donde la clave es lo que envía el usuario y el valor es el nombre real de la columna, con un valor por defecto para lo desconocido, y una dirección que solo puede ser ASC o DESC. Nunca se interpola directamente lo que llega del cliente.
Y dos capas de defensa en profundidad que ya conoces: el rol nimbus_api sin permisos de DDL (así una inyección no puede crear ni borrar tablas) y RLS en PostgreSQL, que filtra por tenant en el propio motor aunque la consulta se equivoque.
- XSS, CSRF y el navegador como frontera
XSS ocurre cuando datos controlados por un usuario acaban interpretados como código en el navegador de otro. La defensa tiene dos capas.
La primera es codificar en la salida, según el contexto: no se codifica igual un texto en HTML, un valor dentro de un atributo, una cadena en JavaScript o un parámetro en una URL. En la SPA de Nimbus (React) el marco ya escapa el contenido de texto por defecto, y el peligro se concentra en las excepciones: dangerouslySetInnerHTML, la construcción dinámica de URLs (href="javascript:...") y las plantillas del servidor. Si hay que aceptar HTML enriquecido, se sanea con una biblioteca de confianza y lista de permitidos de etiquetas y atributos; escribir el saneador propio es garantía de fallo.
La segunda capa es la CSP (§9), que limita el daño cuando la primera falla. Y una medida que decide el impacto real: la cookie de sesión con HttpOnly, para que un XSS no pueda leerla.
CSRF es distinto: el atacante no roba nada, sino que hace que el navegador de la víctima ejecute una acción legítima aprovechando que las cookies se envían solas.
respuesta.set_cookie( # las cuatro propiedades que importan
"sesion", token,
httponly=True, # invisible para JavaScript: neutraliza el robo por XSS
secure=True, # solo por HTTPS
samesite="Lax", # no viaja en POST desde otro sitio -> corta CSRF
max_age=28800, path="/")SameSite=Lax corta la mayoría de los CSRF por sí solo, y Strict es aún más estricto a costa de romper los enlaces entrantes. Para las operaciones sensibles se añade el token anti-CSRF con el patrón de doble envío: un valor aleatorio en una cookie legible y el mismo valor en una cabecera que el JavaScript propio añade; un sitio ajeno no puede leer la cookie, así que no puede componer la cabecera. Y una regla que resuelve la mitad del problema por diseño: las peticiones que cambian estado nunca son GET.
Nota de arquitectura: si la SPA autentica con token en cabecera Authorization en lugar de cookie, el CSRF clásico desaparece —el navegador no añade esa cabecera sola—, pero aparece el problema de dónde guardar el token, que se trata en §12.
- Control de acceso: del IDOR a la autorización centralizada
El control de acceso roto es el fallo número uno del Top 10 de OWASP, y es el que más ha costado a Nimbus: un IDOR en 01-04 y otro residual en el pentest de 05-03. El patrón del error se repite: la autorización se decide en cada ruta, y basta que un desarrollador se olvide una vez.
# ANTES — cada endpoint recuerda (o no) filtrar. El IDOR es cuestion de tiempo.
@router.get("/reservas/{reserva_id}")
def ver(reserva_id: int, db=Depends(get_db)):
return db.get(Reserva, reserva_id) # devuelve la reserva DE CUALQUIERA
# DESPUES — la autorizacion se centraliza en dependencias reutilizables.
def usuario_actual(token: str = Depends(oauth2)) -> Usuario:
return verificar_token(token) # 03-07
def tenant_actual(u: Usuario = Depends(usuario_actual)) -> int:
return u.tenant_id # del token verificado, NUNCA de la peticion
def exige(*permisos: str):
"""Fabrica de dependencias: declara el permiso necesario en la propia ruta."""
def comprobar(u: Usuario = Depends(usuario_actual)) -> Usuario:
if set(permisos) - set(ROLES[u.rol]): # roles.yaml (modulo 2)
# Registrar la denegacion alimenta la deteccion de escalada (05-02).
log.warning("autz.denegada", extra={"campos": {"actor_id": u.id}})
raise HTTPException(403, "No autorizado")
return u
return comprobar
def obtener_reserva(reserva_id: int, tid: int = Depends(tenant_actual),
db=Depends(get_db)) -> Reserva:
"""Punto UNICO donde se resuelve una reserva: el filtro por tenant vive
aqui, asi que saltarselo exige un acto deliberado y no un olvido."""
r = db.query(Reserva).filter(Reserva.id == reserva_id,
Reserva.tenant_id == tid).first()
if not r:
# 404 y no 403: un 403 confirmaria que ese id existe en OTRO tenant.
raise HTTPException(404, "No encontrada")
return r
@router.get("/reservas/{reserva_id}")
def ver(reserva: Reserva = Depends(obtener_reserva),
_=Depends(exige("reservas:leer"))):
return reservaCinco principios se materializan en ese código: denegar por defecto (sin permiso declarado, no se pasa); el identificador de tenant nunca es entrada del usuario; un único punto de resolución por recurso, de modo que el fallo requiera un acto deliberado y no un olvido; 404 en lugar de 403 para no confirmar existencias ajenas; y registro de cada denegación, que es la fuente de la detección de escalada.
Tres refuerzos completan el modelo: RLS en PostgreSQL como red inferior por si la consulta se equivoca; pruebas automáticas de aislamiento entre tenants en el CI —una prueba por cada endpoint que intente, con el token del tenant A, acceder a un recurso del tenant B y exija 404—; y la comprobación de que los identificadores no sean predecibles (UUID en lugar de enteros consecutivos), que no es un control por sí solo pero eleva mucho el coste de descubrir el fallo.
- SSRF, deserialización y carga de ficheros
SSRF es lograr que tu servidor haga una petición a un destino elegido por el atacante. En la nube es especialmente grave porque el servicio de metadatos de la instancia responde en una IP interna y entrega credenciales.
import ipaddress, socket
from urllib.parse import urlparse
DOMINIOS_PERMITIDOS = {"api.pasarela-ejemplo.com", "hooks.proveedor-correo.com"}
def url_segura(url: str) -> str:
p = urlparse(url)
if p.scheme != "https" or p.hostname not in DOMINIOS_PERMITIDOS:
raise ValueError("Destino no permitido") # LISTA DE PERMITIDOS
# Comprobar tambien la IP resuelta: un dominio permitido puede apuntar a
# una IP interna, y sin esto la allowlist de nombres se burla via DNS.
ip = ipaddress.ip_address(socket.gethostbyname(p.hostname))
if ip.is_private or ip.is_loopback or ip.is_link_local:
raise ValueError("Destino interno bloqueado") # cubre 169.254.169.254
return urlComplementos imprescindibles: bloquear el servicio de metadatos en el cortafuegos de salida (05-04) y exigir su versión con sesión (IMDSv2 o equivalente, 05-07); no seguir redirecciones automáticamente, porque un destino permitido puede redirigir a uno interno; y tiempos de espera cortos.
Deserialización insegura: no se deserializa nunca un formato que pueda instanciar objetos arbitrarios (pickle, yaml.load sin SafeLoader, eval) con datos que vengan de fuera. Se usa JSON con esquema validado, y punto.
Carga de ficheros (los partes escaneados del bucket A-02), con seis controles que se aplican en conjunto:
TIPOS = {"application/pdf": ".pdf", "image/jpeg": ".jpg", "image/png": ".png"}
MAX = 10 * 1024 * 1024
async def subir(f: UploadFile, tid: int = Depends(tenant_actual)):
datos = await f.read(MAX + 1)
if len(datos) > MAX:
raise HTTPException(413, "Fichero demasiado grande") # 1) tamano
# 2) Tipo REAL por contenido, no por extension ni por Content-Type: ambos
# los controla el atacante.
mime = magic.from_buffer(datos, mime=True)
if mime not in TIPOS:
raise HTTPException(415, "Tipo no permitido")
clave = f"tenant/{tid}/{uuid4().hex}{TIPOS[mime]}" # 3) nombre generado
# 4) Fuera del webroot, en el bucket privado (nunca publico).
s3.put_object(Bucket="nimbus-adjuntos-prod", Key=clave, Body=datos,
ContentType=mime, ServerSideEncryption="aws:kms")
cola.enviar("analizar_adjunto", {"clave": clave}) # 5) antivirus
return {"clave": clave}Y el sexto control, ya conocido: la descarga se sirve con URL firmada de 120 segundos (03-07) generada tras comprobar la autorización, nunca con un enlace público ni sirviendo el fichero desde el servidor de la aplicación.
- Seguridad de APIs
El OWASP API Security Top 10 existe porque las APIs fallan de forma distinta a las webs: no hay interfaz que limite lo que se puede pedir.
| Riesgo | Qué es | En Nimbus |
|---|---|---|
| API1 BOLA | Autorización rota a nivel de objeto: acceder al recurso de otro | El IDOR. Resuelto en §5 |
| API2 Autenticación rota · API3 BOPLA | Login sin límite de tasa · autorización rota a nivel de propiedad: devolver o aceptar campos de más | Corregido en 05-03 · el perfil devolvía rol y tenant_id, y aceptaba rol en el PATCH |
| API4 Consumo sin límite · API5 Autorización de función · API7 SSRF | Sin paginación ni cuotas · un usuario normal invoca un endpoint administrativo · §6 | Cuota por tenant · exige("...") en todas las rutas · lista de permitidos |
| API8 Mala configuración · API9 Inventario | CORS abierto, errores verbosos · versiones y entornos sin retirar | CORS explícito · v0 viva sin control, y A-22 |
BOPLA merece código, porque es el más silencioso: el objeto se devuelve entero «porque es cómodo» y filtra campos internos, o se acepta entero y permite que un cliente se ascienda a administrador.
class PerfilSalida(BaseModel): # lista blanca de SALIDA:
id: UUID; nombre: str; email: EmailStr # `rol`, `tenant_id` y hash no salen
class PerfilEntrada(BaseModel):
nombre: str | None = None; telefono: str | None = None
model_config = {"extra": "forbid"} # un `rol` en el cuerpo -> error 422
@router.patch("/perfil", response_model=PerfilSalida)
def actualizar(cambios: PerfilEntrada, u: Usuario = Depends(usuario_actual)):
return repo.actualizar(u.id, cambios.model_dump(exclude_unset=True))extra: "forbid" es la línea que impide la asignación masiva: sin ella, cualquier campo enviado que coincida con un atributo del modelo se aplicaría en silencio. Y response_model garantiza que la salida se filtre siempre, aunque el objeto interno crezca con campos nuevos en el futuro.
Completan el capítulo: límite de tasa por cliente y por tenant, no solo por IP (una clínica con cien empleados comparte IP, y un atacante con cien IPs comparte cuenta); versionado con fecha de retirada anunciada, porque el mayor riesgo del versionado es la v0 que nadie apagó; paginación obligatoria con tope duro en el servidor (limit máximo 100, aunque pidan 10.000); CORS con lista explícita de orígenes, jamás * con credenciales; y documentación que no filtre —el esquema OpenAPI público describe solo los endpoints públicos, y /docs no se sirve en producción sin autenticación—.
- Gestión de secretos en el código y en el despliegue
| Método | Seguridad | Cuándo |
|---|---|---|
| Secreto en el código | Inaceptable | Nunca. Es R-06 y el día 5 de 02-06 |
Fichero .env en el servidor · variable de entorno inyectada |
Baja · media | Desarrollo local con .gitignore · aceptable si el origen es un gestor |
| Gestor de secretos con acceso por rol y rotación | Alta | Objetivo de Nimbus (03-06); en el CI, variables cifradas por entorno |
Tres reglas operativas. El contenedor recibe el secreto en el arranque, desde el gestor y con una identidad de servicio, y nunca lo lleva incrustado en la imagen (05-07). Los secretos del CI se limitan por entorno, de modo que un pull request de una rama no acceda a las credenciales de producción —un fallo muy común y muy explotable—. Y la prevención se pone antes del error: gitleaks declarado en .pre-commit-config.yaml (repositorio gitleaks/gitleaks, rev: v8.18.4, hooks: [{id: gitleaks}]) es la única barrera que actúa antes de que el secreto entre en el historial. El escaneo del CI (§11) es la red para quien no tenga el hook instalado, y la rotación inmediata sigue siendo obligatoria cuando algo se cuela.
- Cabeceras de seguridad y despliegue de una CSP
# CSP: la defensa mas potente contra XSS. 'self' = solo nuestro propio origen.
# Sin 'unsafe-inline' ni 'unsafe-eval', que anulan gran parte de su valor.
add_header Content-Security-Policy "default-src 'self'; script-src 'self';
style-src 'self'; img-src 'self' data:; font-src 'self';
connect-src 'self' https://api.nimbusreservas.example;
frame-ancestors 'none'; base-uri 'self'; form-action 'self'; object-src 'none'" always;
# HTTPS obligatorio un ano, subdominios incluidos. Solo cuando TODOS sirvan
# HTTPS: revertirlo en los navegadores lleva meses.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains; preload" always;
# Impide que el navegador "adivine" el tipo y ejecute como script lo que se
# sirvio como texto o imagen.
add_header X-Content-Type-Options "nosniff" always;
# Limita lo que viaja en el Referer hacia otros sitios (URLs con identificadores).
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
# Desactiva capacidades del navegador que la aplicacion no usa.
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), payment=()" always;
server_tokens off; # y lo que NO debe estar: la version del servidorNotas: frame-ancestors 'none' sustituye al antiguo X-Frame-Options y evita el clickjacking; always hace que la cabecera se envíe también en respuestas de error, que es donde se olvida; y preload solo se solicita cuando se está seguro, porque revertirlo lleva meses.
Cómo se despliega una CSP sin romper la SPA, en cuatro pasos: (1) publicar la política como Content-Security-Policy-Report-Only con un report-uri propio, de modo que no bloquee nada y solo informe; (2) recoger informes dos semanas, que es lo que tarda en pasar por todos los flujos reales de los usuarios; (3) ajustar la política para los orígenes legítimos que aparezcan —normalmente analítica, fuentes y algún iframe de pago—, sin caer en la tentación de añadir 'unsafe-inline' para acallar los avisos, porque eso desactiva la protección principal; y (4) cambiar a modo bloqueante y mantener el report-uri, que a partir de ahí se convierte en una señal de detección: informes nuevos pueden significar un XSS en curso.
- Dependencias y cadena de suministro del producto
Enlazando con 04-04, la política de Nimbus tiene cinco piezas. Fijación por hash: requirements.txt generado con pip-compile --generate-hashes e instalado con pip install --require-hashes, de modo que una versión republicada con contenido distinto rompe la instalación en lugar de entrar sin avisar. Bot de actualizaciones (Dependabot o Renovate) que abre un pull request por dependencia, agrupando las de parche semanalmente y separando las mayores, con la política de plazos de 05-01. Cobertura de pruebas suficiente para poder fusionar una actualización de seguridad el mismo día sin miedo: sin pruebas, el parcheo se pospone y esa es la causa real de la mayoría de los retrasos. SBOM generado en cada release y publicado junto al artefacto, más el VEX de 05-01. Y evaluación antes de añadir una dependencia nueva: ¿está mantenida?, ¿cuántos mantenedores?, ¿cuántas dependencias transitivas arrastra?, ¿podríamos escribir en veinte líneas lo que hace? Una dependencia de tres líneas con doce transitivas es peor negocio que escribirla.
- Pruebas de seguridad en el CI y revisión de código
Una regla semgrep propia vale más que cien reglas genéricas, porque conoce tu código:
# .semgrep/nimbus-tenant.yml
rules:
- id: consulta-sin-filtro-de-tenant
languages: [python]
severity: ERROR
message: >
Consulta sobre un modelo multi-tenant sin filtro por tenant_id. Usa la
dependencia `obtener_recurso` en vez de consultar directamente (§5).
patterns:
- pattern-either: # 1) que buscamos
- pattern: $DB.query(Reserva)...
- pattern: $DB.get(Reserva, ...)
- pattern-not: $DB.query(...).filter(..., Reserva.tenant_id == ..., ...)
- pattern-not-inside: # 2) donde SI esta permitido
def obtener_reserva(...): ...
paths: { exclude: ["tests/", "migrations/"] } # 3) rutas excluidasEs la regla que habría detectado el IDOR del pentest en el propio pull request. El patrón general de una buena regla propia son las tres partes que se ven: qué se busca, dónde está legítimamente permitido (pattern-not-inside) y qué rutas se excluyen. Sin las dos últimas, la regla genera ruido y se desactiva.
# .github/workflows/appsec.yml
name: Seguridad de aplicacion
on: [pull_request]
permissions: { contents: read, security-events: write }
jobs:
estatico:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with: { fetch-depth: 0 }
- uses: gitleaks/gitleaks-action@v2 # BLOQUEA: secretos
# --error hace fallar el job con hallazgos ERROR (nuestras reglas
# propias); las comunitarias entran como WARNING e informan.
- run: pip install semgrep pip-audit
- run: semgrep --config .semgrep/ --config p/python --error --sarif -o sast.sarif
- run: pip-audit -r requirements.txt --strict
# BLOQUEA: verifica que el tenant A no ve datos del B. Es la red que
# impide que un IDOR llegue a produccion otra vez.
- run: pytest tests/seguridad/test_aislamiento_tenants.py -q
- uses: github/codeql-action/upload-sarif@v3
if: always()
with: { sarif_file: sast.sarif }
dast:
runs-on: ubuntu-latest
needs: [estatico]
steps:
# `baseline` es pasivo: no ataca, solo observa lo que responde el
# servidor. Seguro contra preproduccion y termina en minutos.
- uses: zaproxy/[email protected]
with: { target: "https://preprod.nimbusreservas.example" }
continue-on-error: true # INFORMA, no bloquea: el DAST tiene ruidoLa política de qué rompe el build es la decisión de diseño más importante del pipeline:
| Comprobación | ¿Bloquea? | Motivo |
|---|---|---|
| Secreto detectado | Sí, siempre | No hay caso legítimo; el coste de dejarlo pasar es una credencial comprometida |
Regla semgrep propia (ERROR) |
Sí | Patrones que ya nos han hecho daño; el falso positivo se documenta como excepción |
| Pruebas de aislamiento entre tenants | Sí | Es el riesgo R-04, el más grave del producto |
| SCA: crítica con parche disponible | Sí | Accionable de inmediato |
| SCA sin parche · SAST comunitario · DAST | No, avisan | Irresoluble o con ruido alto: bloquear enseña a saltarse el control |
Revisión de código con enfoque de seguridad. Iván revisa cada pull request con siete preguntas, y no con una lectura general: ¿toca autorización y usa las dependencias comunes?; ¿alguna consulta concatena, o se salta el filtro por tenant?; ¿alguna entrada nueva carece de esquema de validación?; ¿la respuesta usa response_model y no devuelve campos de más?; ¿aparece algún secreto, URL interna o credencial, aunque sea en un comentario o en un fichero de prueba?; ¿los errores filtran trazas o datos?; ¿la dependencia nueva está justificada y fijada? Un cambio que toca autorización o datos personales lo revisan dos personas, y es la única regla de proceso que Nimbus impone por escrito.
- Errores, frontend, móvil y métricas
Manejo de errores. El cliente recibe un mensaje genérico y un identificador; el detalle va al log interno.
@app.exception_handler(Exception)
async def error_no_controlado(peticion: Request, exc: Exception):
# La traza COMPLETA va al log interno (05-02), nunca a la respuesta.
log.exception("error.no_controlado", extra={"campos": {"ruta": peticion.url.path}})
return JSONResponse(status_code=500, content={
"error": "Se ha producido un error interno", # sin traza, sin SQL, sin ruta
"referencia": request_id.get(), # Ruben lo cruza con el log
})Una traza en la respuesta regala versiones, rutas del sistema de ficheros, nombres de tablas y a veces credenciales de conexión. Y en producción el modo depuración va desactivado, sin excepciones.
Frontend. Las dependencias de la SPA se auditan igual que las del servidor (npm audit, osv-scanner) y con el mismo bot. Sobre dónde guardar el token:
localStorage |
Cookie HttpOnly + Secure + SameSite |
|
|---|---|---|
| Accesible desde JavaScript | Sí: cualquier XSS lo roba | No |
| Se envía sola | No (hay que añadirla) | Sí → requiere protección CSRF |
| Persistencia y revocación | Hasta borrarla; difícil de revocar | Caducidad y revocación desde el servidor |
La cookie HttpOnly es mejor opción: el XSS es más frecuente que el CSRF y sus consecuencias son peores, y el CSRF se resuelve con SameSite más token, mientras que un token en localStorage no tiene defensa alguna frente al XSS. Además, el frontend no es un control de seguridad: ocultar un botón según el rol es usabilidad; el permiso se comprueba siempre en el servidor.
Aplicación móvil. Tres medidas: fijación de certificado (pinning) con plan de rotación —un pinning sin plan de cambio deja la app inservible el día que rota el certificado—; no almacenar secretos en el binario, porque se decompila en minutos; y almacenamiento seguro de credenciales en el llavero del sistema. El principio que las une: nunca confíes en el cliente; toda comprobación que importe se repite en el servidor.
| Métrica de AppSec | Objetivo Nimbus |
|---|---|
| Cobertura de pruebas de aislamiento entre tenants | 100 % de los endpoints que devuelven datos de cliente |
| Vulnerabilidades introducidas por release · MTTR de aplicación | Tendencia decreciente · ≤ 7 días para críticos |
| PR con revisión de seguridad al tocar datos personales · dependencias con parche sin aplicar > 30 días | 100 % · 0 |
Errores Comunes y Consejos
- Confiar en la validación del navegador. Es usabilidad;
curlla ignora. Toda validación se repite en el servidor. - Creer que el ORM protege por sí solo. Protege si se usa su API; con
text()y una f-string vuelve la inyección. - Decidir la autorización en cada endpoint, o devolver 403 en lugar de 404 para recursos ajenos. Lo primero garantiza que alguien lo olvide; lo segundo confirma que ese identificador existe en otro tenant, y eso ya es una fuga.
- Añadir
'unsafe-inline'para que la CSP deje de quejarse. Es desactivarla manteniendo la apariencia. - Guardar el token en
localStorage, o devolver trazas de error al cliente. Lo primero lo roba cualquier XSS; lo segundo regala versiones, rutas, nombres de tablas y a veces credenciales. - Consejo: escribe una regla
semgreppropia por cada incidente. Es la forma más barata de que un fallo no ocurra dos veces, y habría detectado el IDOR en el pull request. - Consejo: las pruebas de aislamiento entre tenants son la mejor inversión del capítulo. Una por endpoint, bloqueantes en el CI, y R-04 deja de depender de la memoria de nadie.
- Consejo: despliega la CSP en
Report-Onlydos semanas. Es la diferencia entre proteger la SPA y romperla un viernes por la tarde.
Ejercicios
Ejercicio 1 — Revisar un pull request
Iván propone este endpoint nuevo. Identifica todos los problemas de seguridad y reescríbelo.
@router.post("/informes/exportar")
def exportar(tenant_id: int, formato: str, columnas: str, db=Depends(get_db)):
sql = f"SELECT {columnas} FROM reservas WHERE tenant_id = {tenant_id}"
filas = db.execute(sql).fetchall()
ruta = f"/var/www/html/export/{tenant_id}_{formato}.csv"
escribir(ruta, filas)
return {"url": f"https://nimbusreservas.example/export/{tenant_id}_{formato}.csv"}Ejercicio 2 — Diseñar la defensa de una funcionalidad nueva
El módulo de teleconsulta permitirá a las clínicas subir un logotipo que se mostrará en la sala de espera virtual, y configurar una URL de webhook a la que Nimbus enviará avisos cuando termine una consulta.
- Enumera los riesgos de cada una de las dos funcionalidades.
- Define los controles concretos, indicando dónde se aplica cada uno.
- Escribe la validación de la URL de webhook y explica qué ataque corta cada línea.
Ejercicio 3 — Decidir la política del pipeline
El equipo discute qué debe romper el build. Lucía quiere bloquear con cualquier hallazgo alto o crítico de cualquier herramienta; Iván dice que así no se despliega nunca. Propón una política razonada, indicando para cada comprobación si bloquea o informa, y explica cómo se gestionan las excepciones y qué señal indicaría que la política está mal calibrada.
Soluciones
Ejercicio 1
Cinco bloques de problemas, de mayor a menor gravedad:
| # | Problema | Consecuencia |
|---|---|---|
| 1 | tenant_id llega como parámetro |
IDOR directo: exportar los datos de cualquier clínica. Es el fallo del pentest, repetido |
| 2 | columnas y tenant_id se concatenan en el SQL |
Inyección total, y por dos vías. columnas es además el punto que no se puede parametrizar y aquí no se valida contra lista cerrada |
| 3 | Escritura en /var/www/html con URL predecible |
El fichero queda servido públicamente y sin autenticación, y cualquiera puede probar identificadores para descargar exportaciones ajenas |
| 4 | Sin comprobación de permiso ni límite de tasa | Cualquier usuario autenticado exporta todo; y un bucle genera un DoS y llena el disco |
| 5 | Sin registro de auditoría | Una exportación masiva no queda trazada y D-05 de 05-02 no puede detectarla |
COLUMNAS = {"fecha": "inicio", "cliente": "cliente", "estado": "estado"} # lista CERRADA
@router.post("/informes/exportar")
@limitar("3/hora") # cuota por usuario y tenant
def exportar(peticion: PeticionExport, # esquema validado
u: Usuario = Depends(exige("informes:exportar")),
tid: int = Depends(tenant_actual), # del token, no de la URL
db=Depends(get_db)):
cols = [COLUMNAS[c] for c in peticion.columnas if c in COLUMNAS]
if not cols: raise HTTPException(422, "Columnas no validas")
filas = db.execute(
text(f"SELECT {', '.join(cols)} FROM reservas "
"WHERE tenant_id = :t AND inicio BETWEEN :d AND :h LIMIT 50000"),
{"t": tid, "d": peticion.desde, "h": peticion.hasta}).fetchall()
# Bucket PRIVADO, clave aleatoria y URL firmada de corta vida: nunca en el
# webroot ni con nombre predecible.
clave = f"export/{tid}/{uuid4().hex}.csv"
s3.put_object(Bucket="nimbus-adjuntos-prod", Key=clave, Body=a_csv(filas))
auditar("reserva.exportada", actor=u.id, tenant=tid, n_registros=len(filas))
return {"url": url_firmada(clave, segundos=120)} # alimenta D-05 (05-02)Ejercicio 2
(1) Riesgos. El logotipo es una carga de fichero: SVG con JavaScript incrustado (XSS almacenado que se ejecuta en el navegador de todos los pacientes de esa sala), fichero enorme que agota disco o ancho de banda, tipo falseado por extensión o Content-Type, path traversal en el nombre y malware alojado bajo el dominio de Nimbus. El webhook es SSRF por diseño: el cliente elige a dónde llama nuestro servidor —incluido 169.254.169.254 para robar credenciales de la instancia, o direcciones internas para escanear la VPC—, más redirecciones hacia destinos internos, respuestas gigantes o lentas que agotan hilos, y uso de Nimbus como amplificador contra terceros.
(2) Controles. Para el logotipo: tamaño y dimensiones máximas; tipo real por contenido limitado a PNG y JPEG, con SVG explícitamente prohibido; reprocesado de la imagen, que al recodificarla elimina cualquier carga incrustada y es el control más eficaz; nombre generado por el servidor; almacenamiento en el bucket privado con URL firmada o en un dominio de contenido separado para que un fallo no herede el origen de la aplicación; nosniff al servirlo; y antivirus. Para el webhook: solo HTTPS, validación de la IP resuelta con bloqueo de rangos privados, link-local y loopback; redirecciones desactivadas; tiempos de espera cortos y límite de tamaño de respuesta; reintentos con retroceso exponencial y desactivación tras N fallos; envío desde una red de salida separada sin ruta a la VPC (05-04); firma HMAC del cuerpo para que el receptor verifique el origen (03-04); y verificación de propiedad de la URL antes de activarla.
(3) Validación:
def validar_webhook(url: str) -> str:
p = urlparse(url)
if p.scheme != "https": # corta http en claro, file://,
raise ValueError("Solo HTTPS") # gopher:// y esquemas raros
if p.port and p.port != 443: # corta el escaneo de puertos internos
raise ValueError("Puerto no permitido")
ips = {ipaddress.ip_address(i[4][0]) for i in socket.getaddrinfo(p.hostname, None)}
for ip in ips: # TODAS las IPs, no solo la primera:
if (ip.is_private or ip.is_loopback # un dominio resuelve a varias, y
or ip.is_link_local # 169.254.169.254 son los metadatos
or ip.is_reserved):
raise ValueError("Destino interno bloqueado")
return urlY la advertencia que hace incompleta cualquier validación previa: entre el momento de validar y el de conectar, el DNS puede cambiar (DNS rebinding). Por eso la validación en el código es necesaria pero no suficiente, y el control que realmente cierra el riesgo es el de red: el proceso que envía webhooks sale por una subred sin ruta hacia la VPC ni hacia el servicio de metadatos.
Ejercicio 3
Ambos tienen razón en parte, y la síntesis es un criterio, no un umbral: bloquea lo que es inequívoco y accionable ahora; informa de lo que es ambiguo o no tiene solución disponible. Aplicado a Nimbus, la política es la tabla del §11: bloquean los secretos, las reglas propias de semgrep, las pruebas de aislamiento entre tenants y las vulnerabilidades críticas con parche disponible; informan el SAST comunitario, el DAST baseline y las vulnerabilidades sin parche.
La postura de Lucía falla por un motivo concreto: bloquear con cualquier alto de cualquier herramienta significa que un falso positivo de una regla genérica o una CVE sin parche en una biblioteca transitiva paran el despliegue de una corrección urgente. El resultado previsible no es más seguridad, sino que alguien añada --no-verify o desactive el job, y entonces se pierden también las comprobaciones buenas. La de Iván falla si se lleva al extremo: un pipeline que nunca bloquea es un informe, no un control.
Gestión de excepciones: se declaran en el repositorio (.semgrepignore, fichero de supresiones del SCA) con tres campos obligatorios —motivo, responsable y fecha de caducidad— y se revisan en el mismo ciclo que las excepciones de 04-02; una supresión sin caducidad es un agujero permanente disfrazado de decisión técnica. Señales de mala calibración, en ambos sentidos: si más del 20 % de las ejecuciones falla por seguridad, o si aparecen commits con la comprobación saltada, la política es demasiado estricta y hay que mover comprobaciones a modo informativo o afinar reglas; si el pipeline lleva seis meses sin bloquear nada y el pentest sigue encontrando fallos de patrón, es demasiado laxa. La métrica que zanja la discusión es cuántos hallazgos del último pentest habrían sido detectados por el pipeline: si son pocos, el problema no es el umbral, son las reglas.
Conclusión
Has trabajado sobre la superficie que no se puede cerrar con un cortafuegos. Sabes integrar la seguridad en el SSDLC con tres puntos de control baratos, y por qué corregir en producción cuesta entre treinta y cien veces más que en diseño. Dominas la validación de entrada con lista de permitidos y esquemas declarativos, y la distinción que evita falsas seguridades: validar no sustituye a proteger cada destino, porque el peligro está en el contexto de uso, no en el dato.
Sabes corregir de verdad los ataques de 02-02: consultas parametrizadas —y por qué un ORM no basta si se usa con text() y f-strings—, con lista cerrada para lo que no se puede parametrizar; codificación de salida y CSP contra XSS, con HttpOnly para que un XSS no llegue a la sesión; SameSite y tokens anti-CSRF, más la regla de que nada que cambie estado sea GET; SSRF con lista de permitidos, validación de la IP resuelta y bloqueo del servicio de metadatos; y carga de ficheros con seis controles combinados y descarga por URL firmada. Y te llevas la pieza central: la autorización centralizada en dependencias reutilizables, donde el tenant_id sale del token y nunca de la petición, con un punto único de resolución por recurso, 404 en lugar de 403, denegación registrada, RLS como red inferior y pruebas automáticas de aislamiento entre tenants en el CI. El IDOR deja de depender de que nadie se olvide. Conoces el OWASP API Top 10 aplicado a Nimbus, con BOPLA resuelto mediante response_model y extra: "forbid" contra la asignación masiva, límite de tasa por cliente y por tenant, paginación con tope duro, CORS explícito, versionado con retirada anunciada y documentación que no filtra. Sabes gestionar secretos con gestor, inyección en arranque, aislamiento por entorno en el CI y gitleaks en pre-commit; tienes el bloque completo de cabeceras explicadas una a una y el procedimiento de cuatro pasos para desplegar una CSP sin romper la SPA, con la trampa de 'unsafe-inline' señalada; y sabes fijar dependencias por hash, automatizar actualizaciones y evaluar una dependencia antes de añadirla. Y tienes el pipeline de AppSec con una regla semgrep propia que habría cazado el IDOR en el pull request, ZAP baseline informativo, la política razonada de qué rompe el build y las siete preguntas de la revisión de código, además del manejo seguro de errores, la comparación localStorage frente a cookie HttpOnly, la protección de la app móvil y las métricas de AppSec.
El código ya se defiende. Pero todo ese código se ejecuta sobre algo: un sistema operativo con paquetes, servicios, usuarios y permisos, y unos cuarenta portátiles repartidos entre Valencia y las casas de media plantilla. Ahí sigue vivo, desde 01-04, el python -m http.server que nadie apagó, y ahí están los equipos sin cifrar y las cuentas con privilegios de administrador que no hacen falta. En Hardening de Sistemas y Seguridad del Endpoint (05-06) aplicamos la reducción de superficie al sistema operativo: líneas base reproducibles con CIS y OpenSCAP, sshd_config directiva a directiva, auditd, fail2ban, gestión de parches como proceso, cifrado de disco, antivirus frente a EDR, MDM, osquery para preguntarle al parque, y automatización con Ansible para que la línea base no se aplique nunca a mano.
Curso de Fundamentos de Seguridad Informática
Módulo 1: Introducción a la Seguridad Informática
- Conceptos Básicos de Seguridad Informática
- Tipos de Amenazas y Vulnerabilidades
- Principios de la Seguridad Informática
- Activos, Superficie de Ataque y Actores de Amenaza
Módulo 2: Ciberseguridad
- Definición y Alcance de la Ciberseguridad
- Tipos de Ataques Cibernéticos
- Ingeniería Social y Phishing
- Medidas de Protección en Ciberseguridad
- Identidad, Autenticación y Control de Acceso
- Casos de Estudio de Incidentes de Ciberseguridad
Módulo 3: Criptografía
- Introducción a la Criptografía
- Criptografía Simétrica
- Criptografía Asimétrica
- Funciones Hash, HMAC y Almacenamiento de Contraseñas
- Protocolos Criptográficos
- Gestión de Claves, Certificados y PKI
- Aplicaciones de la Criptografía
Módulo 4: Gestión de Riesgos y Medidas de Protección
- Evaluación de Riesgos
- Políticas de Seguridad
- Controles de Seguridad
- Riesgo de Terceros y Cadena de Suministro
- Plan de Respuesta a Incidentes
- Recuperación ante Desastres y Continuidad de Negocio
Módulo 5: Herramientas y Técnicas de Seguridad
- Herramientas de Análisis de Vulnerabilidades
- Técnicas de Monitoreo y Detección
- Pruebas de Penetración
- Seguridad en Redes
- Seguridad en Aplicaciones
- Hardening de Sistemas y Seguridad del Endpoint
- Seguridad en la Nube y en Contenedores
Módulo 6: Buenas Prácticas y Normativas
- Buenas Prácticas en Seguridad Informática
- Normativas y Estándares de Seguridad
- Protección de Datos Personales y RGPD en la Práctica
- Cumplimiento y Auditoría
- Formación y Concienciación
- Ética, Aspectos Legales y Divulgación Responsable
