1Por qué importa
La validación de entrada es la frontera de confianza de una API. Todo lo que la cruza sin
comprobarse se convierte en un supuesto que la lógica de negocio da por cierto: que
monto es un número, que usuario es una cadena, que no llegan campos
que nadie esperaba. Cada uno de esos supuestos, si no se verifica en la puerta, es una vía
para que un cuerpo inesperado provoque un comportamiento inesperado: un error 500 que filtra
una traza, una operación que se ejecuta con un tipo que rompe una comprobación posterior, o un
campo de más que acaba en la base de datos.
La salida importa por la razón simétrica. Una API que devuelve el registro entero "porque es cómodo" filtra los campos internos que ese registro arrastra: el hash de la contraseña, el identificador interno, marcas de estado que revelan cómo funciona el sistema por dentro. El cliente no pidió esos campos y no debería verlos, pero ahí están, en cada respuesta, esperando a que alguien los lea. Y la enumeración es la más sutil de las tres: no filtra un dato directamente, filtra la existencia de un dato, que muchas veces es justo lo que el atacante necesita para el siguiente paso.
No estás aprendiendo a poner if not campo: error por todas partes. Estás
aprendiendo a tratar el cuerpo de una petición como entrada hostil hasta que un esquema
demuestre lo contrario, y a tratar cada respuesta como una filtración potencial hasta que
una lista de campos públicos demuestre que solo sale lo que debe.
2Conceptos
- Validación de sintaxis: ¿el cuerpo es JSON bien formado? Si no, 400 y punto.
- Validación de esquema: ¿tiene los campos correctos, del tipo correcto, y ninguno de más?
- Esquema: la descripción declarativa de la forma válida de un cuerpo (campos, tipos, rangos, obligatoriedad).
- Sobre-exposición de salida: devolver campos que el cliente no necesita y no debería ver (clave, id interno).
- Lista de campos públicos: la allow-list aplicada a la respuesta; solo esos campos salen.
- Enumeración: deducir qué valores existen (usuarios, ids) a partir de las diferencias en las respuestas.
- Oráculo: cualquier señal —código, mensaje, tiempo— que responde "sí/no" a una pregunta que no debería responder.
3Explicación profunda
Conviene separar dos cosas que se llaman igual. Validar la sintaxis es comprobar que el
cuerpo es JSON bien formado; un cuerpo truncado o con una coma de más se rechaza con 400 antes
de mirar nada. Validar el esquema es lo que viene después: el JSON parsea, pero ¿tiene
los campos que este endpoint necesita, del tipo correcto, sin campos desconocidos? La mayoría
de las APIs hacen lo primero de gratis, porque el parser falla solo, y se saltan lo segundo,
que es justo donde vive la seguridad. Un cuerpo {"monto": "mucho"} es JSON
perfectamente válido y a la vez completamente inválido para una transferencia.
Un esquema se declara, no se programa a mano. En vez de una cascada de if, se
describe la forma válida —monto es un número positivo, usuario es
una cadena de 3 a 32 caracteres, no se admiten campos adicionales— y una librería la aplica.
La ventaja no es solo escribir menos: es que la validación es completa por defecto.
Una cascada de if olvida casos; un esquema con additionalProperties:
false rechaza cualquier campo que no haya declarado, incluido el que un atacante
inventará mañana. Es la misma idea que la lista de campos permitidos de la lección anterior,
ahora sobre la forma entera del cuerpo.
La validación de entrada tiene que ocurrir en la puerta, antes de la lógica de negocio,
y tiene que ser fallo seguro: si algo no encaja, se rechaza y no se toca el estado. El
error clásico es validar a medias y avanzar: comprobar que usuario existe pero no
su tipo, empezar a operar, y fallar a la mitad dejando el sistema en un estado intermedio. Una
petición inválida no debe producir ningún efecto lateral. O pasa entera la validación, o no
pasa nada.
La salida es la otra mitad. Devolver el objeto de dominio directamente es la causa más común de fuga de datos por API: ese objeto arrastra campos que existen por razones internas y que nunca debieron viajar al cliente. La defensa es un modelo de salida explícito —un DTO de respuesta, una serialización con lista de campos públicos— de modo que añadir un campo interno al modelo de dominio no lo publique por accidente. Igual que en la entrada, lo seguro es enumerar lo que sale, no lo que se oculta.
La enumeración es más fina porque no filtra el dato, filtra su existencia. Si
/login responde 404 "usuario no existe" y 401 "clave incorrecta", un atacante
prueba nombres y, sin acertar ni una contraseña, obtiene la lista exacta de usuarios reales.
Lo mismo con /perfil/<usuario> que devuelve 200 o 404, o con un registro
que responde distinto según si el correo ya está dado de alta. La existencia de una cuenta es
un dato, y a menudo el que abre el siguiente ataque: relleno de credenciales dirigido, phishing
a usuarios confirmados, o simplemente saber a quién atacar. La defensa es hacer las respuestas
indistinguibles: el mismo código, el mismo mensaje, y —cuando importa— el mismo tiempo
de respuesta, porque un login que tarda más cuando el usuario existe es un oráculo temporal.
4Ejemplo sencillo
# El portero de un edificio VALIDACION DE SINTAXIS ¿traes un papel? si no, no pasas. (¿es JSON?) VALIDACION DE ESQUEMA ¿el papel es el formulario correcto, relleno, sin hojas de mas pegadas detras? (¿tiene la forma valida?) # El portero que enumera sin querer Preguntas por "Ana Torres, 4o B". - "aqui no vive nadie con ese nombre" -> no existe - "vive aqui pero no te dejo subir" -> existe Sin subir a ningun piso, ya sabes quien vive en el edificio: el portero te lo confirmo con dos respuestas distintas. La respuesta correcta es la misma para los dos casos: "no puedo darte esa informacion".
5Ejemplo técnico
El laboratorio deja ver los tres fallos con tres peticiones. Míralos como un mapa de lo que la API acepta de más y devuelve de más:
| Petición | Qué pasa en la API vulnerable | Qué debería pasar |
|---|---|---|
POST /registro {"usuario":42,"clave":[]} | Lo acepta: no comprueba tipos | 400 por esquema: usuario debe ser cadena |
GET /perfil/ana | Devuelve el registro entero, clave incluida | Solo campos públicos: usuario y rol |
GET /perfil/zzz vs /perfil/ana | 404 vs 200: confirma quién existe | Respuesta uniforme para el cliente que no es dueño |
Escrito como riesgo con actor, vía, activo y consecuencia: "Cualquiera puede enumerar la lista completa de usuarios reales comparando los códigos de respuesta de /perfil y /login, y leer la clave de cualquier usuario en la salida de /perfil, lo que convierte una API de consulta en un directorio de credenciales."
6Diagrama
Ver el mismo diagrama como código Mermaid (editable)
flowchart LR
P[petición · cuerpo hostil] --> V[validar esquema]
V -->|no encaja| E[400 uniforme]
V -->|encaja| L[lógica]
L --> S[filtrar salida · solo campos públicos]
S --> O[respuesta sin clave ni id interno]
7Código
El registro del laboratorio no valida tipos y devuelve el registro entero. La validación de esquema y el filtro de salida se pueden escribir a mano y quedan claros:
# Validación de esquema en la puerta: tipos, obligatoriedad, nada de más PUBLICOS = ("usuario", "rol") # lo unico que puede SALIR def validar_registro(cuerpo): if not isinstance(cuerpo, dict): return "el cuerpo debe ser un objeto" permitidos = {"usuario", "clave"} extra = set(cuerpo) - permitidos if extra: return "campos no permitidos" # additionalProperties: false u, c = cuerpo.get("usuario"), cuerpo.get("clave") if not isinstance(u, str) or not (3 <= len(u) <= 32): return "usuario invalido" if not isinstance(c, str) or len(c) < 8: return "clave invalida" return None # None = valido def solo_publicos(registro): return {k: registro[k] for k in PUBLICOS if k in registro}
Y la corrección de la enumeración: un cliente que no es dueño del perfil recibe siempre la misma respuesta, exista el usuario o no. Solo el dueño (probado por el token) obtiene el 200:
# Respuesta uniforme: no confirmar la existencia a quien no es dueño def perfil(nombre, quien_llama): if nombre != quien_llama: # mismo codigo y mensaje exista o no: sin oraculo return 404, {"error": "no encontrado"} registro = USUARIOS.get(nombre) if registro is None: return 404, {"error": "no encontrado"} return 200, solo_publicos(registro) # nunca la clave
La clave ya no aparece en ninguna respuesta, y "no existe" y "no es tuyo" dan la
misma salida. Un atacante que pruebe nombres no aprende nada.
8Qué puede salir mal
Validar sintaxis y creer que es validación. Que el cuerpo sea JSON válido no dice
nada sobre su forma. {"monto": "mucho"} parsea y es basura para la lógica.
Olvidar additionalProperties: false. Un esquema que valida los campos
que conoce pero ignora los que no, deja pasar el campo de más. La validación tiene que
rechazar lo desconocido, no solo comprobar lo conocido.
Serializar el objeto de dominio. Devolver el modelo tal cual filtra sus campos
internos. El día que alguien añade un campo notas_internas al modelo, aparece
en todas las respuestas sin que nadie lo decidiera.
Mensajes de error demasiado útiles. "El usuario no existe" y "clave incorrecta" son dos mensajes que juntos forman un oráculo de enumeración. Uno solo, uniforme, cierra la puerta: "usuario o clave incorrectos".
9Cómo lo abusaría un atacante
La enumeración es reconocimiento, y el reconocimiento es barato. Un atacante escribe un
bucle que prueba mil nombres de usuario contra /login con una clave cualquiera
y anota cuáles devuelven 401 (existe) y cuáles 404 (no existe). En unos segundos tiene la
lista exacta de cuentas reales, sin haber acertado ni una contraseña. Esa lista no es el
premio: es el mapa para el siguiente ataque, que ahora se dirige solo a usuarios que sabe
que existen.
Los tipos inesperados son la otra palanca. Enviar un array donde se espera una cadena, un número gigantesco donde se espera un entero pequeño, o un objeto anidado donde se espera un valor plano, busca provocar un error 500. Ese error muchas veces trae una traza que revela el framework, la versión, rutas del sistema de ficheros o fragmentos de la consulta. La validación de esquema convierte todos esos casos en un 400 aburrido y sin información.
Y la sobre-exposición de salida se explota sin esfuerzo: se lee. Cada campo que la API devuelve de más es información gratis sobre cómo funciona por dentro. Un id interno secuencial revela cuántos registros hay y permite iterarlos; un campo de estado revela la máquina de estados del negocio; una clave, evidentemente, lo revela todo. El atacante no tiene que hacer nada especial: la API se lo cuenta en cada respuesta.
10Cómo defenderlo
- Declara un esquema por endpoint y valídalo en la puerta, antes de la lógica. Con
additionalProperties: falsepara rechazar lo desconocido. - Trata una petición inválida como sin efecto: o pasa entera la validación, o no se toca el estado.
- Define un modelo de salida con la lista de campos públicos. Nunca serialices el objeto de dominio directamente.
- Uniforma las respuestas de autenticación: un solo mensaje para "usuario o clave incorrectos", el mismo código, y cuidado con el tiempo.
- No confirmes la existencia de un recurso a quien no está autorizado a saberlo: mismo 404 para "no existe" y "no es tuyo".
- Convierte los errores 500 en 400 validados: un cuerpo raro nunca debe llegar a producir una traza para el cliente.
11Laboratorio
Levanta la API y observa los tres fallos: entrada sin esquema, salida de más y enumeración.
cd cursos/seguridad/labs/lab-api-vuln
docker compose up -d --build
Ahora defiende las tres. En api/servidor.py: añade una validación de
esquema a _registro (tipos y additionalProperties), filtra la
salida de /perfil a los campos públicos, y uniforma /login a
un solo mensaje. Reconstruye y comprueba:
docker compose up -d --build
curl -s http://localhost:8096/perfil/ana
La respuesta ya no debe traer clave, el cuerpo con tipos malos debe dar
400, y /login debe responder igual para usuario inexistente y clave
incorrecta. Cuando termines:
docker compose down -v
12Ejercicios
Sintaxis, esquema o salida
Clasifica cada fallo como validación de sintaxis, de esquema o de salida:
(1) la API acepta {"monto": "mucho"};
(2) la API devuelve el hash de la contraseña;
(3) la API revienta con un cuerpo JSON truncado;
(4) la API acepta un campo es_admin que no esperaba;
(5) la respuesta trae un id interno secuencial.
additionalProperties: false).(1) esquema: tipo incorrecto. (2) salida: sobre-exposición. (3) sintaxis: JSON malformado. (4) esquema: campo no permitido. (5) salida: filtra un dato interno.
Clave: esquema · salida · sintaxis · esquema · salida. Solo (3) lo detecta el parser gratis; el resto necesita esquema o filtro de salida explícitos.
Filtra la salida de /perfil
Con el lab-api-vuln levantado, modifica el handler de /perfil en
api/servidor.py para que devuelva solo los campos públicos
(usuario y rol) y nunca la clave. Reconstruye y demuestra con
curl que la clave ya no aparece en la respuesta.
do_GET devuelve el registro completo.curl -s http://localhost:8096/perfil/ana | grep clave: no debe encontrar nada.
Aplica la función solo_publicos de la lección antes de responder, con
PUBLICOS = ("usuario", "rol"). La verificación:
# tras reconstruir, esto no debe imprimir nada curl -s http://localhost:8096/perfil/ana | grep -i clave \ && echo "FALLA: aun filtra la clave" || echo "OK: sin clave"
Clave: el éxito es que la respuesta solo trae usuario y rol. Serializar el registro entero es la causa; el filtro explícito de campos públicos es la cura.
El oráculo temporal
Uniformar el mensaje de /login no cierra del todo la enumeración si el tiempo
de respuesta delata al usuario. Investiga por qué un login puede tardar más cuando el
usuario existe (pista: la verificación de la contraseña) y diseña cómo hacer que el tiempo
sea indistinguible. ¿Qué se hace cuando el usuario no existe para igualar el coste?
El login existente hace más trabajo cuando el usuario existe: recupera su hash y ejecuta la función de derivación, que es cara por diseño. Cuando el usuario no existe, sale al instante. Esa diferencia de tiempo es un oráculo aunque el mensaje sea idéntico. La defensa es hacer siempre el mismo trabajo: si el usuario no existe, verificar la clave recibida contra un hash señuelo fijo, de modo que el coste temporal sea igual en ambos casos. Así el atacante no puede distinguir por reloj lo que ya no distingue por mensaje.
Clave: igualar el trabajo, no solo el mensaje. Verificar contra un hash señuelo cuando el usuario no existe hace el tiempo indistinguible y cierra el oráculo temporal.
Red Team · enumera todos los usuarios
Dentro del lab-api-vuln, y solo dentro de él: sin acertar ninguna contraseña,
obtén la lista de usuarios que existen usando solo las diferencias de respuesta de
/login o /perfil. Escribe el bucle y explica qué señal usaste
para decidir "existe" o "no existe".
sh con una lista de nombres y curl -o /dev/null -w "%{http_code}" basta.
Un bucle prueba nombres contra /login con una clave falsa: 401 significa
"existe pero clave mal", 404 significa "no existe". También sirve
GET /usuarios, que en esta API regala la lista entera. La señal es la
diferencia de código; no hizo falta acertar ninguna contraseña. En una API bien hecha,
los dos casos darían la misma respuesta y este bucle no aprendería nada.
# dentro del cliente del lab for n in ana beto zzz tesoreria nadie; do code=$(curl -s -o /dev/null -w "%{http_code}" \ -X POST http://api:8000/login -H 'Content-Type: application/json' \ -d "{\"usuario\":\"$n\",\"clave\":\"x\"}") echo "$n -> $code" done
Clave: 401 = existe, 404 = no existe. La enumeración no necesita adivinar contraseñas; le basta con que la API responda distinto. La defensa es una respuesta uniforme.
Blue Team · detecta la enumeración en los logs
Ejecuta el bucle de enumeración del ejercicio anterior y mira
docker compose logs api. ¿Qué patrón deja un ataque de enumeración en el log
de acceso, y qué regla lo detectaría sin dar falsos positivos con un usuario que se
equivoca de contraseña un par de veces?
En el log aparece una ráfaga de POST /login desde el mismo origen, con
muchos nombres de usuario distintos, en pocos segundos. Lo que distingue enumeración de
un usuario torpe no es el número de errores, sino la variedad de usuarios
probados: un humano insiste en su propia cuenta, un atacante recorre una lista. Una
regla razonable cuenta usuarios distintos por origen y ventana de tiempo, y alerta por
encima de un umbral. Para que la regla exista, el log tiene que registrar el usuario
probado (no la clave) además del origen y la hora.
Clave: la señal es la diversidad de usuarios por origen y ventana, no el volumen de 401. El log debe traer usuario probado, origen y hora; nunca la clave.
Architecture Challenge · esquema como contrato
Diseña cómo la validación de entrada y salida se vuelve parte del contrato de la API y no una comprobación dispersa. Define: dónde vive el esquema, cómo se comparte entre la validación en tiempo de ejecución y la documentación, cómo evitas que un endpoint nuevo se despliegue sin esquema, y cómo garantizas que ninguna respuesta serialice el objeto de dominio directamente.
Una propuesta razonable: un esquema declarativo por endpoint (OpenAPI o JSON Schema) que es la única fuente de verdad; de él se genera tanto la validación en tiempo de ejecución como la documentación, de modo que no puedan divergir. La salida se define con su propio esquema de campos públicos, y la serialización pasa siempre por él —nunca por el objeto de dominio— de forma que un campo nuevo del dominio no se publique sin declararse. En el pipeline, una comprobación que falle si un endpoint no tiene esquema de entrada y de salida, y un test de contrato que envíe tipos inválidos a cada endpoint y exija un 400.
Clave: no hay una única respuesta. Se evalúa que el esquema sea una sola fuente para validación y documentación, que la salida pase por un modelo de campos públicos, y que haya una puerta que impida desplegar un endpoint sin esquema.
13Preguntas de comprensión
¿Qué añade la validación de esquema sobre "la app ya comprueba algunas cosas"?
Un esquema con additionalProperties: false es completo por defecto: rechaza tipos malos y campos desconocidos, incluido el que un atacante invente mañana.
La API responde 404 si el usuario no existe y 401 si la clave es incorrecta. ¿Qué regala?
Dos respuestas distintas dejan probar nombres y separar los que existen de los que no, sin acertar ninguna clave. La defensa es una respuesta uniforme.
¿Por qué es un fallo que /perfil devuelva el registro entero?
Serializar el objeto de dominio publica sus campos internos. La cura es un modelo de salida con solo los campos públicos.
Validar la entrada, ¿de qué otra clase de problema protege además del dato mal formado?
Un tipo inesperado busca provocar un 500 que filtra información del sistema. La validación de esquema convierte todos esos casos en un 400 sin datos.
14Reto adicional
El registro también enumera
La enumeración no vive solo en el login. Piensa en el registro: si POST /registro
responde 409 "ese usuario ya existe", acabas de dar un oráculo para enumerar cuentas por la
puerta de alta. Diseña cómo un flujo de registro puede evitar confirmar la existencia de una
cuenta y aun así impedir duplicados. ¿Qué papel juega el correo de confirmación aquí?
El registro responde siempre igual —"si el correo es válido, te hemos enviado un enlace de confirmación"— exista o no ya la cuenta. Si existe, el correo real recibe un aviso de "alguien intentó registrarse con tu dirección"; si no existe, recibe el enlace de alta. El atacante que prueba direcciones no aprende nada por la respuesta de la API: la información va al buzón del dueño, no a quien pregunta. La unicidad se garantiza del lado del servidor sin filtrarla por la respuesta síncrona.
Clave: mover la señal de existencia fuera de la respuesta inmediata y hacia el canal del dueño (el correo) cierra el oráculo sin perder la garantía de unicidad. Responder distinto según exista la cuenta es el error.
15Resumen
- Validar sintaxis (¿es JSON?) no es validar esquema (¿tiene la forma correcta?). La seguridad vive en lo segundo.
- Un esquema con
additionalProperties: falsees completo por defecto: rechaza tipos malos y campos desconocidos. - La validación ocurre en la puerta y es fallo seguro: una petición inválida no toca el estado.
- La salida se filtra a una lista de campos públicos; nunca se serializa el objeto de dominio.
- La enumeración filtra la existencia de un dato mediante diferencias de código, mensaje o tiempo.
- La defensa contra enumeración es hacer las respuestas indistinguibles, incluido el tiempo.
16Mis notas
Estas notas se guardan solo en este navegador. No se envían a ningún servidor. Expórtalas desde el panel de progreso si cambias de máquina.
17Recursos
Los comentarios se cargan solo si los pides, desde GitHub Discussions. No se carga nada de terceros mientras no pulses.