Ir al contenido

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.

Intermedio17 min de lectura

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.

  1. Fronteracontrolador, consumidor de colas, lector de ficheros
  2. Normalizardecodificar, canonizar rutas y Unicode
  3. Validar sintaxistipo, longitud, formato
  4. Validar semánticareglas de negocio y autorización
Valida lo antes posible, después de normalizar. Y al salir, codifica según el contexto (HTML, SQL, URL).
  • 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%2f es ../ 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.

Con spring-boot-starter-validation, las restricciones se declaran en el DTO y se aplican con @Valid:

NewNote.java
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) { }
NoteController.java
@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.

Un endpoint de subida es una puerta para que cualquiera deje contenido en tu servidor. Las reglas:

RiesgoControl
Ficheros gigantes que agotan disco o memoriaspring.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 nombreNombre generado por el servidor (UUID)
Ejecutar lo subidoGuardar fuera del directorio servido como estático, idealmente en un almacenamiento de objetos
Malware para otros usuariosEscaneo antivirus asíncrono antes de publicar
El navegador “adivina” el tipoServir con Content-Disposition: attachment y X-Content-Type-Options: nosniff
application.properties
spring.servlet.multipart.max-file-size=5MB
spring.servlet.multipart.max-request-size=6MB

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:

payload.xml
<?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:

SafeXml.java
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);

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 ObjectInputStream con datos no confiables. Si un sistema legado te obliga, limita las clases con un ObjectInputFilter.
  • 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.

AttachmentController.java
@RestController
class 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);
}
}
  1. Enumera los problemas de seguridad.
  2. 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
  1. Path traversal en la subida y en la descarga: getOriginalFilename() y name pueden contener ../.
  2. Guardado en el directorio estático: lo subido se sirve directamente desde tu dominio; un .html o .svg con JavaScript es un XSS almacenado.
  3. Sin lista blanca de tipos ni comprobación de contenido.
  4. Sin límite de tamaño explícito.
  5. Sobrescritura: dos usuarios que suben foto.png se pisan.
  6. 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.