1Por qué importa
La mayor parte del código de autorización que se escribe protege el acceso a un recurso: ¿puede este usuario ver este pedido? Es la pregunta de la lección de control de acceso roto, y es imprescindible. Pero hay una segunda pregunta que casi nadie escribe y que una API deja abierta por omisión: ¿puede este usuario modificar este campo de este recurso? Leer y escribir no son el mismo permiso, y dentro de escribir, no todos los campos valen lo mismo. Cambiar tu propio nombre es una cosa; cambiar tu propio rol es otra completamente distinta, aunque las dos sean "editar mi perfil".
El mass assignment aparece porque los frameworks facilitan justo lo peligroso. Convertir el
cuerpo JSON en un objeto y volcarlo entero a la base de datos es una línea, y funciona a la
primera para el caso feliz. El campo que sobra no da error: se guarda en silencio. El día que
alguien descubre que puede añadir "rol": "admin" al mismo cuerpo con el que se
registra, la comodidad de esa línea se cobra la aplicación entera. No hace falta romper nada:
la API hace exactamente lo que le pidió el atacante.
No estás aprendiendo a validar formularios. Estás aprendiendo a decidir, campo por campo, quién tiene derecho a escribir qué, y a no delegar esa decisión en la forma del cuerpo que te manda un cliente que no controlas. La regla es simple de enunciar y difícil de mantener: el servidor decide los campos privilegiados, nunca el cliente.
2Conceptos
- Autenticación: demostrar quién eres. Un token válido responde a esto.
- Autorización: decidir qué te está permitido hacer. Es una decisión por operación, no una propiedad del token.
- Scope: la lista de operaciones que un token concreto habilita (
pedidos:leer,perfil:escribir). Acota permisos, no identidad. - Autorización a nivel de objeto: ¿este usuario puede tocar este recurso? (control de acceso roto, IDOR).
- Autorización a nivel de campo: dentro de un recurso que sí puede tocar, ¿qué campos puede escribir?
- Mass assignment: el servidor copia el cuerpo entero al objeto, así que el cliente escribe campos que debía fijar el servidor.
- Lista de campos permitidos (allow-list): la corrección; solo se leen del cuerpo los campos que el cliente tiene derecho a escribir.
3Explicación profunda
Autenticación y autorización se confunden porque suelen llegar juntas en el mismo token, pero resuelven problemas distintos. La autenticación termina en una afirmación: "esta petición viene de la usuaria Ana". A partir de ahí, la autorización empieza de cero en cada operación: que la petición sea de Ana no dice si Ana puede borrar el pedido 42, ni si puede ascenderse a administradora. Un sistema que trata "estar autenticado" como "tener permiso" ha colapsado las dos preguntas en una, y esa es la raíz de casi todos los fallos de esta fase.
Los scopes son la forma estándar de escribir permisos en un token de acceso. Un token
emitido para una app de solo lectura lleva pedidos:leer y nada más; aunque el
usuario dueño del token sea administrador, ese token no puede escribir, porque el scope acota
lo que el token habilita, con independencia de quién sea su portador. Esto es mínimo
privilegio aplicado a credenciales: la app de informes recibe un token que no puede hacer
daño aunque se filtre. El error clásico es emitir un único token con todos los scopes "para no
complicarse", que es como darle a cada empleado la llave maestra del edificio.
Dentro de la autorización hay dos niveles que conviene no mezclar. El nivel de objeto
pregunta si puedes tocar un recurso concreto: es lo que falla en un IDOR, cuando cambias el id
de la URL y lees el pedido de otro. El nivel de campo pregunta, una vez que sí puedes
tocar el recurso, qué partes de él puedes escribir. El mass assignment vive en este segundo
nivel: tienes todo el derecho a editar tu propio perfil —el objeto es tuyo— pero no a editar
el campo rol de tu propio perfil. La API que solo comprueba "¿es tuyo el
objeto?" y luego vuelca el cuerpo entero deja el nivel de campo sin ninguna defensa.
El mecanismo técnico del mass assignment es siempre el mismo: un binding automático.
update(cuerpo), Object.assign(user, body),
User(**body), model.attributes = params. Todas esas líneas toman las
claves del cuerpo que envió el cliente y las escriben como atributos del objeto. Si el cuerpo
trae rol, se escribe rol. El framework no distingue entre un campo
que el cliente tenía derecho a poner y uno que no: para él son claves de un diccionario. La
defensa no puede ser "recordar no mandar rol"; tiene que ser que el servidor nunca lea
rol del cuerpo, aunque venga.
Hay dos formas de cerrarlo, y la diferencia importa. La lista de campos permitidos (allow-list) enumera lo que sí se lee del cuerpo y descarta todo lo demás; es segura por defecto, porque un campo nuevo y peligroso que alguien añada mañana no está en la lista y no se escribe. La lista de campos prohibidos (deny-list) enumera lo que no se lee; es frágil, porque el día que se añade un campo sensible y nadie actualiza la lista, queda abierto. En seguridad, por defecto se rechaza: la allow-list es la única de las dos que falla de forma segura.
4Ejemplo sencillo
# El formulario de alta de un gimnasio Lo que el formulario "oficial" te deja rellenar: nombre, correo, contrasena Lo que de verdad viaja si lo rellenas tu a mano: nombre, correo, contrasena, tipo_socio=VIP, cuota=0 # El recepcionista con mass assignment copia entera la ficha que pusiste, sin mirar. Tu escribiste "tipo_socio: VIP" en un hueco en blanco y ahora eres socio VIP con cuota cero. Nadie te lo dio: lo escribiste tu, y el sistema lo copio sin preguntar. # El recepcionista con lista de campos permitidos solo mira nombre, correo y contrasena. El resto de la ficha lo ignora. El tipo de socio lo fija el, segun sus reglas, no segun lo que tu escribiste.
5Ejemplo técnico
El laboratorio de esta fase expone POST /registro. Míralo primero como lo ve un
cliente honesto y luego como lo ve alguien que añade un campo:
| Petición | Cuerpo enviado | Lo que guarda la API vulnerable |
|---|---|---|
| Cliente honesto | {"usuario":"leo","clave":"..."} | rol: cliente (el valor por defecto del servidor) |
| Cliente que prueba | {"usuario":"mallory","clave":"...","rol":"admin"} | rol: admin (el valor que puso el cliente) |
| Cliente que exagera | {"usuario":"eva","clave":"...","saldo":999999} | saldo: 999999 (dinero que nadie ingresó) |
La API arranca con un valor por defecto correcto (rol: cliente) y luego lo
pisa con lo que trae el cuerpo. El defecto no protege nada si después se sobrescribe con la
entrada del cliente. Escrito como riesgo con actor, vía, activo y consecuencia:
"Cualquier cliente puede darse de alta como administrador añadiendo el campo rol al cuerpo
de /registro, porque el servidor fusiona el cuerpo completo, lo que le da control total de la
API."
6Diagrama
Ver el mismo diagrama como código Mermaid (editable)
flowchart LR
C[cuerpo: usuario, clave, rol=admin] -->|binding sin filtro| U[update cuerpo]
C -->|lista de permitidos| P[solo usuario, clave]
U --> A[registro rol=admin · escalada]
P --> B[registro rol=cliente · servidor fija el rol]
7Código
Este es el corazón del fallo tal como está en el laboratorio. El valor por defecto correcto se pierde en la línea siguiente:
# VULNERABLE — el cuerpo entero pisa los valores del servidor def registro(cuerpo): registro = {"rol": "cliente", "saldo": 0} registro.update(cuerpo) # rol y saldo del cuerpo GANAN registro["usuario"] = cuerpo["usuario"] USUARIOS[cuerpo["usuario"]] = registro return 201, registro
La corrección no añade una comprobación: cambia quién decide. El servidor lee del cuerpo solo los campos públicos y fija los privilegiados por su cuenta:
# CORRECTO — lista de campos permitidos; el servidor fija rol y saldo PERMITIDOS = {"usuario", "clave"} def registro(cuerpo): limpio = {k: cuerpo[k] for k in PERMITIDOS if k in cuerpo} if "usuario" not in limpio or "clave" not in limpio: return 400, {"error": "faltan campos obligatorios"} registro = { "usuario": limpio["usuario"], "clave": limpio["clave"], "rol": "cliente", # lo fija el servidor, no el cuerpo "saldo": 0, # idem } USUARIOS[registro["usuario"]] = registro return 201, {"usuario": registro["usuario"], "rol": registro["rol"]}
Fíjate en que rol ya no aparece leído del cuerpo en ninguna parte. Esa es la
prueba visual de que el fallo está cerrado: si el nombre del campo no está en el código del
lado del servidor como algo que se lee de la entrada, el cliente no puede fijarlo por mucho
que lo mande.
8Qué puede salir mal
Confiar en el formulario del cliente. "El campo rol no está en nuestra app, así que
nadie lo va a mandar." El cliente de tu app no es el único que habla con tu API; cualquiera
con curl manda el cuerpo que quiera.
Usar una lista de campos prohibidos. Filtrar rol y
es_admin parece suficiente hasta que alguien añade rol_id o
grupo y nadie actualiza la lista. Lo que no está enumerado como permitido
debe rechazarse, no al revés.
Validar solo en la creación. El mass assignment reaparece en
PATCH /perfil: editar el perfil propio es legítimo, escribir el rol dentro de
esa edición no lo es. Cada endpoint que hace binding necesita su propia lista.
Confundir "el objeto es mío" con "puedo escribir todo el objeto". Autorización de objeto y de campo son comprobaciones distintas. Pasar la primera no exime de la segunda.
9Cómo lo abusaría un atacante
Quien ataca una API empieza por leer qué campos devuelve. Si
GET /perfil/ana responde con un campo rol, ya sabe el nombre
exacto del campo que le interesa escribir. La API le está enseñando su propio esquema. El
siguiente paso es probar ese mismo nombre en cualquier endpoint que acepte un cuerpo:
registro, edición de perfil, creación de cualquier recurso.
La prueba es barata y silenciosa. Manda el cuerpo normal más el campo sospechoso y mira la respuesta: si el campo vuelve con el valor que puso, hay mass assignment. No hay ninguna alarma que salte, porque desde el punto de vista de la API es una petición válida y bien formada. Por eso este fallo puede vivir años en producción: nunca produce un error, solo produce un administrador de más de vez en cuando.
Y encadena hacia arriba. Un saldo escrito a mano es fraude directo; un
rol: admin abre todos los endpoints administrativos, que suelen tener aún
menos validación porque "solo los usan los administradores". El primer campo de más es la
llave; lo que hay detrás de la puerta es el resto de la aplicación.
10Cómo defenderlo
- En cada endpoint que reciba un cuerpo, lee solo una lista explícita de campos permitidos. Descarta el resto sin avisar o rechaza con 400; nunca lo escribas.
- Fija los campos privilegiados en el servidor: rol, saldo, estado, propietario. Que no se lean nunca de la entrada.
- Separa el modelo de entrada del modelo de persistencia. Un DTO de entrada con solo los campos públicos hace el fallo imposible por construcción.
- Aplica autorización de objeto y de campo como dos comprobaciones. Ser dueño del recurso no autoriza a escribir cualquier campo de él.
- Da a cada credencial el scope mínimo. Un token de solo lectura no debería poder llegar a un endpoint de escritura aunque su dueño sea administrador.
- Escribe un test de regresión que mande
rol: adminen el registro y exija que la respuesta y la base guardenrol: cliente.
11Laboratorio
Levanta la API vulnerable y confirma que responde:
cd cursos/seguridad/labs/lab-api-vuln
docker compose up -d --build
curl -s http://localhost:8096/salud
Ahora escala a administrador con un solo campo de más en el cuerpo del registro:
Ahora defiéndelo. Edita api/servidor.py, cambia el _registro
para que lea solo usuario y clave del cuerpo y fije
rol y saldo en el servidor, y reconstruye:
docker compose up -d --build
curl -s -X POST http://localhost:8096/registro -H 'Content-Type: application/json' -d '{"usuario":"tester","clave":"x","rol":"admin"}'
La respuesta ahora debe traer rol: cliente. Si aún dice
admin, tu lista de permitidos no está filtrando. Cuando termines:
docker compose down -v
12Ejercicios
Autenticación o autorización
Para cada frase, di si describe un fallo de autenticación o de autorización: (1) un token caducado sigue siendo aceptado; (2) un cliente cambia el id de la URL y lee el pedido de otro; (3) un usuario se asigna el rol admin al registrarse; (4) la API acepta un token firmado con una clave que no es la suya; (5) un token de solo lectura consigue escribir.
(1) autenticación: acepta una prueba de identidad que ya no vale. (2) autorización de objeto. (3) autorización de campo (mass assignment). (4) autenticación: valida mal la firma. (5) autorización: el scope del token debería impedirlo.
Clave: AutN · AutZ · AutZ · AutN · AutZ. Los casos (2), (3) y (5) son tres niveles distintos de autorización: objeto, campo y scope.
Cierra el mass assignment con una allow-list
Con el lab-api-vuln levantado, modifica _registro en
api/servidor.py para que use una lista de campos permitidos. El registro debe
aceptar solo usuario y clave del cuerpo, y fijar rol
y saldo en el servidor. Reconstruye y demuestra que rol: admin
enviado por el cliente ya no tiene efecto.
registro.update(cuerpo): copia todo el cuerpo.PERMITIDOS = {"usuario", "clave"}, sin leer nada más del cuerpo.docker compose up -d --build, repite el ataque y comprueba que la respuesta trae rol: cliente.
La corrección es la del bloque de código de la lección: leer solo los campos de
PERMITIDOS y fijar rol y saldo con literales del
servidor. La verificación:
# tras reconstruir, esto debe devolver rol: cliente curl -s -X POST http://localhost:8096/registro \ -H 'Content-Type: application/json' \ -d '{"usuario":"t1","clave":"x","rol":"admin"}' | grep rol
Clave: la señal de éxito es que rol ya no se lee del cuerpo en el código y que la respuesta al ataque devuelve cliente. Si sigue diciendo admin, el binding sin filtrar sigue ahí.
El mismo fallo en PATCH
Imagina que la API añade PATCH /perfil/<usuario> para que un cliente
edite su propio nombre y correo. Diseña ese endpoint sin reintroducir el mass assignment,
y explica por qué una allow-list de creación no basta y hace falta una específica para la
edición. Investiga cómo lo resuelven dos frameworks reales (por ejemplo strong
parameters de Rails y los DTO de entrada en un framework tipado) y compáralos.
usuario se deje cambiar.
PATCH /perfil debe leer solo nombre y correo del
cuerpo; ni rol, ni saldo, ni siquiera usuario
(la identidad viene del token, no del cuerpo). La allow-list de creación no sirve
porque los campos escribibles cambian según la operación: al crear se fija la clave, al
editar el perfil normalmente no. Rails lo resuelve con strong parameters
(params.require(:perfil).permit(:nombre, :correo)), una allow-list
declarada en el controlador; un framework tipado lo resuelve con un DTO de entrada que
solo tiene esos dos campos, de modo que el binding no puede escribir nada más porque el
tipo no lo contiene. El segundo es más robusto: el compilador impide el error.
Clave: una allow-list por operación, la identidad desde el token y nunca desde el cuerpo, y preferir un DTO de entrada tipado que hace el mass assignment imposible por construcción en vez de por disciplina.
Red Team · descubre el nombre del campo y escálalo
Dentro del lab-api-vuln, y solo dentro de él: sin mirar el código del
servidor, descubre qué campo controla el rol usando solo las respuestas de la API, y luego
úsalo para registrarte como administrador. Documenta cómo supiste el nombre del campo.
/perfil. La API enseña su esquema.
Registrar un usuario normal y pedir GET /perfil/<usuario> revela un
campo rol con valor cliente. Ese es el nombre. Reenviar
POST /registro con "rol":"admin" en el cuerpo devuelve un
registro con rol: admin. La API filtró su propio esquema en las respuestas
y no filtró la entrada: las dos mitades del mismo fallo de validación.
Clave: el nombre del campo salió de la salida de la API, no de adivinar. Sobre-exponer campos en las respuestas y no filtrar la entrada es un fallo doble; los cierras en las lecciones de este mismo laboratorio.
Blue Team · detecta el intento en los logs
Genera varios registros, algunos honestos y otros con rol o saldo
en el cuerpo, y mira docker compose logs api. Con el formato de log actual,
¿puedes distinguir un registro honesto de uno que intenta escalar? Enumera qué campos
tendría que registrar la API para que un intento de mass assignment fuera detectable.
Con el log de acceso no se distingue: todos los registros son POST /registro
con código 201. El intento vive en el cuerpo, que no se registra. La forma correcta de
hacerlo detectable no es loggear el cuerpo (contiene la clave), sino que la propia
allow-list emita un evento cuando descarta un campo no permitido: "registro de mallory
intentó fijar rol". Eso registra el intento sin registrar el secreto, y da una señal
clara: un cliente que manda campos privilegiados que no le tocan es sospechoso aunque
la petición se procese bien.
Clave: el entregable es la lista de campos a registrar cuando la allow-list descarta algo: usuario, endpoint, nombre del campo rechazado. Nunca el valor, nunca la clave.
Architecture Challenge · separar entrada de persistencia
Diseña la capa de entrada de una API para que el mass assignment sea imposible por construcción, no por disciplina. Define: qué separa el modelo que recibe la petición del modelo que se guarda, dónde se fijan los campos privilegiados, cómo se aplican los scopes del token a la escritura, y qué comprobación automática en el pipeline garantiza que un endpoint nuevo no reintroduzca el fallo.
Una propuesta razonable: un DTO de entrada por operación con solo los campos públicos, distinto del modelo de persistencia; el handler convierte el DTO en el modelo fijando rol, propietario y estado desde el token y las reglas de negocio, nunca desde el cuerpo; el scope del token se comprueba antes de escribir, de modo que un token de lectura ni llega al handler de escritura. En el pipeline, un test de contrato que envía campos extra a cada endpoint de escritura y exige que se ignoren o se rechacen; y una regla que falle la build si un handler hace binding directo del cuerpo al modelo de persistencia sin pasar por un DTO.
Clave: no hay una única respuesta. Se evalúa que el fallo sea imposible por el tipo (DTO de entrada) y no por recordar filtrar, que los campos privilegiados se fijen del lado del servidor, y que haya una puerta automática que impida reintroducirlo.
13Preguntas de comprensión
¿Qué es exactamente mass assignment?
El binding automático toma las claves del cuerpo como atributos del objeto. Si el cuerpo trae rol, se escribe rol, sin distinguir quién tenía derecho a ponerlo.
Estás autenticado como cliente normal. ¿Por qué eso no basta para estar seguro?
Que la petición sea tuya no dice qué puedes escribir. La autorización empieza de cero en cada operación, y a nivel de campo.
¿Cuál es la corrección correcta para el mass assignment de /registro?
La allow-list es segura por defecto: un campo peligroso que se añada mañana no está en la lista y no se escribe. Rechazar solo rol es una deny-list frágil.
¿Qué acotan los scopes de un token de acceso?
El scope es mínimo privilegio aplicado a la credencial: un token de solo lectura no puede escribir aunque su dueño sea administrador.
14Reto adicional
Autorización de campo con dos roles
Amplía el laboratorio en tu cabeza (o en el código): un administrador legítimo sí debe
poder fijar el rol de otro usuario mediante la API, pero un cliente no. Diseña cómo el
mismo endpoint acepta el campo rol del cuerpo solo cuando quien llama es
administrador, y lo ignora cuando es un cliente. ¿Dónde se toma esa decisión, y por qué no
puede depender de un campo del cuerpo?
"soy_admin": true en el cuerpo, habrías reinventado el mass assignment.
La lista de campos permitidos se elige según el rol de quien llama, y ese rol se lee del
token autenticado, nunca del cuerpo. Para un cliente, la lista es
{usuario, clave}; para un administrador probado, se amplía con
rol. La decisión se toma en el servidor, después de verificar el token y
antes de tocar el registro. Si dependiera de cualquier dato del cuerpo, el atacante
afirmaría ser admin en el mismo cuerpo y volveríamos al principio: la autoridad tiene
que venir de algo que el cliente no pueda falsificar.
Clave: lo interesante es que la corrección del mass assignment no es "prohibir rol para siempre", sino "decidir la allow-list según una autoridad que el cliente no controla". El campo privilegiado puede existir; lo que no puede es que lo autorice el propio cuerpo.
15Resumen
- Autenticación dice quién eres; autorización decide qué puedes hacer, y se comprueba en cada operación.
- Hay autorización de objeto (¿puedo tocar este recurso?) y de campo (¿qué partes puedo escribir?). El mass assignment vive en la segunda.
- El mass assignment es un binding automático que copia el cuerpo entero al objeto, escribiendo campos que debía fijar el servidor.
- La corrección es una lista de campos permitidos, no una de prohibidos: por defecto se rechaza.
- Los campos privilegiados —rol, saldo, propietario— se fijan en el servidor a partir del token, nunca se leen del cuerpo.
- Separar el modelo de entrada del de persistencia hace el fallo imposible por construcción.
update(cuerpo), Object.assign(user, body), User(**body). Copia el cuerpo entero al objeto.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.