1. Upload como cambio de estado, no como intercambio de datos
Cuando un desarrollador escucha por primera vez "hay que hacer la carga del avatar", el cerebro suele dibujar una imagen del nivel: "bueno, es como PATCH, pero en lugar de JSON — un archivo". Es una reacción normal, hasta que empiezas a pensar qué es exactamente lo que ocurre en el sistema. La carga de un archivo casi siempre cambia el estado de la aplicación, y por lo tanto automáticamente se convierte en un escenario de seguridad, no solo en "una funcionalidad más".
En nuestro proyecto, el avatar es parte del perfil del usuario. Es decir, cuando aceptamos un archivo, no solo aceptamos bytes "en el vacío". Hacemos dos cosas: guardamos el archivo (o su representación) y cambiamos la referencia al avatar de un usuario concreto. Esto ya se parece no a "transmitir datos", sino a "introducir cambios en la cuenta", y eso siempre es una zona de mayor atención.
Mini-ejemplo que "aterriza" bien la idea: el perfil almacena la ruta/enlace al avatar, no el archivo en sí.
package com.example.securecontent.profile;
public class UserProfile {
private String displayName;
// Importante: esto es parte del estado del perfil, no un "detalle administrativo"
private String avatarPath;
// getters/setters ...
}
Observe una cosa simple pero importante: el campo avatarPath es parte del estado del usuario. Por lo tanto, POST /api/me/avatar no es "transmitir un archivo", sino "una operación que cambia el perfil". Por eso debemos pensar en la seguridad al menos a nivel de "quién tiene derecho a hacerlo", "se puede falsificar la solicitud", "qué exactamente se puede enviar" y "qué haremos con lo que nos envían".
2. Capas de validación para el endpoint de upload
Cuando tenemos un endpoint JSON normal como PATCH /api/me/profile, ya estamos acostumbrados: hay datos de entrada, hay validación, hay reglas de seguridad. Pero el upload es una situación donde es demasiado fácil equivocarse si se piensa solo al estilo "bueno, pongamos authenticated() y listo". En realidad, un endpoint de este tipo casi siempre tiene varias capas de validación, y responden a preguntas diferentes.
Me gusta pensar en el escenario de upload como "un pasillo con varias puertas". La primera puerta es el acceso (autenticación/autorización), la segunda es la corrección del contenido, la tercera es el guardado seguro del resultado. Si pusiste un guardia solo en la primera puerta, pero dejaste un almacén abierto con cualquier cosa — felicidades, tienes una entrada vigilada a un desorden.
La forma más clara de mostrarlo es con una tabla. No es un "estándar oficial", sino un mapa de ingeniería útil:
| Capa de validación | Pregunta que responde | Dónde debe residir | Ejemplo de fallo |
|---|---|---|---|
| Acceso al endpoint | "¿Quién tiene derecho a invocar /api/me/avatar?" | SecurityFilterChain y las reglas ya conocidas para /api/me/** | usuario no ha iniciado sesión |
| Protección de solicitud que cambia el estado | "¿La solicitud no está falsificada en el modelo de navegador/cookie?" | Mecanismo CSRF de Spring Security | no hay token CSRF o es incorrecto |
| Validación del archivo | "¿Qué exactamente nos enviaron y es adecuado como avatar?" | Lógica de aplicación del servicio (y un poco de configuración) | archivo vacío, enorme, no es una imagen |
| Guardado seguro | "¿Cómo guardar para no crear una brecha en el sistema de archivos?" | Capa de almacenamiento del proyecto | path traversal, sobrescritura de archivo ajeno |
Aquí lo más importante es fijar la idea: upload es un escenario donde "verificar el acceso" es necesario, pero no suficiente. Cada capa tiene su propia pregunta y su propio punto de fallo.
3. Zona personal: /api/me/avatar en lugar de /api/users/{id}/avatar
Si has trabajado con backend antes, puede que sientas la tentación de "generalizar" el endpoint y escribir algo como POST /api/users/{id}/avatar. Se ve bonito: pasas el id, subes el archivo, listo. El problema es que desde el punto de vista de la seguridad, de repente te pones una mina bajo los pies: ¿quién le impide a un usuario normal poner un id ajeno?
El patrón "el endpoint acepta un identificador de objeto, y la autorización en el código se olvida o se hace mal" es una de las fuentes más frecuentes de broken access control. Y es desagradable porque la aplicación puede "funcionar más o menos", mientras la vulnerabilidad vive durante años hasta que a alguien se le ocurre verificar "¿y si pongo otro id?".
En nuestro proyecto elegimos desde el principio un endpoint al estilo "owner-by-design": /api/me/avatar. No acepta userId. Por su significado dice: "cambio el avatar del usuario actual". Y es una decisión muy correcta para un proyecto de aprendizaje: no elimina la necesidad de verificar la autenticación, pero reduce drásticamente la probabilidad de crear una vulnerabilidad del tipo "el usuario cambia el avatar del vecino".
Para sentir la diferencia, comparemos dos controladores. El primero — potencialmente peligroso (ejemplo de "cómo no hacerlo"):
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PathVariable;
public class InsecureAvatarController {
@PostMapping("/api/users/{id}/avatar")
public void upload(@PathVariable long id) {
// Riesgo: si se olvida/equivoca la verificación, se produce un IDOR (cambio de avatar de otro usuario)
// "Oye, ¿y dónde está la verificación de que id == usuario actual?"
}
}
El segundo — "owner-by-design", en el que la solicitud no tiene "palanca" en forma de id ajeno:
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
public class MeAvatarController {
@PostMapping("/api/me/avatar")
public void upload() {
// Por diseño no hay userId: solo cambiamos el avatar del usuario actual
}
}
Observa que todavía no hemos hablado de multipart, MultipartFile, tipos y tamaños. Simplemente hemos hecho una elección importante de diseño de seguridad: el endpoint no debe convertirse accidentalmente en un "cañón universal" que dispare contra cualquier usuario cuando hay un error en la autorización.
4. Riesgos de la carga multipart en lugar de JSON
Hasta ahora hemos visto el upload como un escenario de seguridad. Ahora es importante ver el síntoma técnico: tan pronto como aparece un archivo en la solicitud, ya no es el habitual @RequestBody con JSON, sino multipart/form-data.
Multipart es otro animal. Puede ser grande, lleva datos binarios, puede comportarse de manera diferente en distintos clientes, y tiene un coste "físico" en recursos: memoria, disco, tiempo de procesamiento. Por lo tanto, incluso un usuario correctamente autenticado puede crearle un problema solo por el tamaño o el contenido del archivo.
Y la firma del endpoint empieza a verse diferente:
package com.example.securecontent.profile;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
public class AvatarController {
// consumes es importante: con esto decimos explícitamente "acepto multipart", no JSON
@PostMapping(path = "/api/me/avatar", consumes = "multipart/form-data")
public void uploadAvatar(@RequestParam("file") MultipartFile file) {
// file — es una entrada del usuario: obligatoriamente habrá que validarlo por tamaño/tipo/contenido
// más adelante irá el servicio, validaciones, guardado...
}
}
Por ahora basta con captar dos cosas: el endpoint dice explícitamente consumes = "multipart/form-data", y el archivo llega no como DTO, sino como MultipartFile. Precisamente aquí suele surgir la siguiente pregunta de ingeniería: cómo llega la solicitud multipart al controlador y en qué momento Spring la convierte en MultipartFile.
5. Clases de errores del endpoint de upload
En el tema de seguridad hay una costumbre desagradable: cuando algo no funciona, el desarrollador mira el error y dice "bueno, Spring Security otra vez molesta". Y luego hace .csrf(csrf -> csrf.disable()) y por un minuto se siente un ganador. Después, sin embargo, suele llegar la realidad y decir: "y ahora explica por qué desactivamos la protección contra falsificación de solicitudes".
En el escenario de upload es especialmente importante entender de antemano que "no se pudo cargar el avatar" no es un solo error. Son al menos cuatro clases diferentes de problemas, que requieren diferentes reacciones y diferentes niveles de "dónde arreglar".
Se puede representar como un pequeño "árbol de decisiones":
flowchart TD
A["POST /api/me/avatar"] --> B{"¿El usuario ha iniciado sesión?"}
B -- no --> C[flujo de login / acceso denegado]
B -- sí --> D{"¿El token CSRF es correcto?"}
D -- no --> E[fallo CSRF]
D -- sí --> F{"¿El archivo es correcto?"}
F -- no --> G[Error de archivo: vacío/tipo/tamaño]
F -- sí --> H[Guardar archivo y actualizar perfil]
Observe: solo la última rama es la "operación de negocio real". Todo lo anterior es seguridad y validación de aplicación en la frontera.
En el modelo browser/session los "síntomas" pueden verse de manera diferente: en el navegador puede ver una redirección a la página de inicio de sesión, y en Postman — un estado, y en los registros — algo más. Es normal; ya hemos visto que distintos clientes viven de manera diferente el mismo modelo de seguridad.
La idea clave: no debemos mezclar en la cabeza "error de acceso", "error CSRF" y "error de archivo". Si los mezcla, casi con toda seguridad empezará a tratar la enfermedad equivocada. Upload es un excelente entrenador para esta habilidad, porque aquí los errores ocurren con frecuencia y rapidez.
6. Esqueleto arquitectónico en el proyecto
Ahora es útil ver no la versión final ejecutable, sino solo el esqueleto. El escenario de upload tiene aquí tres soportes: la zona personal /api/me/**, un controlador delgado sobre multipart/form-data y un servicio en el que luego confluyen las validaciones del archivo, el guardado seguro y la actualización del perfil. Si se intenta fijar todo a la vez, el esqueleto se llena de detalles al instante y se convierte en una segunda versión accidentalmente más débil del mismo endpoint.
A nivel de reglas de acceso, por ahora solo necesitamos una idea: el avatar vive en la zona personal, por lo tanto el endpoint hereda la regla "solo para usuario autenticado".
http.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/me/**").authenticated()
.anyRequest().denyAll()
);
Los puntos de servicio como la obtención del token CSRF o los detalles del flujo de login no los desglosamos aquí a propósito: lo importante es el contorno de la zona personal.
El controlador también lo mantenemos en forma de esqueleto. Acepta explícitamente multipart/form-data, espera una parte con nombre file y delega inmediatamente el escenario más adelante.
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile;
public class AvatarController {
private final AvatarService avatarService;
public AvatarController(AvatarService avatarService) {
this.avatarService = avatarService;
}
@PostMapping(path = "/api/me/avatar", consumes = "multipart/form-data")
public void uploadAvatar(@RequestParam("file") MultipartFile file) {
// El controlador solo recibe los datos de entrada y pasa el escenario adelante
avatarService.upload(file);
}
}
Y el servicio por ahora se puede percibir como el centro del escenario, no como una implementación lista.
import org.springframework.web.multipart.MultipartFile;
public class AvatarService {
public void upload(MultipartFile file) {
// Aquí más adelante confluyen:
// 1) usuario actual
// 2) validaciones de tamaño y tipo
// 3) guardado seguro
// 4) actualización de avatarPath en el perfil
}
}
Este esqueleto ya es suficiente para no perder la lógica: el endpoint vive en la zona personal, el controlador es delgado, el trabajo principal va al servicio, y la validación del archivo y el almacenamiento son capas separadas, no ifs casuales en un solo método.
7. Errores típicos al cargar el avatar
Cuando haces file upload por primera vez, es muy fácil caer en la trampa de "ya sé seguridad: pongo authenticated() y listo". En la práctica, esto lleva a soluciones frágiles que o bien abren una brecha, o bien te obligan a desactivar mecanismos de protección porque "molestan". A continuación, los errores más frecuentes precisamente a nivel de escenario y diseño, incluso antes de profundizar en los detalles de multipart.
Error nº 1: considerar el upload una "característica normal", no un cambio de estado.
Si se trata la carga del avatar como "simplemente transmitir un archivo", es fácil olvidar que estás cambiando el perfil del usuario y creando un nuevo artefacto en el almacenamiento. Luego, de repente resulta que cualquier usuario que haya iniciado sesión puede "subir cualquier cosa", y el sistema ni siquiera entiende qué almacena y para qué. El soporte correcto aquí es simple: el avatar es parte del perfil, por lo tanto es una operación que cambia el estado y debe protegerse como un cambio de perfil.
Error nº 2: diseñar el endpoint como /api/users/{id}/avatar sin una lógica estricta de propietario.
Este diseño no siempre es incorrecto, pero requiere una autorización muy cuidadosa: hay que garantizar que el usuario solo pueda cambiar su propio avatar (o que el administrador pueda cambiar el de otro — si está explícitamente previsto). Si aún no estás listo para expresar esas reglas, es más seguro y sencillo mantener el endpoint al estilo /api/me/avatar: menos posibilidades de cometer un "IDOR de la nada".
Error nº 3: mezclar las causas de los errores en un solo "no se cargó".
Si en tu cabeza no hay separación entre "no inició sesión", "CSRF falló", "archivo malo", "storage no pudo guardar", arreglarás donde no es. Por ejemplo, desactivarás CSRF cuando simplemente el archivo estaba vacío. O empezarás a hurgar en multipart cuando ni siquiera estás autenticado. En el escenario de upload es útil tener de antemano estas causas como ramas separadas.
Error nº 4: hacer del controlador el "dios" del escenario.
Cuando en un solo método del controlador hay parseo, validación, guardado, cambio de perfil y manejo de errores, el código se vuelve difícil de entender y mantener. Y en código de seguridad, "difícil de mantener" casi siempre significa "algún día alguien abrirá el acceso accidentalmente". Es mucho más segura la disciplina: controlador delgado, servicios que ejecutan los pasos, y la infraestructura de seguridad hace lo suyo antes de entrar en el código de negocio.
Error nº 5: pensar que porque el usuario ha iniciado sesión, el archivo es automáticamente seguro.
La autenticación responde a la pregunta "¿quién eres?", pero no responde en absoluto a la pregunta "¿qué enviaste?". Incluso un usuario bienintencionado puede cargar accidentalmente un archivo enorme o de un tipo incorrecto. Y uno malintencionado lo hará a propósito. Por lo tanto, el escenario de upload siempre debe tener una "segunda capa" de protección: reglas sobre tipo y tamaño, así como un guardado cuidadoso.
GO TO FULL VERSION