FundamentosLección 07
Entrada no confiable: validación, ficheros y deserialización
Todo lo que cruza la frontera de tu aplicación puede venir manipulado. Cómo validar con listas blancas, subir y servir ficheros sin path traversal, y por qué deserializar datos ajenos es peligroso.
Al terminar sabrás
- Validar entrada con listas blancas y Bean Validation, en la frontera de la aplicación.
- Evitar path traversal al leer o escribir ficheros con nombres del usuario.
- Recibir ficheros subidos sin convertir tu servidor en un almacén de malware.
- Reconocer XXE, deserialización insegura y ReDoS, y cómo neutralizarlos.
En la lección de vulnerabilidades web viste que la inyección nace de mezclar datos y código. Esta lección va un paso antes: qué hacer con los datos en el momento en que entran. Parámetros, cuerpos JSON, cabeceras, nombres de fichero, ficheros enteros, XML, objetos serializados… todo lo que viene de fuera es, por defecto, sospechoso.
Dónde y cómo validar
Sección titulada «Dónde y cómo validar»- Fronteracontrolador, consumidor de colas, lector de ficheros
- Normalizardecodificar, canonizar rutas y Unicode
- Validar sintaxistipo, longitud, formato
- Validar semánticareglas de negocio y autorización
- En la frontera: cuanto antes, mejor. Un dato validado al entrar se puede tratar como tipado y seguro en el resto del código.
- Lista blanca antes que lista negra: define qué es válido (un id es un número positivo; un nombre de usuario son 3–30 caracteres
[a-z0-9_]) en lugar de intentar enumerar todo lo malicioso. - Normaliza primero:
%2e%2e%2fes../codificado; una lista negra que busque../antes de decodificar no lo verá. - La validación no sustituye a la codificación de salida: un nombre válido puede contener
'o<. Las consultas parametrizadas y el escapado en la salida siguen siendo obligatorios.
Bean Validation en Spring
Sección titulada «Bean Validation en Spring»Con spring-boot-starter-validation, las restricciones se declaran en el DTO y se aplican con @Valid:
public record NewNote( @NotBlank @Size(max = 120) String title, @NotBlank @Size(max = 10_000) String content, @Pattern(regexp = "^[a-z0-9-]{1,30}$") String tag) { }@PostMapping("/api/notes")ResponseEntity<NoteView> create(@Valid @RequestBody NewNote body, Authentication auth) { Note note = notes.create(body, auth.getName()); return ResponseEntity.created(URI.create("/api/notes/" + note.id())).body(NoteView.of(note));}Si la validación falla, Spring responde 400 con un ProblemDetail (activa spring.mvc.problemdetails.enabled=true) sin llegar a tu código.
Path traversal: nombres de fichero que salen de su carpeta
Sección titulada «Path traversal: nombres de fichero que salen de su carpeta»Si construyes una ruta con un nombre que viene del usuario, ../../etc/passwd te saca del directorio previsto.
Evita
@GetMapping("/files/{name}")Resource download(@PathVariable String name) { return new FileSystemResource("/srv/uploads/" + name); // name = "..%2F..%2Fetc%2Fpasswd"}Prefiere
private static final Path BASE = Path.of("/srv/uploads").toAbsolutePath().normalize();
@GetMapping("/files/{id}")Resource download(@PathVariable UUID id) { Path file = BASE.resolve(id + ".bin").normalize(); if (!file.startsWith(BASE)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST); } return new FileSystemResource(file);}La mejor defensa es no usar nombres del usuario en el sistema de ficheros: guarda cada fichero con un identificador generado (un UUID) y conserva el nombre original solo como dato, en la base de datos. Si no queda más remedio, normaliza y comprueba con startsWith que la ruta final sigue dentro de la carpeta base.
Subida de ficheros
Sección titulada «Subida de ficheros»Un endpoint de subida es una puerta para que cualquiera deje contenido en tu servidor. Las reglas:
| Riesgo | Control |
|---|---|
| Ficheros gigantes que agotan disco o memoria | spring.servlet.multipart.max-file-size y max-request-size |
Un .html o .svg con JavaScript servido desde tu dominio (XSS) | Lista blanca de tipos, comprobando los bytes mágicos, no la extensión ni el Content-Type del cliente |
| Sobrescribir ficheros o path traversal con el nombre | Nombre generado por el servidor (UUID) |
| Ejecutar lo subido | Guardar fuera del directorio servido como estático, idealmente en un almacenamiento de objetos |
| Malware para otros usuarios | Escaneo antivirus asíncrono antes de publicar |
| El navegador “adivina” el tipo | Servir con Content-Disposition: attachment y X-Content-Type-Options: nosniff |
spring.servlet.multipart.max-file-size=5MBspring.servlet.multipart.max-request-size=6MBXXE: XML que lee tus ficheros
Sección titulada «XXE: XML que lee tus ficheros»Muchos parsers XML procesan por defecto entidades externas (XML External Entities). Un documento como este hace que el parser lea un fichero del servidor e incluya su contenido en la respuesta:
<?xml version="1.0"?><!DOCTYPE note [ <!ENTITY secret SYSTEM "file:///etc/passwd"> ]><note><title>&secret;</title></note>La defensa es desactivar los DTD en el parser:
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);factory.setXIncludeAware(false);factory.setExpandEntityReferences(false);Deserialización insegura
Sección titulada «Deserialización insegura»Deserializar es reconstruir objetos a partir de bytes. Si los bytes vienen de fuera y el formato permite elegir qué clase se crea, un atacante puede encadenar clases de tu classpath (gadget chains) hasta ejecutar código.
- Nunca uses
ObjectInputStreamcon datos no confiables. Si un sistema legado te obliga, limita las clases con unObjectInputFilter. - JSON con tipos polimórficos: en Jackson, no actives el tipado por defecto global (
activateDefaultTyping) ni aceptes nombres de clase en el JSON. Deserializa siempre a DTOs concretos. - Firma lo que vuelve a ti: si guardas datos serializados en una cookie o un campo oculto, fírmalos (HMAC) para detectar cualquier modificación.
Mini lab · 20 min
Un servicio de adjuntos seguro
Objetivo: Corregir un endpoint de subida y descarga de adjuntos con al menos cuatro problemas.
@RestControllerclass AttachmentController {
@PostMapping("/api/attachments") String upload(@RequestParam MultipartFile file) throws IOException { Path target = Path.of("src/main/resources/static/uploads", file.getOriginalFilename()); file.transferTo(target); return "/uploads/" + file.getOriginalFilename(); }
@GetMapping("/api/attachments") Resource download(@RequestParam String name) { return new FileSystemResource("uploads/" + name); }}- Enumera los problemas de seguridad.
- Propón una versión corregida (en palabras o en código).
Pista
Fíjate en: de dónde sale el nombre del fichero, dónde se guarda, qué tipos acepta, cuánto puede pesar y quién puede descargar qué.
Mostrar respuestaOcultar respuesta
- Path traversal en la subida y en la descarga:
getOriginalFilename()ynamepueden contener../. - Guardado en el directorio estático: lo subido se sirve directamente desde tu dominio; un
.htmlo.svgcon JavaScript es un XSS almacenado. - Sin lista blanca de tipos ni comprobación de contenido.
- Sin límite de tamaño explícito.
- Sobrescritura: dos usuarios que suben
foto.pngse pisan. - Sin autorización por objeto: cualquiera descarga cualquier adjunto conociendo su nombre (IDOR).
Versión corregida: generar un UUID como nombre, guardar fuera del directorio servido (o en un almacenamiento de objetos), guardar en base de datos id, owner, nombre original y tipo detectado; aceptar solo una lista blanca de tipos verificados por sus bytes mágicos; limitar el tamaño; descargar por id comprobando el dueño (findByIdAndOwner) y responder con Content-Disposition: attachment y nosniff.