Subir un fichero a un backend Java parece lo más sencillo del mundo hasta que alguien sube un ejecutable renombrado como .png, un nombre de fichero con ../../ o un vídeo de 2 GB a un endpoint pensado para avatares. Spring Boot 4 se lleva bien con todo esto, pero los valores por defecto no te protegen de lo importante. Aquí montamos un endpoint de subida completo con Spring MVC, almacenamiento en disco con la API Path, validación del contenido real, errores en formato ProblemDetail y un cliente en el navegador con barra de progreso.
Versiones y requisitos
La documentación oficial de Spring Boot 4.1 indica que necesita como mínimo Java 17 y que funciona hasta Java 27, con Spring Framework 7 y Servlet 6.1 (Tomcat 11 o Jetty 12.1 como contenedores embebidos). Para un proyecto nuevo lo razonable es Java 25, que es la versión LTS actual; Java 26 salió en marzo de 2026 y es una versión de seis meses de soporte. El código de este artículo no usa ninguna característica en preview, así que sirve igual desde Java 17.
La dependencia es la de siempre, el starter web:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
Ojo con el nombre: en Spring Boot 4 el starter pasó a llamarse spring-boot-starter-webmvc. Si vienes de Boot 3, el antiguo spring-boot-starter-web sigue funcionando, pero la guía de migración lo marca como obsoleto y se eliminará en una versión futura.
Los límites: la primera línea de defensa
Las propiedades viven bajo el prefijo spring.servlet.multipart y se enlazan con la clase MultipartProperties. Los valores por defecto son muy conservadores: 1 MB por fichero y 10 MB por petición. Fíjalos siempre de forma explícita, porque lo que decide es tu caso de uso y no el valor por defecto de una versión concreta.
spring:
servlet:
multipart:
max-file-size: 10MB # tamaño máximo de cada fichero
max-request-size: 12MB # tamaño máximo de toda la petición
file-size-threshold: 1MB # a partir de aquí se vuelca a disco temporal
app:
upload:
dir: /var/app/uploads
allowed-types:
- image/png
- image/jpeg
- application/pdf
Propiedad | Qué controla | Por defecto |
| Tamaño máximo de cada fichero subido | 1MB |
| Tamaño máximo de la petición multipart completa | 10MB |
| Umbral a partir del cual el contenido se escribe a disco | 0 |
| Directorio temporal de los ficheros (si no se indica, el temporal del sistema) | directorio temporal |
Hay dos propiedades más que conviene conocer, resolve-lazily (retrasa el parseo multipart hasta que el controlador pide el fichero) y strict-servlet-compliance. Los valores por defecto de ambas te sirven; las dejo fuera para no complicar el ejemplo.
Estos límites no sirven de nada si hay un proxy delante que corta antes. Nginx, por ejemplo, rechaza cuerpos de más de 1 MB por defecto (client_max_body_size), y el error que recibe el usuario no sale de tu aplicación. Alinea los tres sitios: proxy, contenedor y Spring.
Configuración tipada
En vez de repartir @Value por el código, agrupa la configuración propia en un record. Spring Boot lo enlaza por constructor y convierte la cadena del directorio a Path sin ayuda.
package com.example.uploads;
import java.nio.file.Path;
import java.util.Set;
import org.springframework.boot.context.properties.ConfigurationProperties;
@ConfigurationProperties(prefix = "app.upload")
public record UploadProperties(Path dir, Set<String> allowedTypes) { }
package com.example.uploads;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.context.properties.ConfigurationPropertiesScan;
@SpringBootApplication
@ConfigurationPropertiesScan
public class UploadsApplication {
public static void main(String[] args) {
SpringApplication.run(UploadsApplication.class, args);
}
}
El controlador
Un MultipartFile recibido con @RequestParam es lo más directo. Spring también acepta List<MultipartFile> para varios ficheros con el mismo nombre de campo, y jakarta.servlet.http.Part si prefieres la API del servlet. El controlador debería ser fino: delega en un servicio y devuelve 201 Created con la ubicación del recurso.
package com.example.uploads;
import java.io.IOException;
import java.net.URI;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.multipart.MultipartFile;
@RestController
@RequestMapping("/api/files")
class UploadController {
private final StorageService storage;
UploadController(StorageService storage) {
this.storage = storage;
}
@PostMapping(consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
ResponseEntity<StoredFile> upload(@RequestParam("file") MultipartFile file) throws IOException {
StoredFile stored = storage.store(file);
return ResponseEntity.created(URI.create("/api/files/" + stored.id())).body(stored);
}
}
Lo que no debes hacer con el nombre del fichero
La documentación de MultipartFile.getOriginalFilename() lo dice sin rodeos: el nombre lo manda el cliente, puede incluir parte de la ruta, puede contener .. y no se debe usar a ciegas. Además puede ser null. La recomendación oficial es generar un nombre propio y guardar el original solo como referencia.
Esto es lo que no hay que escribir nunca:
// MAL: el cliente decide dónde se escribe
file.transferTo(Path.of("/var/app/uploads/" + file.getOriginalFilename()));
Con un nombre como ../../etc/cron.d/algo el fichero acaba fuera del directorio de subidas. La solución tiene dos partes: un nombre generado por el servidor (un UUID) y una comprobación de que la ruta resuelta sigue dentro del directorio base.
No te fíes del Content-Type
El tipo MIME de la parte lo envía el navegador, o el atacante con curl. getContentType() puede devolver cualquier cosa, incluido null. La extensión del nombre tampoco vale. Lo que sí puedes hacer sin dependencias externas es mirar los primeros bytes del fichero, la firma o «magic number»: un PNG empieza por 89 50 4E 47, un JPEG por FF D8 FF y un PDF por %PDF-. Para un catálogo grande de formatos es mejor usar Apache Tika, pero para tres o cuatro tipos conocidos esto es suficiente y no añade superficie de ataque.
El servicio hace todo en una sola lectura del flujo: lee la cabecera con mark/reset, decide el tipo real, genera el nombre y copia. Files.copy sin opciones falla si el destino ya existe, lo que añade una protección más contra sobrescrituras.
package com.example.uploads;
import java.io.BufferedInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.Map;
import java.util.UUID;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
@Service
class StorageService {
private static final Map<String, String> EXTENSIONS = Map.of(
"image/png", "png",
"image/jpeg", "jpg",
"application/pdf", "pdf");
private final Path root;
private final UploadProperties props;
StorageService(UploadProperties props) throws IOException {
this.props = props;
this.root = props.dir().toAbsolutePath().normalize();
Files.createDirectories(root);
}
StoredFile store(MultipartFile file) throws IOException {
if (file.isEmpty()) {
throw new InvalidUploadException(HttpStatus.BAD_REQUEST, "No se ha enviado ningún fichero");
}
try (InputStream in = new BufferedInputStream(file.getInputStream())) {
in.mark(16);
byte[] head = in.readNBytes(12);
in.reset();
String type = sniff(head);
if (type == null || !props.allowedTypes().contains(type)) {
throw new InvalidUploadException(HttpStatus.UNSUPPORTED_MEDIA_TYPE,
"Tipo de fichero no permitido");
}
String id = UUID.randomUUID() + "." + EXTENSIONS.get(type);
Path target = root.resolve(id).normalize();
if (!target.startsWith(root)) {
throw new InvalidUploadException(HttpStatus.BAD_REQUEST, "Ruta no válida");
}
Files.copy(in, target);
return new StoredFile(id, displayName(file.getOriginalFilename()), type, Files.size(target));
}
}
private static String sniff(byte[] h) {
if (startsWith(h, 0x89, 0x50, 0x4E, 0x47)) return "image/png";
if (startsWith(h, 0xFF, 0xD8, 0xFF)) return "image/jpeg";
if (startsWith(h, 0x25, 0x50, 0x44, 0x46, 0x2D)) return "application/pdf";
return null;
}
private static boolean startsWith(byte[] data, int... signature) {
if (data.length < signature.length) return false;
for (int i = 0; i < signature.length; i++) {
if ((data[i] & 0xFF) != signature[i]) return false;
}
return true;
}
/** Nombre solo para mostrar: sin ruta, sin caracteres de control, acotado. */
private static String displayName(String original) {
if (original == null || original.isBlank()) return "sin-nombre";
String name = original.substring(Math.max(original.lastIndexOf('/'), original.lastIndexOf('\')) + 1);
name = name.replaceAll("\p{Cntrl}", "");
return name.length() > 100 ? name.substring(0, 100) : name;
}
}
Hay un detalle: el nombre original se devuelve en la respuesta pero nunca se usa para tocar el disco. Si decides mostrarlo después en una página, escápalo igual que cualquier otro dato del usuario.
Los dos tipos auxiliares son triviales:
package com.example.uploads;
public record StoredFile(String id, String originalName, String contentType, long size) { }
package com.example.uploads;
import org.springframework.http.HttpStatus;
class InvalidUploadException extends RuntimeException {
private final HttpStatus status;
InvalidUploadException(HttpStatus status, String message) {
super(message);
this.status = status;
}
HttpStatus status() {
return status;
}
}
Errores con ProblemDetail
Cuando un fichero supera max-file-size, Spring lanza MaxUploadSizeExceededException. Esa excepción ya implementa ErrorResponse y lleva un ProblemDetail con estado 413 (CONTENT_TOO_LARGE), de modo que si tu aplicación extiende ResponseEntityExceptionHandler obtienes una respuesta RFC 9457 sin escribir nada. Falta mapear tu propia excepción de validación:
package com.example.uploads;
import org.springframework.http.ProblemDetail;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;
@RestControllerAdvice
class UploadErrors extends ResponseEntityExceptionHandler {
@ExceptionHandler(InvalidUploadException.class)
ProblemDetail invalidUpload(InvalidUploadException ex) {
return ProblemDetail.forStatusAndDetail(ex.status(), ex.getMessage());
}
}
Si quieres personalizar el 413, por ejemplo para indicar el límite en el mensaje, sobrescribe handleMaxUploadSizeExceededException(MaxUploadSizeExceededException, HttpHeaders, HttpStatusCode, WebRequest) y delega en handleExceptionInternal.
El cliente: fetch frente a XMLHttpRequest
Aquí hay una trampa habitual: fetch() no ofrece eventos de progreso de subida. Puedes leer el progreso de una descarga con los streams de la respuesta, pero para la subida el estándar no te da nada equivalente que funcione en todos los navegadores. Si quieres una barra de progreso de verdad, el camino sigue siendo XMLHttpRequest y su objeto upload, que emite loadstart, progress, error, abort y timeout. Los listeners hay que añadirlos antes de llamar a send().
<input type="file" id="fichero" accept=".png,.jpg,.jpeg,.pdf">
<progress id="barra" max="100" value="0"></progress>
<p id="estado"></p>
<script>
const MAX_BYTES = 10 * 1024 * 1024;
function subir(file, onProgress) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('POST', '/api/files');
xhr.responseType = 'json';
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) onProgress(Math.round((e.loaded * 100) / e.total));
});
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) resolve(xhr.response);
else reject(xhr.response ?? new Error('Error ' + xhr.status));
});
xhr.addEventListener('error', () => reject(new Error('Fallo de red')));
xhr.addEventListener('abort', () => reject(new Error('Subida cancelada')));
const datos = new FormData();
datos.append('file', file); // el nombre debe coincidir con @RequestParam("file")
xhr.send(datos); // sin fijar Content-Type: el navegador añade el boundary
});
}
document.getElementById('fichero').addEventListener('change', async (e) => {
const file = e.target.files[0];
const estado = document.getElementById('estado');
const barra = document.getElementById('barra');
if (!file) return;
if (file.size > MAX_BYTES) {
estado.textContent = 'El fichero supera los 10 MB';
return;
}
try {
const r = await subir(file, (p) => { barra.value = p; });
estado.textContent = 'Subido: ' + r.id;
} catch (err) {
estado.textContent = err.detail ?? err.message;
}
});
</script>
Dos errores clásicos. El primero es poner a mano la cabecera Content-Type: multipart/form-data: sin el parámetro boundary el servidor no puede separar las partes, y es el navegador quien lo genera al enviar un FormData. El segundo es confiar en la validación del cliente (el atributo accept, la comprobación de tamaño): sirve para dar una respuesta rápida al usuario, pero la que cuenta es la del servidor.
Si usas Spring Security con CSRF activado, que es lo normal en aplicaciones con sesión, la petición POST necesita el token. Con un cliente JavaScript lo habitual es leerlo de una cookie o de una etiqueta meta y enviarlo en la cabecera que corresponda, con xhr.setRequestHeader antes del send().
Servir los ficheros de vuelta
Subir con cuidado y servir sin cuidado deja el agujero abierto igual. Valida el identificador con una expresión estricta, de modo que no pueda contener separadores de ruta, y fuerza la descarga para que el navegador no interprete el contenido:
@GetMapping("/{id:[0-9a-f\-]{36}\.(?:png|jpg|pdf)}")
ResponseEntity<Resource> download(@PathVariable String id) throws MalformedURLException {
Path file = storage.resolve(id); // root.resolve(id).normalize() con la misma comprobación startsWith
if (!Files.isRegularFile(file)) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok()
.header(HttpHeaders.CONTENT_DISPOSITION, "attachment")
.header("X-Content-Type-Options", "nosniff")
.contentType(MediaType.APPLICATION_OCTET_STREAM)
.body(new UrlResource(file.toUri()));
}
Servir con application/octet-stream y attachment es lo más conservador. Si necesitas mostrar imágenes en línea, guarda el tipo detectado junto al fichero y devuélvelo solo para los tipos que has validado, siempre con nosniff.
Probar el endpoint
Con MockMvc puedes comprobar los dos caminos importantes sin levantar el servidor: que un PNG real se acepta y que un fichero con contenido ajeno al tipo declarado se rechaza.
@SpringBootTest
@AutoConfigureMockMvc
class UploadControllerTest {
@Autowired MockMvc mvc;
@Test
void aceptaUnPngReal() throws Exception {
byte[] png = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A, 0, 0, 0, 0};
var file = new MockMultipartFile("file", "foto.png", "image/png", png);
mvc.perform(multipart("/api/files").file(file))
.andExpect(status().isCreated());
}
@Test
void rechazaContenidoQueNoCoincideConElTipo() throws Exception {
var file = new MockMultipartFile("file", "falso.png", "image/png", "no soy un png".getBytes());
mvc.perform(multipart("/api/files").file(file))
.andExpect(status().isUnsupportedMediaType());
}
}
En Spring Boot 4 @SpringBootTest ya no aporta soporte de MockMvc por sí solo, por eso aparece @AutoConfigureMockMvc en la clase. Añade también el starter de test de Web MVC, spring-boot-starter-webmvc-test, junto al spring-boot-starter-webmvc de producción.
Lista de comprobación
- Límites de tamaño fijados en Spring y alineados con el proxy.
- Nombre en disco generado por el servidor; el original, solo como metadato.
- Ruta resuelta y normalizada, con comprobación
startsWithcontra el directorio base. - Tipo decidido por el contenido, con lista blanca, y
Content-Typedel cliente ignorado. - Directorio de subidas fuera de la raíz web y sin permisos de ejecución.
- Descarga con
attachmentynosniff, o tipos validados si se sirve en línea. - Ficheros que van a tratarse más adelante (imágenes que se redimensionan, PDF que se parsean) procesados en un entorno acotado, porque los exploits suelen vivir en esas librerías y no en tu controlador.
- Para volúmenes grandes o varias instancias, almacenamiento de objetos (S3 o compatible) en lugar del disco local, con URLs prefirmadas para que el fichero ni siquiera pase por tu aplicación.
Con esto tienes una base que aguanta los ataques de subida más habituales. Si tu aplicación se apoya en controladores Spring, te puede interesar cómo funcionan los controladores en Spring Boot, y para proteger el acceso a los endpoints la guía de autenticación en Spring Boot con JWT y OAuth2. El equivalente en el mundo PHP lo tienes en subida de ficheros en PHP con validación y almacenamiento seguro.
Imagen: Pexels / Panumas Nikhomkhai
