Seguridad de aplicaciones Java y Spring
La seguridad no es una capa que se añade al final: es una propiedad del diseño. Este módulo recorre los principios (CIA, defensa en profundidad, mínimo privilegio, confianza cero), la criptografía que de verdad vas a usar, la arquitectura completa de Spring Security 6, la autenticación con tokens, OAuth 2 y OpenID Connect, el OWASP Top 10 con código vulnerable y su corrección, la cadena de suministro, la privacidad y el proceso. Siempre con el por qué, porque una regla de seguridad que no entiendes es una regla que acabarás desactivando “solo en desarrollo”.
1. Fundamentos: principios y modelado de amenazas
1.1 La tríada CIA: qué proteges exactamente
Todo requisito de seguridad se reduce a tres propiedades. Nombrarlas te obliga a ser concreto y evita frases vacías del tipo “la aplicación tiene que ser segura”.
| Propiedad | Qué garantiza | Ejemplo de fallo en una API de pedidos | Controles típicos |
|---|---|---|---|
| Confidencialidad | Solo quien debe, ve el dato. | GET /pedidos/4711 devuelve el pedido de otro cliente, con su dirección y su teléfono. |
Autorización, cifrado en tránsito y en reposo, minimización de campos en las respuestas. |
| Integridad | El dato no se altera sin autorización y, si se altera, se detecta. | El cliente envía un total de 0,01 € y el servidor lo acepta en lugar de recalcularlo. | Validación en el servidor, firmas, hashes, transacciones, auditoría, control de versiones optimista. |
| Disponibilidad | El servicio responde cuando se le necesita. | Un endpoint sin paginación ni límite de tamaño tumba el servicio con una sola petición. | Rate limiting, timeouts, cuotas, límites de payload, autoescalado, WAF y anti-DDoS. |
A la tríada clásica se le añaden dos propiedades imprescindibles en sistemas de negocio: autenticidad (el mensaje viene de quien dice venir) y no repudio (quien hizo algo no puede negarlo después, gracias a la auditoría).
1.2 Los siete principios que debes poder recitar
| Principio | Qué significa | Traducción a código o configuración |
|---|---|---|
| Superficie de ataque mínima | Cada puerto, endpoint, parámetro, dependencia y usuario es una puerta más que vigilar. | Borra endpoints muertos, cierra Actuator, elimina dependencias sin usar, imagen base distroless. |
| Defensa en profundidad | Ninguna medida es infalible: se apilan capas independientes para que el fallo de una no sea catastrófico. | WAF + autorización por URL + autorización por método + comprobación de propiedad en la consulta. |
| Mínimo privilegio | Cada identidad tiene exactamente los permisos que necesita, y durante el tiempo que los necesita. | Usuario de base de datos sin DROP, un rol de IAM por servicio, contenedor sin root, token con un solo scope. |
| Confianza cero (zero trust) | La red no otorga confianza. Cada petición se autentica y se autoriza, venga de dentro o de fuera. | mTLS entre microservicios, validación del token en cada salto, nunca “si viene de la VPN, pasa”. |
| Fallar de forma segura | Si algo va mal, el resultado por defecto es denegar, no permitir. | anyRequest().authenticated() al final; si el servicio de permisos no responde, se deniega. |
| Seguridad por diseño y por defecto | La opción segura es la que sale de fábrica; la insegura requiere un acto explícito y justificado. | TLS obligatorio, contraseñas hasheadas por el framework, CSRF activado, cookies HttpOnly. |
| Shift-left | El coste de arreglar un fallo crece un orden de magnitud en cada fase, así que se busca pronto. | Modelado de amenazas en diseño, SAST y SCA en cada pull request, DAST en preproducción. |
1.3 Modelado de amenazas con STRIDE, aplicado de verdad
Modelar amenazas es una reunión de una hora con un diagrama, no un documento de ochenta páginas. El procedimiento honesto son las cuatro preguntas de Adam Shostack: ¿en qué estamos trabajando? (diagrama), ¿qué puede salir mal? (STRIDE), ¿qué vamos a hacer? (mitigaciones con dueño y fecha) y ¿lo hemos hecho bien? (revisión).
DIAGRAMA DE FLUJO DE DATOS DE LA API DE PEDIDOS (lo que dibujas en la pizarra)
┌─────────────┐ 1. HTTPS ┌───────────────┐ 3. HTTPS/mTLS ┌──────────────┐
│ Navegador │────────────────►│ │──────────────────►│ Servicio │
│ (SPA) │◄────────────────│ API pedidos │◄──────────────────│ de pagos │
└─────────────┘ │ (Spring Boot) │ └──────────────┘
│ └───┬───────┬───┘
│ 2. Authorization Code │ │ 4. JDBC sobre TLS
│ + PKCE │ ▼
▼ │ ┌──────────────┐
┌─────────────┐ valida el JWT │ │ PostgreSQL │
│ Keycloak │◄─── con el JWKS ─────┘ │ (cifrado │
│ (AS/OIDC) │ │ en reposo) │
└─────────────┘ └──────────────┘
════ LÍMITES DE CONFIANZA (trust boundaries) ════
A) Navegador ↔ API : todo dato de entrada es hostil. NADA de lógica en el cliente.
B) API ↔ Keycloak : confío en la FIRMA del token, no en la red.
C) API ↔ pagos : otro equipo, otro despliegue → identidad propia y autorización.
D) API ↔ base de datos : credenciales, mínimo privilegio y cifrado del canal.
Cada FLECHA que cruza un límite de confianza es donde se aplica STRIDE.
| STRIDE | Amenaza concreta en la API de pedidos | Propiedad violada | Mitigación |
|---|---|---|---|
| Spoofing suplantación |
Alguien llama a POST /pedidos con un JWT que ha fabricado él, o reutiliza el token robado de otro usuario. |
Autenticidad | Validar la firma con el JWKS del emisor, comprobar iss, aud y exp, vida corta del token, MFA en el login, mTLS entre servicios. |
| Tampering manipulación |
El cliente envía un precio unitario de 0,01 € o cambia el estado del pedido a PAGADO en el JSON. |
Integridad | DTO de entrada con solo los campos que el cliente puede fijar; precios y estados siempre recalculados en el servidor; @Version optimista. |
| Repudiation repudio |
Un operador cancela 300 pedidos y luego niega haberlo hecho; no hay rastro de quién fue. | No repudio | Log de auditoría con actor, acción, recurso, IP, traceId y marca de tiempo, en almacenamiento de solo añadido y con retención definida. |
| Information disclosure divulgación |
GET /pedidos/4711 devuelve el pedido de otro cliente (IDOR). El stack trace revela versiones y rutas internas. |
Confidencialidad | Comprobación de propiedad en la consulta, no solo en un if; errores genéricos con traceId; TLS; sin datos personales en los logs. |
| Denial of service denegación de servicio |
GET /pedidos?size=1000000, o 50.000 intentos de login por minuto, o un JSON de 500 MB. |
Disponibilidad | Paginación con tope duro, spring.servlet.multipart.max-file-size, rate limiting por IP y por usuario, timeouts, cuotas y WAF. |
| Elevation of privilege escalada |
Un usuario normal llama a DELETE /admin/pedidos/4711, que solo estaba oculto en la interfaz. |
Autorización | Deny by default, autorización en el servicio con @PreAuthorize y no solo por URL, roles que nunca vienen del cliente. |
1.4 Autenticación, autorización y auditoría no son lo mismo
| Concepto | Pregunta que responde | Cuándo ocurre | En Spring Security | Código HTTP si falla |
|---|---|---|---|---|
| Autenticación (AuthN) | ¿Quién eres? ¿Puedes demostrarlo? | Una vez, al principio de la sesión o de la petición. | AuthenticationManager, AuthenticationProvider, filtros de autenticación. |
401 Unauthorized (mal nombrado: significa “no autenticado”), con WWW-Authenticate. |
| Autorización (AuthZ) | ¿Puedes hacer esto, sobre este recurso? | En cada operación, tantas veces como haga falta. | AuthorizationManager, AuthorizationFilter, @PreAuthorize. |
403 Forbidden, o 404 si el propio hecho de que el recurso exista es información sensible. |
| Auditoría (accounting) | ¿Quién hizo qué, cuándo y desde dónde? | Después, de forma inmutable. | Eventos AuthenticationSuccess/FailureEvent, AOP, @EntityListeners. |
No falla: si no puedes auditar una operación crítica, considera abortarla. |
Checklist — fundamentos
2. Criptografía aplicada: lo justo y sin errores
2.1 Hash, cifrado y firma: tres cosas distintas
| Primitiva | ¿Reversible? | ¿Clave? | Qué garantiza | Ejemplo de uso correcto |
|---|---|---|---|---|
| Hash (SHA-256, SHA-3) | No: función de un solo sentido | No | Integridad: mismo dato, mismo resumen. | Huella de un fichero, deduplicación, árboles de Merkle, ETag. |
| Hash de contraseñas (BCrypt, Argon2id) | No, y además deliberadamente lento | No, pero con salt | Que un volcado de la base de datos no revele las contraseñas. | La columna del hash en la tabla de usuarios. |
| MAC / HMAC (HMAC-SHA256) | No | Sí, simétrica | Integridad y autenticidad entre quienes comparten la clave. | Firma de webhooks, JWT con HS256, cookies firmadas. |
| Cifrado simétrico (AES-GCM, ChaCha20-Poly1305) | Sí, con la misma clave | Sí, una compartida | Confidencialidad, e integridad si es AEAD. | Cifrado de un campo sensible en base de datos, ficheros, el interior de TLS. |
| Cifrado asimétrico (RSA-OAEP, ECIES) | Sí: cifra con la pública, descifra con la privada | Sí, par de claves | Confidencialidad sin compartir un secreto previo. | Intercambiar una clave de sesión; cifrar un secreto pequeño para un destinatario concreto. |
| Firma digital (RSA-PSS, ECDSA P-256, Ed25519) | No | Sí: firma con la privada, verifica con la pública | Autenticidad, integridad y no repudio. | JWT con RS256 o ES256, certificados TLS, firma de artefactos. |
2.2 Hashing de contraseñas: el caso que sí te va a tocar
MD5, SHA-1, SHA-256 y SHA-512 no sirven para contraseñas. No porque estén “rotos” en el sentido de las colisiones (SHA-256 está perfectamente bien como hash), sino porque están diseñados para ser rapidísimos: exactamente lo contrario de lo que necesitas aquí. Una GPU moderna calcula del orden de miles de millones de SHA-256 por segundo, así que un diccionario con los miles de millones de contraseñas filtradas históricas se prueba en minutos.
Un algoritmo de contraseñas necesita tres propiedades que SHA no tiene:
- Salt aleatorio y único por usuario. Impide precomputar tablas (rainbow tables) y hace que dos usuarios con la misma contraseña tengan hashes distintos. El salt no es secreto: se guarda junto al hash.
- Coste configurable (factor de trabajo). El hash tarda deliberadamente 100–500 ms. Para ti es imperceptible; para quien prueba millones de candidatos es un muro. Y se puede subir con los años sin cambiar de algoritmo.
- Coste de memoria en los algoritmos modernos. Argon2 y SCrypt exigen decenas de MiB por cálculo, lo que arruina la ventaja de las GPU y los ASIC, que tienen mucha unidad aritmética pero poca memoria por núcleo.
// ❌ INCORRECTO — todas estas variantes se han visto en producción y todas son inaceptables
usuario.setPassword(password); // texto plano
usuario.setPassword(DigestUtils.md5Hex(password)); // MD5: se rompe en segundos
usuario.setPassword(sha256(password)); // rápido → fuerza bruta trivial
usuario.setPassword(sha256(SAL_GLOBAL + password)); // salt único global: inútil
usuario.setPassword(Base64.getEncoder().encodeToString(bytes)); // Base64 NO es cifrado
usuario.setPassword(cifrarAes(password, CLAVE)); // reversible: roban la clave,
// roban todas las contraseñas
// ✅ CORRECTO — delega en Spring Security y no toques el algoritmo a mano
@Bean
PasswordEncoder passwordEncoder() {
// Devuelve un DelegatingPasswordEncoder: codifica con el algoritmo por defecto (bcrypt)
// y sabe VERIFICAR cualquiera de los formatos históricos gracias al prefijo {id}.
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
// Alta de usuario
String hash = passwordEncoder.encode(passwordEnClaro);
// hash → "{bcrypt}$2a$10$N9qo8uLOickgx2ZMRZoMye/Ci1Xh0zqQ8pEeS6PfLZKuHNP7BvJ2u"
// └──┬───┘└┬┘└┬┘└──────── salt (22 caracteres) ──┘└────── hash ──────┘
// │ │ └ coste = 2^10 = 1024 iteraciones internas
// │ └── versión del algoritmo bcrypt
// └──────── identificador que permite migrar de algoritmo sin romper nada
// Verificación: NUNCA compares hashes tú mismo, ni con equals ni con ==
boolean ok = passwordEncoder.matches(passwordRecibido, usuario.getPasswordHash());
// Elección explícita del algoritmo cuando quieres Argon2id (recomendación de OWASP)
@Bean
PasswordEncoder passwordEncoderArgon2() {
String idPorDefecto = "argon2";
Map<String, PasswordEncoder> codificadores = Map.of(
"argon2", Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8(),
"bcrypt", new BCryptPasswordEncoder(12),
"scrypt", SCryptPasswordEncoder.defaultsForSpringSecurity_v5_8(),
"pbkdf2", Pbkdf2PasswordEncoder.defaultsForSpringSecurity_v5_8());
var delegante = new DelegatingPasswordEncoder(idPorDefecto, codificadores);
// Cualquier hash antiguo SIN prefijo se asume bcrypt (soporte de migración):
delegante.setDefaultPasswordEncoderForMatches(new BCryptPasswordEncoder());
return delegante;
}
| Algoritmo | Tipo | Parámetros recomendados | Cuándo usarlo |
|---|---|---|---|
| Argon2id | Memoria dura; ganador del Password Hashing Competition | Desde 19 MiB de memoria, 2 iteraciones y paralelismo 1 (perfil de OWASP). Spring usa 16 MiB por defecto. | Primera opción en proyectos nuevos. |
| BCrypt | Coste en CPU, basado en Blowfish | Coste 10–12: mídelo y apunta a unos 250 ms en tu hardware. | Estándar de facto y por defecto en Spring. La opción segura y aburrida. |
| SCrypt | Memoria dura, anterior a Argon2 | N = 2^16, r = 8, p = 1. | Alternativa válida si ya lo usas. |
| PBKDF2-HMAC-SHA256 | Solo iteraciones | Desde 600.000 iteraciones y salt de 16 bytes. | Solo cuando lo exige una certificación (FIPS) que no admite los anteriores. |
matches() funciona sin que tú guardes nada más.
Migrar de algoritmo sin obligar a nadie a cambiar su contraseña
// El prefijo {id} de DelegatingPasswordEncoder permite convivencia y migración perezosa:
// en el momento del login (el ÚNICO momento en que tienes la contraseña en claro) rehasheas.
@Service
public class ServicioAutenticacion {
private final PasswordEncoder encoder;
private final RepositorioUsuarios repositorio;
@Transactional
public Usuario autenticar(String email, String password) {
Usuario u = repositorio.porEmail(email)
.orElseThrow(() -> new BadCredentialsException("Credenciales no válidas"));
if (!encoder.matches(password, u.getPasswordHash())) {
throw new BadCredentialsException("Credenciales no válidas"); // mensaje IDÉNTICO
}
// Migración transparente: si el hash usa un algoritmo o un coste antiguos, se regraba
if (encoder.upgradeEncoding(u.getPasswordHash())) {
u.setPasswordHash(encoder.encode(password));
repositorio.guardar(u);
}
return u;
}
}
2.3 Cifrado simétrico: AES-GCM bien hecho
Si necesitas guardar un dato y luego recuperarlo (un IBAN, el token de un proveedor
externo, un número de historia clínica), usas cifrado simétrico autenticado (AEAD). En la JCA eso es
AES/GCM/NoPadding. Tres reglas no negociables:
- Nunca ECB.
AES/ECBcifra cada bloque de 16 bytes de forma independiente, así que bloques de texto claro iguales producen bloques cifrados iguales y el patrón del original se ve a través del cifrado (el famoso “pingüino ECB”). Y ojo:Cipher.getInstance("AES")significa ECB en la implementación por defecto de la JDK. Escribe siempre el modo de forma explícita. - IV (nonce) aleatorio y único por mensaje. Reutilizar el par (clave, IV) en GCM es catastrófico: no solo se filtra información del texto claro, sino que permite recuperar la clave de autenticación y falsificar mensajes. El IV no es secreto: se guarda junto al criptograma.
- Verifica la etiqueta. GCM ya lo hace: si el criptograma se ha manipulado,
doFinal()lanzaAEADBadTagException. No captures esa excepción para “seguir con lo que se pueda”.
// ❌ INCORRECTO — el catálogo de errores de cifrado en Java
Cipher c = Cipher.getInstance("AES"); // = AES/ECB: patrón visible
Cipher d = Cipher.getInstance("AES/CBC/PKCS5Padding"); // sin MAC → maleable, padding oracle
byte[] iv = new byte[12]; // IV de ceros
byte[] iv2 = "1234567890ab".getBytes(); // IV constante: rotura total en GCM
SecretKey k = new SecretKeySpec("claveSuperSecreta1234567890123456".getBytes(), "AES"); // en el código
Cipher e = Cipher.getInstance("DES/CBC/PKCS5Padding"); // DES, 3DES, RC4, Blowfish: obsoletos
// ✅ CORRECTO — AES-256-GCM con IV aleatorio por mensaje y datos asociados
public final class CifradoCampo {
private static final String TRANSFORMACION = "AES/GCM/NoPadding";
private static final int LONGITUD_IV = 12; // 96 bits: el tamaño recomendado por NIST
private static final int LONGITUD_TAG_BITS = 128; // etiqueta de autenticación completa
private static final SecureRandom ALEATORIO = new SecureRandom();
private final SecretKey clave; // inyectada desde un KMS o desde un secreto de la plataforma
public CifradoCampo(SecretKey clave) { this.clave = clave; }
/**
* Devuelve iv || criptograma+etiqueta en Base64 URL-safe.
* @param datosAsociados contexto NO cifrado pero SÍ autenticado (p. ej. "usuario:4711:iban").
* Impide mover un criptograma válido de una fila a otra.
*/
public String cifrar(byte[] claro, byte[] datosAsociados) throws GeneralSecurityException {
byte[] iv = new byte[LONGITUD_IV];
ALEATORIO.nextBytes(iv); // ← nuevo IV en CADA llamada
Cipher cipher = Cipher.getInstance(TRANSFORMACION);
cipher.init(Cipher.ENCRYPT_MODE, clave, new GCMParameterSpec(LONGITUD_TAG_BITS, iv));
cipher.updateAAD(datosAsociados);
byte[] criptograma = cipher.doFinal(claro); // incluye la etiqueta al final
byte[] salida = new byte[iv.length + criptograma.length];
System.arraycopy(iv, 0, salida, 0, iv.length);
System.arraycopy(criptograma, 0, salida, iv.length, criptograma.length);
return Base64.getUrlEncoder().withoutPadding().encodeToString(salida);
}
public byte[] descifrar(String base64, byte[] datosAsociados) throws GeneralSecurityException {
byte[] todo = Base64.getUrlDecoder().decode(base64);
if (todo.length <= LONGITUD_IV) throw new IllegalArgumentException("criptograma truncado");
GCMParameterSpec spec = new GCMParameterSpec(LONGITUD_TAG_BITS, todo, 0, LONGITUD_IV);
Cipher cipher = Cipher.getInstance(TRANSFORMACION);
cipher.init(Cipher.DECRYPT_MODE, clave, spec);
cipher.updateAAD(datosAsociados);
// Si alguien tocó un solo bit → AEADBadTagException. Déjala subir: el dato NO es de fiar.
return cipher.doFinal(todo, LONGITUD_IV, todo.length - LONGITUD_IV);
}
}
// Derivar una clave AES a partir de una contraseña (solo si NO tienes KMS)
SecretKeyFactory fabrica = SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256");
KeySpec spec = new PBEKeySpec(passphrase.toCharArray(), salt, 600_000, 256);
SecretKey clave = new SecretKeySpec(fabrica.generateSecret(spec).getEncoded(), "AES");
2.4 Asimétrico, firmas y certificados
El cifrado asimétrico resuelve el problema del huevo y la gallina: cómo acordar un secreto con alguien con quien nunca has hablado, por un canal que alguien está escuchando. Es lento (miles de veces más que AES), así que no se usa para cifrar datos, sino para tres cosas:
- Acordar una clave de sesión (lo que hace TLS con ECDHE) que luego se usa con AES.
- Firmar: yo firmo con mi clave privada; cualquiera verifica con mi pública. Es lo que hacen un JWT RS256 y un certificado.
- Cifrar secretos pequeños para un destinatario concreto (RSA-OAEP, nunca PKCS#1 v1.5, por el ataque de Bleichenbacher).
| Familia | Tamaño mínimo hoy | Firma | Notas prácticas |
|---|---|---|---|
| RSA | 2048 bits (3072 para datos de larga vida) | RSA-PSS, mejor que PKCS#1 v1.5 | Ubicuo y bien soportado. Claves y firmas grandes, operaciones lentas. |
| Curva elíptica (ECDSA/ECDH) | P-256, equivalente a unos 3072 bits de RSA | ES256 en JWT | Claves y firmas pequeñas, y rápido. Es lo que usa casi todo TLS moderno. |
| Ed25519 / EdDSA | 256 bits | Ed25519 (JDK 15+) | Rápido, determinista y sin parámetros que puedas equivocar. La mejor opción para firmar hoy. |
// Firmar y verificar: el patrón es siempre el mismo
KeyPairGenerator gen = KeyPairGenerator.getInstance("Ed25519");
KeyPair par = gen.generateKeyPair();
Signature firmador = Signature.getInstance("Ed25519");
firmador.initSign(par.getPrivate());
firmador.update(mensaje);
byte[] firma = firmador.sign(); // solo el dueño de la privada puede producirla
Signature verificador = Signature.getInstance("Ed25519");
verificador.initVerify(par.getPublic()); // la pública se puede publicar sin miedo
verificador.update(mensaje);
boolean valida = verificador.verify(firma); // ← ¿compruebas el RESULTADO? Es obligatorio
if (!valida) throw new SecurityException("firma no válida");
// ⚠️ El fallo real más común con firmas no es criptográfico: es IGNORAR el booleano de verify()
// o capturar la excepción y continuar. Ese "if" salva sistemas enteros.
Certificados y cadena de confianza
Una clave pública por sí sola no dice nada: ¿de quién es? Un certificado X.509 es una clave pública, más una identidad (el subject, hoy siempre en la extensión SAN), más un periodo de validez, todo firmado por una autoridad de certificación (CA). Tu navegador confía en unas ciento cincuenta CA raíz preinstaladas; a partir de ahí, la confianza se hereda por la cadena.
CADENA DE CONFIANZA
┌──────────────────────────┐
│ CA raíz (autofirmada) │ Está en el truststore del sistema o de la JVM (cacerts).
│ clave privada offline │ Si esta se compromete, se cae todo el sistema.
└────────────┬─────────────┘
│ firma
┌────────────▼─────────────┐
│ CA intermedia │ Firma el día a día. El servidor DEBE enviarla también
└────────────┬─────────────┘ (olvidarla = "certificado no válido" solo en algunos clientes).
│ firma
┌────────────▼─────────────┐
│ Certificado del servidor │ SAN = api.miempresa.com · validez 90 días (ACME/Let's Encrypt)
└──────────────────────────┘
VALIDACIÓN QUE HACE EL CLIENTE (todas, no solo la primera):
1. La firma de cada eslabón es correcta y llega a una raíz de confianza.
2. Ninguno está caducado ni es todavía futuro (¡reloj del contenedor sincronizado!).
3. El nombre solicitado coincide con una SAN del certificado.
4. El certificado no está revocado (OCSP stapling o CRL).
5. Los usos de clave y las restricciones básicas son coherentes.
❌ Desactivar el paso 1 o el 3 (un "TrustManager que acepta todo", un "HostnameVerifier que
devuelve true") convierte TLS en cifrado SIN autenticación: cualquiera puede ponerse en
medio. Es la vulnerabilidad más frecuente en clientes móviles y en integraciones internas
"provisionales".
2.5 TLS: el handshake, mTLS y cómo se configura
HANDSHAKE DE TLS 1.3 (1 RTT: la mitad que en TLS 1.2)
CLIENTE SERVIDOR
│ │
│──── ClientHello ────────────────────────────────────────────►│
│ · versiones soportadas (1.3) │
│ · cipher suites (TLS_AES_128_GCM_SHA256…) │
│ · key_share: mi parte pública de ECDHE (X25519) │
│ · SNI: api.miempresa.com · ALPN: h2 │
│ │
│◄─── ServerHello ─────────────────────────────────────────────│
│ · cipher suite elegida │
│ · key_share: su parte pública de ECDHE │
│ ══ desde aquí TODO va cifrado ══ │
│ · {EncryptedExtensions} │
│ · {Certificate} ← cadena X.509 │
│ · {CertificateVerify} ← firma que demuestra que tiene │
│ la clave privada del cert. │
│ · {Finished} │
│ │
│ [el cliente valida la cadena, el nombre y las fechas] │
│ │
│──── {Finished} ─────────────────────────────────────────────►│
│──── {datos de aplicación: GET /pedidos} ────────────────────►│
CLAVE DEL DISEÑO: la clave de sesión sale de ECDHE, es EFÍMERA y nunca viaja por la red.
Aunque un atacante grabe todo el tráfico y años después robe la clave privada del servidor,
NO puede descifrar lo grabado → forward secrecy, obligatoria en TLS 1.3.
TLS 1.3 eliminó: RSA como transporte de clave, Diffie-Hellman estático, la renegociación,
la compresión (CRIME), MD5 y SHA-1, RC4, los modos CBC y todos los cipher suites no AEAD.
→ "usa TLS 1.3, y 1.2 solo como respaldo" es una configuración correcta y suficiente.
mTLS (TLS mutuo) añade un paso: el servidor pide también un certificado al cliente y verifica su cadena. Deja de haber “clientes anónimos con token”; hay identidades criptográficas a nivel de conexión. Es el mecanismo estándar para servicio a servicio y la base de las mallas de servicio (Istio, Linkerd), que lo aplican de forma transparente. Véase el módulo 08.
# application.yml — TLS en Spring Boot 3.x con SSL bundles (3.1+)
server:
port: 8443
ssl:
enabled: true
bundle: servidor
client-auth: none # need = mTLS obligatorio · want = opcional
spring:
ssl:
bundle:
jks:
servidor:
key:
alias: api
keystore:
location: file:/etc/tls/keystore.p12
password: ${KEYSTORE_PASSWORD} # NUNCA literal en el fichero
type: PKCS12
truststore: # solo necesario para mTLS
location: file:/etc/tls/truststore.p12
password: ${TRUSTSTORE_PASSWORD}
type: PKCS12
# Un bundle PEM es más cómodo con cert-manager en Kubernetes:
pem:
interno:
keystore:
certificate: file:/etc/tls/tls.crt
private-key: file:/etc/tls/tls.key
truststore:
certificate: file:/etc/tls/ca.crt
# Generar material de prueba (SOLO para desarrollo local)
keytool -genkeypair -alias api -keyalg EC -groupname secp256r1 \
-storetype PKCS12 -keystore keystore.p12 \
-validity 365 -dname "CN=localhost" \
-ext "SAN=dns:localhost,ip:127.0.0.1"
# Inspeccionar lo que sirve un servidor de verdad
openssl s_client -connect api.miempresa.com:443 -servername api.miempresa.com -tls1_3 < /dev/null
openssl s_client -connect api.miempresa.com:443 -showcerts 2>/dev/null \
| openssl x509 -noout -dates -text
# Ver qué versiones y suites acepta (auditoría rápida)
nmap --script ssl-enum-ciphers -p 443 api.miempresa.com
# En producción, la referencia pública es SSL Labs (ssllabs.com/ssltest). Objetivo: A o A+.
server:
# Detrás de un proxy, para que Spring sepa el esquema y la IP originales y genere URLs https
forward-headers-strategy: framework # confía en X-Forwarded-* (el proxy DEBE sanearlos)
tomcat:
remoteip:
remote-ip-header: x-forwarded-for
protocol-header: x-forwarded-proto
error:
include-stacktrace: never # jamás un stack trace al cliente
include-message: never
include-binding-errors: never
servlet:
session:
cookie:
http-only: true # el JavaScript no puede leer la cookie
secure: true # solo se envía por HTTPS
same-site: lax # mitiga CSRF; strict si no necesitas navegación entrante
// Redirigir HTTP a HTTPS y forzar HSTS lo hace normalmente el ingress; en la aplicación:
http.requiresChannel(canal -> canal.anyRequest().requiresSecure())
.headers(h -> h.httpStrictTransportSecurity(hsts -> hsts
.includeSubDomains(true)
.preload(true)
.maxAgeInSeconds(31_536_000))); // 1 año
// ⚠️ HSTS es una decisión IRREVERSIBLE durante max-age para los navegadores que ya lo vieron.
// No lo actives con includeSubDomains sobre un dominio del que algún subdominio siga en HTTP.
2.6 Aleatoriedad, comparaciones y gestión de claves
// ❌ Random NO es seguro: es un generador congruencial de 48 bits, predecible con dos salidas.
String token = Long.toHexString(new Random().nextLong()); // adivinable
String otro = UUID.nameUUIDFromBytes(email.getBytes()).toString(); // UUID v3: DETERMINISTA
int codigo = (int) (Math.random() * 1_000_000); // OTP predecible
// ✅ SecureRandom para todo lo que deba ser impredecible
private static final SecureRandom RNG = new SecureRandom(); // reutilízalo: es thread-safe
public static String tokenOpaco() {
byte[] bytes = new byte[32]; // 256 bits de entropía
RNG.nextBytes(bytes);
return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
}
String idPublico = UUID.randomUUID().toString(); // UUID v4: usa SecureRandom por dentro
// getInstanceStrong() para material de clave de larga vida
SecureRandom fuerte = SecureRandom.getInstanceStrong();
// ✅ Comparación en tiempo constante: obligatoria para tokens, MAC y códigos OTP.
// equals() sale en el primer byte distinto → filtra información por el TIEMPO de respuesta.
boolean malo = tokenEsperado.equals(tokenRecibido); // ❌ canal lateral
boolean bueno = MessageDigest.isEqual(tokenEsperado.getBytes(UTF_8),
tokenRecibido.getBytes(UTF_8)); // ✅ tiempo constante
// Y no guardes tokens de sesión ni API keys en claro: guarda su SHA-256 (son valores de alta
// entropía, no necesitan bcrypt) y compara hashes. Si roban la tabla, no roban tokens usables.
| Aspecto de la gestión de claves | Mal | Bien |
|---|---|---|
| Dónde vive la clave | Una constante en el código, versionada en el repositorio. | KMS gestionado (AWS KMS, Azure Key Vault, Google Cloud KMS), Vault o HSM. La clave maestra nunca sale del KMS. |
| Cómo cifras muchos datos | Una sola clave para todo, para siempre. | Envelope encryption: el KMS protege la clave maestra y cada dato se cifra con una DEK única que se guarda cifrada junto al dato. |
| Rotación | Nunca, porque “funciona”. | Programada (anual) y bajo demanda tras un incidente. Versiona las claves con un keyId para poder descifrar lo antiguo mientras cifras con lo nuevo. |
| Separación | La misma clave en desarrollo y en producción. | Clave distinta por entorno y por propósito (cifrado ≠ firma ≠ sesiones). Desarrollo jamás puede descifrar producción. |
| Auditoría | Sin registro de uso. | Cada operación del KMS queda en el log de auditoría, con alertas por uso anómalo. |
application.yml del repositorio ni en una
imagen Docker; y no desactives la validación de certificados “temporalmente”, porque eso siempre acaba en
producción.
Checklist — criptografía
3. Spring Security 6: arquitectura y configuración moderna
3.1 La cadena de filtros: dónde ocurre todo
Spring Security es, en esencia, un solo filtro de servlet registrado en el contenedor
(springSecurityFilterChain, envuelto en DelegatingFilterProxy) que delega en una
o varias SecurityFilterChain. Cada cadena es una lista ordenada de filtros, y ese
orden lo explica casi todo: por qué el CORS debe ir antes que la autorización, por qué el
CSRF bloquea tu POST antes de llegar al controlador, o por qué un 403 aparece “sin que se ejecute tu
código”.
PETICIÓN HTTP
│
▼
FilterChainProxy ── elige la PRIMERA SecurityFilterChain cuyo securityMatcher encaje
│
▼ (orden real, simplificado a los filtros que de verdad te afectan)
┌────────────────────────────────────────────────────────────────────────────────────┐
│ 1 DisableEncodeUrlFilter evita el jsessionid en la URL (fuga de sesión) │
│ 2 WebAsyncManagerIntegrationFilter propaga el SecurityContext a peticiones async │
│ 3 SecurityContextHolderFilter CARGA el SecurityContext (de la sesión) y lo │
│ limpia al final. En SS6 ya NO guarda solo; │
│ el guardado es explícito (repository.saveContext)│
│ 4 HeaderWriterFilter escribe CSP, X-Frame-Options, HSTS, nosniff… │
│ 5 CorsFilter preflight OPTIONS y cabeceras Access-Control-* │
│ 6 CsrfFilter valida el token CSRF en POST/PUT/PATCH/DELETE │
│ 7 LogoutFilter /logout: invalida sesión y limpia contexto │
│ 8 OAuth2AuthorizationRequestRedirectFilter inicia el flujo Authorization Code │
│ 9 UsernamePasswordAuthenticationFilter procesa el POST del formulario login │
│10 DefaultLoginPageGeneratingFilter la página de login "gratis" │
│11 ConcurrentSessionFilter control de sesiones simultáneas │
│12 BearerTokenAuthenticationFilter LEE "Authorization: Bearer …" (resource server) │
│13 BasicAuthenticationFilter HTTP Basic │
│14 RequestCacheAwareFilter reenvía a la URL que pedías antes del login │
│15 SecurityContextHolderAwareRequestFilter integra con la API de Servlet │
│16 RememberMeAuthenticationFilter cookie de "recuérdame" │
│17 AnonymousAuthenticationFilter si NADIE autenticó, crea un Authentication │
│ "anonymousUser" → nunca hay null a partir de aquí│
│18 SessionManagementFilter protección de fijación de sesión │
│19 ExceptionTranslationFilter ⇦ CAPTURA las excepciones de los filtros de │
│ abajo: AuthenticationException → 401 vía │
│ AuthenticationEntryPoint │
│ AccessDeniedException → 403 vía │
│ AccessDeniedHandler │
│20 AuthorizationFilter ⇦ APLICA authorizeHttpRequests (en SS5 era │
│ FilterSecurityInterceptor). Lanza │
│ AccessDeniedException, que sube al 19. │
└────────────────────────────────────────────────────────────────────────────────────┘
│ si nadie ha cortado la petición
▼
DispatcherServlet ──► @RestController ──► @Service con @PreAuthorize (segunda barrera)
CLAVE: el 19 envuelve al 20. Por eso un fallo de autorización se traduce en 403 "solo",
sin llegar a tu @ControllerAdvice. Y por eso el orden 5 antes que 20 permite que el
preflight OPTIONS del navegador funcione aunque el endpoint requiera autenticación.
3.2 Los objetos que tienes que conocer
| Componente | Responsabilidad | Lo que debes recordar |
|---|---|---|
SecurityContextHolder |
Guarda el SecurityContext de la petición en curso. |
Usa un ThreadLocal por defecto. No se propaga a hilos que crees tú: con @Async, ExecutorService o hilos virtuales necesitas DelegatingSecurityContext(Executor|Runnable). Y siempre se limpia al final de la petición (si no, el siguiente usuario del hilo del pool heredaría la identidad). |
Authentication |
El “quién”: getPrincipal(), getCredentials(), getAuthorities(), isAuthenticated(). |
Antes de autenticar es una petición de autenticación (con la contraseña); después es el resultado (sin credenciales, con authorities). Nunca es null tras el filtro anónimo. |
AuthenticationManager |
Punto de entrada de la autenticación. La implementación normal es ProviderManager. |
Recorre sus AuthenticationProvider y usa el primero que soporte el tipo de Authentication. |
AuthenticationProvider |
Sabe validar un mecanismo (usuario/contraseña, LDAP, OIDC, mTLS, API key…). | DaoAuthenticationProvider = UserDetailsService + PasswordEncoder. |
UserDetailsService |
loadUserByUsername(String): recupera el usuario y su hash. |
Debe lanzar UsernameNotFoundException; Spring la traduce a BadCredentialsException para no filtrar si el usuario existe. |
GrantedAuthority |
Un permiso, como cadena. Nada más. | hasRole("ADMIN") busca literalmente la authority ROLE_ADMIN; hasAuthority("X") busca X exacto. El prefijo ROLE_ es pura convención, pero obligatoria si usas hasRole. |
AuthorizationManager<T> |
Decide si un Authentication puede acceder a un objeto seguro. |
En Spring Security 6 sustituye a AccessDecisionManager y AccessDecisionVoter, que fueron eliminados. Es la extensión natural para reglas de dominio. |
// Obtener el usuario actual: tres formas, de mejor a peor
// ✅ 1. Inyección en el controlador: testeable y explícito
@GetMapping("/perfil")
public PerfilDto perfil(@AuthenticationPrincipal Jwt jwt) {
return servicio.perfilDe(jwt.getSubject());
}
// ✅ 2. En un servicio, cuando de verdad hace falta el contexto
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
String usuario = auth.getName();
boolean esAdmin = auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_ADMIN".equals(a.getAuthority()));
// ❌ 3. Pasar el "userId" como parámetro que envía el cliente
@GetMapping("/pedidos") // el cliente decide QUIÉN es → suplantación
public List<PedidoDto> mios(@RequestParam String usuarioId) { … }
// Propagar la identidad a otro hilo (necesario con @Async y con hilos virtuales propios)
Executor seguro = new DelegatingSecurityContextExecutor(Executors.newVirtualThreadPerTaskExecutor());
seguro.execute(() -> log.info("Aquí sí veo a {}",
SecurityContextHolder.getContext().getAuthentication().getName()));
3.3 Configuración moderna: componentes y lambda DSL
WebSecurityConfigurerAdapter ya no existe. Fue marcado como obsoleto en Spring
Security 5.7 y eliminado en 6.0. Si encuentras un tutorial con
extends WebSecurityConfigurerAdapter, configure(HttpSecurity),
authorizeRequests(), antMatchers() o and() encadenado, es material
de antes de 2022: no compilará. El modelo actual es declarar beans
(SecurityFilterChain, UserDetailsService, PasswordEncoder,
AuthenticationManager) y configurar con lambdas.
// ❌ ANTIGUO (Spring Security 5.x, ya eliminado) — solo para que lo reconozcas
@Configuration
public class SeguridadVieja extends WebSecurityConfigurerAdapter {
@Override protected void configure(HttpSecurity http) throws Exception {
http.authorizeRequests()
.antMatchers("/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
.and().formLogin()
.and().csrf().disable();
}
}
// ✅ MODERNO (Spring Boot 3.x / Spring Security 6.x)
@Configuration
@EnableWebSecurity
@EnableMethodSecurity // activa @PreAuthorize/@PostAuthorize (prePost está en true por defecto)
public class ConfiguracionSeguridad {
@Bean
SecurityFilterChain cadenaWeb(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
// El ORDEN IMPORTA: se evalúa de arriba abajo y gana la PRIMERA que encaja.
.requestMatchers("/", "/css/**", "/js/**", "/webjars/**").permitAll()
.requestMatchers("/actuator/health", "/actuator/info").permitAll()
.requestMatchers("/admin/**").hasRole("ADMIN") // busca ROLE_ADMIN
.requestMatchers(HttpMethod.POST, "/pedidos").hasAuthority("pedidos:crear")
.requestMatchers("/interno/**").hasIpAddress("10.0.0.0/8")
.anyRequest().authenticated()) // ← DENY BY DEFAULT: siempre al final
.formLogin(login -> login
.loginPage("/login").permitAll()
.defaultSuccessUrl("/panel", false)
.failureHandler(this::fallosDeLogin)) // audita y no filtra el motivo
.logout(logout -> logout
.logoutUrl("/logout") // por defecto exige POST con CSRF
.invalidateHttpSession(true)
.clearAuthentication(true)
.deleteCookies("JSESSIONID")
.logoutSuccessUrl("/?salida"))
.sessionManagement(sesion -> sesion
.sessionFixation(SessionFixationConfigurer::changeSessionId) // por defecto ya
.maximumSessions(3).maxSessionsPreventsLogin(false))
.headers(h -> h
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; object-src 'none'; base-uri 'self'; " +
"frame-ancestors 'none'; form-action 'self'"))
.frameOptions(FrameOptionsConfig::deny)
.referrerPolicy(r -> r.policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
.httpStrictTransportSecurity(hsts -> hsts.includeSubDomains(true)
.maxAgeInSeconds(31_536_000))
.permissionsPolicy(p -> p.policy("geolocation=(), camera=(), microphone=()")))
.build();
}
@Bean
PasswordEncoder passwordEncoder() {
return PasswordEncoderFactories.createDelegatingPasswordEncoder();
}
@Bean
UserDetailsService usuarios(RepositorioUsuarios repositorio) {
return email -> repositorio.porEmail(email)
.map(u -> User.withUsername(u.getEmail())
.password(u.getPasswordHash()) // ya viene con {bcrypt}…
.authorities(u.getAuthorities())
.accountLocked(u.estaBloqueado())
.credentialsExpired(u.debeCambiarPassword())
.build())
.orElseThrow(() -> new UsernameNotFoundException("no encontrado"));
}
private void fallosDeLogin(HttpServletRequest req, HttpServletResponse res,
AuthenticationException ex) throws IOException {
log.warn("login_fallido usuario={} ip={} motivo={}",
req.getParameter("username"), req.getRemoteAddr(), ex.getClass().getSimpleName());
res.sendRedirect("/login?error"); // al usuario, un mensaje genérico
}
}
requestMatchers("/**").permitAll() seguido de
requestMatchers("/admin/**").hasRole("ADMIN") deja el panel de administración
abierto a todo el mundo, porque la primera regla ya encajó. Igual de grave:
requestMatchers("/api/**").permitAll() “mientras desarrollamos”. Regla mnemotécnica:
de lo más específico a lo más general, y anyRequest().authenticated() siempre al final.
Y escribe un test que lo demuestre.
3.4 Varias cadenas de filtros con securityMatcher
Es el caso real de casi cualquier aplicación: una API stateless con JWT y una parte web con
formulario de login, cada una con reglas incompatibles (CSRF sí/no, sesión sí/no). No se resuelve con
if: se declaran dos beans ordenados.
@Configuration
@EnableWebSecurity
public class ConfiguracionMultiple {
@Bean
@Order(1) // se evalúa PRIMERO
SecurityFilterChain api(HttpSecurity http) throws Exception {
return http
.securityMatcher("/api/**") // esta cadena solo atiende /api/**
.csrf(AbstractHttpConfigurer::disable) // API con Bearer: no aplica (ver 3.7)
.cors(cors -> cors.configurationSource(fuenteCors()))
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.OPTIONS, "/api/**").permitAll() // preflight
.requestMatchers("/api/publico/**").permitAll()
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(Customizer.withDefaults()))
.exceptionHandling(ex -> ex
.authenticationEntryPoint(new BearerTokenAuthenticationEntryPoint()) // 401 + WWW-Authenticate
.accessDeniedHandler(new BearerTokenAccessDeniedHandler())) // 403 correcto
.build();
}
@Bean
@Order(2)
SecurityFilterChain actuadorInterno(HttpSecurity http) throws Exception {
return http
.securityMatcher(EndpointRequest.toAnyEndpoint()) // requiere spring-boot-actuator
.authorizeHttpRequests(auth -> auth
.requestMatchers(EndpointRequest.to("health", "info")).permitAll()
.anyRequest().hasRole("OPERADOR"))
.httpBasic(Customizer.withDefaults())
.csrf(AbstractHttpConfigurer::disable)
.build();
}
@Bean
@Order(3) // cajón de sastre: TODO lo demás
SecurityFilterChain web(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/registro", "/css/**").permitAll()
.anyRequest().authenticated())
.formLogin(Customizer.withDefaults())
.build(); // CSRF y sesión ACTIVADOS: es lo correcto aquí
}
}
WebSecurityCustomizer con
ignoring() salvo que sepas exactamente lo que haces: esas rutas quedan
completamente fuera de Spring Security, sin cabeceras de seguridad y sin posibilidad de aplicar
nada después. Es más seguro un permitAll() dentro de la cadena.
3.5 Autorización por método: la barrera que de verdad protege
La autorización por URL es necesaria pero insuficiente. Es frágil (una ruta nueva, un
@RequestMapping refactorizado, una barra final, un alias) y no protege los caminos que no
pasan por HTTP: un consumidor de Kafka, una tarea programada, un resolver de GraphQL o una
llamada interna entre servicios. La autorización en la capa de servicio viaja con el
código, no con la ruta.
@Service
@Transactional
public class ServicioPedidos {
// Roles y authorities
@PreAuthorize("hasRole('ADMIN')")
public void cancelarTodos(LocalDate dia) { … }
@PreAuthorize("hasAnyAuthority('pedidos:leer', 'pedidos:administrar')")
public List<PedidoDto> listar(Pageable pagina) { … }
// SpEL con los ARGUMENTOS del método (#nombre requiere -parameters al compilar,
// que Spring Boot ya activa; si no, usa #p0 / @P("id"))
@PreAuthorize("#nif == authentication.name or hasRole('SOPORTE')")
public ClienteDto porNif(String nif) { … }
// Con un bean propio: la forma limpia de expresar reglas de dominio
@PreAuthorize("@guardiaPedidos.puedeVer(#id, authentication)")
public PedidoDto ver(Long id) { … }
// POSTautorización: la decisión depende del OBJETO DEVUELTO.
// ⚠️ El método YA SE EJECUTÓ: si tenía efectos secundarios, ya ocurrieron.
// Con @Transactional se hace rollback, pero un email enviado no vuelve.
@PostAuthorize("returnObject.clienteNif == authentication.name or hasRole('SOPORTE')")
public PedidoDto detalle(Long id) { … }
// Filtrar colecciones
@PostFilter("filterObject.clienteNif == authentication.name") // filtra lo DEVUELTO
public List<PedidoDto> misPedidos() { … }
@PreFilter("filterObject.importe <= 1000 or hasRole('ADMIN')") // filtra el ARGUMENTO
public void importar(List<LineaImportacion> lineas) { … }
}
// El bean de reglas: lógica de negocio testeable, no SpEL kilométrico
@Component("guardiaPedidos")
public class GuardiaPedidos {
private final RepositorioPedidos repositorio;
public boolean puedeVer(Long pedidoId, Authentication auth) {
if (tieneRol(auth, "ROLE_SOPORTE")) return true;
return repositorio.existePorIdYClienteNif(pedidoId, auth.getName()); // 1 consulta, sin cargar todo
}
private static boolean tieneRol(Authentication auth, String rol) {
return auth.getAuthorities().stream().anyMatch(a -> rol.equals(a.getAuthority()));
}
}
@PreAuthorize se aplica con un proxy
AOP. Por tanto no funciona en métodos private, final o
static, ni en llamadas internas (this.otroMetodo()), porque no
pasan por el proxy. Si necesitas asegurar un método interno, extráelo a otro bean. Con
@EnableMethodSecurity puedes activar @AuthorizeReturnObject y proxies AspectJ,
pero lo pragmático es diseñar los servicios para que la frontera pública sea la que se protege.
3.6 Autorización de dominio: “¿puede ver ESTE pedido?”
Esta es la pregunta que distingue una aplicación segura de una vulnerable, y la que Spring Security no puede responder por ti: solo tú sabes qué significa “ser dueño” en tu negocio. Y hay una forma correcta y una incorrecta de implementarla.
// ❌ VULNERABLE (IDOR): autenticado ≠ autorizado. Cambiando el id, ves pedidos ajenos.
@GetMapping("/pedidos/{id}")
public PedidoDto ver(@PathVariable Long id) {
return mapper.aDto(repositorio.findById(id).orElseThrow());
}
// ⚠️ MEJOR, PERO FRÁGIL: comprueba después de cargar. Funciona, pero (a) se olvida en el
// siguiente endpoint, (b) carga datos que el usuario no puede ver, (c) el 403 confirma que existe.
@GetMapping("/pedidos/{id}")
public PedidoDto ver(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
Pedido p = repositorio.findById(id).orElseThrow(PedidoNoEncontrado::new);
if (!p.getClienteNif().equals(jwt.getSubject())) throw new AccessDeniedException("no es tuyo");
return mapper.aDto(p);
}
// ✅ CORRECTO: la propiedad forma parte de la CONSULTA. No se puede olvidar,
// no carga datos ajenos y devuelve 404 en lugar de 403 (no confirma la existencia).
public interface RepositorioPedidos extends JpaRepository<Pedido, Long> {
Optional<Pedido> findByIdAndClienteNif(Long id, String nif);
Page<Pedido> findAllByClienteNif(String nif, Pageable pagina);
}
@GetMapping("/pedidos/{id}")
public PedidoDto ver(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
return repositorio.findByIdAndClienteNif(id, jwt.getSubject())
.map(mapper::aDto)
.orElseThrow(PedidoNoEncontrado::new); // → 404, mismo resultado exista o no
}
// ✅ Y cuando las reglas son ricas (roles + propiedad + delegación + estado), un AuthorizationManager
@Component
public class PedidoAuthorizationManager
implements AuthorizationManager<RequestAuthorizationContext> {
private final RepositorioPedidos repositorio;
@Override
public AuthorizationDecision check(Supplier<Authentication> authentication,
RequestAuthorizationContext contexto) {
Authentication auth = authentication.get();
String id = contexto.getVariables().get("id"); // variable de la plantilla de ruta
if (id == null || auth == null || !auth.isAuthenticated()) {
return new AuthorizationDecision(false); // fallar de forma segura
}
boolean permitido = repositorio.puedeAcceder(Long.valueOf(id), auth.getName())
|| auth.getAuthorities().stream()
.anyMatch(a -> "ROLE_SOPORTE".equals(a.getAuthority()));
return new AuthorizationDecision(permitido);
}
}
// Enganchado en la cadena con la variable de ruta disponible:
.authorizeHttpRequests(auth -> auth
.requestMatchers("/pedidos/{id}").access(pedidoAuthorizationManager)
.anyRequest().authenticated())
Cuando los permisos son por instancia y variables en el tiempo (“Ana puede editar la
carpeta 42; Luis solo leerla”), lo que necesitas es una ACL: una tabla
(tipo_recurso, id_recurso, sujeto, permiso) consultada en la propia query, o el módulo
spring-security-acl. Antes de adoptarlo, mide: en el 90% de los casos basta una columna de
propietario y un par de roles, y una ACL mal diseñada es una fuente inagotable de consultas N+1.
3.7 CSRF: qué es y cuándo hace falta de verdad
CSRF (Cross-Site Request Forgery) explota que el navegador adjunta las cookies de un dominio automáticamente, sin importar quién originó la petición. Si tu sesión vive en una cookie, una página maliciosa puede provocar acciones en tu nombre sin leer nada de tu sitio.
ANATOMÍA DE UN ATAQUE CSRF
1. Ana inicia sesión en banco.com → el navegador guarda la cookie JSESSIONID.
2. Ana visita, en otra pestaña, un foro donde alguien ha publicado esto:
<form action="https://banco.com/transferencia" method="POST">
<input type="hidden" name="destino" value="ES00-atacante">
<input type="hidden" name="importe" value="5000">
</form>
<script>document.forms[0].submit()</script>
3. El navegador envía el POST a banco.com Y ADJUNTA LA COOKIE de Ana.
4. banco.com ve una sesión válida → ejecuta la transferencia.
El atacante NUNCA lee la respuesta (la política del mismo origen se lo impide).
No necesita leerla: le basta con que el EFECTO ocurra.
DEFENSA (token sincronizador): el servidor exige, en cada petición que cambia estado,
un valor secreto que SOLO puede conocer quien haya podido LEER una página del sitio.
El atacante puede provocar la petición, pero no puede leer nada → no tiene el token.
| Cómo transporta el cliente la credencial | ¿Hace falta CSRF? | Por qué |
|---|---|---|
| Cookie de sesión (aplicación web clásica o SPA con cookie) | SÍ, obligatorio | El navegador la envía sola en peticiones entre sitios. |
Authorization: Bearer <jwt> en una cabecera puesta por JavaScript | NO | El navegador nunca añade esa cabecera por su cuenta; el atacante no puede fabricarla desde otro origen. |
| JWT guardado en una cookie | SÍ | Es una cookie: se comporta como una cookie. El “JWT no necesita CSRF” solo aplica a la cabecera. |
| HTTP Basic con navegador | SÍ | El navegador reenvía las credenciales automáticamente igual que una cookie. |
| Cliente servidor-a-servidor (sin navegador) | NO | No hay un navegador que adjunte credenciales ni un usuario al que engañar. |
// ✅ Web con sesión: CSRF ACTIVADO (es el valor por defecto: no lo toques).
// Thymeleaf inserta el campo _csrf automáticamente en los <form th:action>.
// ✅ SPA que se autentica con cookie y lee el token del DOM/cookie:
http.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) // cookie XSRF-TOKEN legible
.csrfTokenRequestHandler(new SpaCsrfTokenRequestHandler()));
// El cliente lee la cookie XSRF-TOKEN y la reenvía en la cabecera X-XSRF-TOKEN.
// ⚠️ Spring Security 6 usa por defecto XorCsrfTokenRequestAttributeHandler (protección contra
// BREACH) y carga el token de forma diferida. Para SPAs, la documentación oficial describe
// exactamente este SpaCsrfTokenRequestHandler; si copias una receta de SS5 verás 403 fantasma.
// ✅ API stateless con Bearer: desactivar CSRF es CORRECTO y no es una excusa.
http.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS));
// ⚠️ Si desactivas CSRF en toda la aplicación "porque daba 403 en Postman" y la parte web usa
// sesión, acabas de introducir una vulnerabilidad real. Sepáralo en dos cadenas (sección 3.4).
SameSite es defensa en profundidad, no sustituto: SameSite=Lax (el
comportamiento por defecto en los navegadores actuales) impide que la cookie viaje en un POST entre
sitios, lo que ya frena el ataque del ejemplo. Aun así, mantén el token CSRF: SameSite no te
protege de un subdominio comprometido (para las cookies, foo.miempresa.com es “mismo sitio”),
ni de clientes antiguos, ni de operaciones que cambian estado por GET (que, por cierto, no
deberías tener: si un GET cambia estado, ese es el bug de fondo).
3.8 CORS: lo que casi todo el mundo configura mal
Primero, el malentendido de base: CORS no es una protección de tu servidor, es una
relajación controlada de la política del mismo origen del navegador. Un curl o un
script en Python ignoran CORS por completo. Abrir CORS no “abre” tu API a los atacantes (ya estaba
abierta a herramientas no-navegador); lo que hace es permitir que páginas web de otros orígenes
lean tus respuestas usando las credenciales del usuario.
// ✅ Configuración correcta y explícita
@Bean
CorsConfigurationSource fuenteCors() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of("https://app.miempresa.com",
"https://admin.miempresa.com")); // lista cerrada
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
config.setAllowedHeaders(List.of("Authorization", "Content-Type", "X-XSRF-TOKEN"));
config.setExposedHeaders(List.of("X-Total-Count", "Location")); // lo que el JS puede LEER
config.setAllowCredentials(true); // solo si usas cookies
config.setMaxAge(Duration.ofMinutes(30)); // cachea el preflight
var fuente = new UrlBasedCorsConfigurationSource();
fuente.registerCorsConfiguration("/api/**", config);
return fuente;
}
// Y engánchalo en la cadena (si no, el CorsFilter de Security no se entera):
http.cors(cors -> cors.configurationSource(fuenteCors()));
| Error típico de CORS | Qué pasa | Corrección |
|---|---|---|
allowedOrigins("*") + allowCredentials(true) | Combinación prohibida por la especificación: Spring lanza IllegalArgumentException al arrancar. | Lista explícita de orígenes, o allowedOriginPatterns si necesitas comodines. |
Reflejar el Origin recibido en la respuesta | Equivale a permitir cualquier origen con credenciales: fuga total de datos del usuario a cualquier web. | Comparar contra una lista blanca antes de responder. |
allowedOriginPatterns("https://*.miempresa.com") sin pensar | Si cualquiera puede crear un subdominio (hosting de clientes, S3, GitHub Pages corporativo), le has dado acceso. | Enumerar los orígenes reales. |
CORS en @CrossOrigin del controlador y también en la cadena | Configuraciones que se contradicen y un preflight que a veces falla. | Un único punto: el CorsConfigurationSource. |
El preflight OPTIONS devuelve 401 | El navegador ni siquiera intenta la petición real; en la consola se ve como “error de CORS”. | El CorsFilter va antes del AuthorizationFilter si usas http.cors(...); si no, permitAll() explícito para OPTIONS. |
3.9 Cabeceras de seguridad, sesión y errores
| Cabecera | Contra qué protege | Valor recomendado |
|---|---|---|
Content-Security-Policy | XSS: define de dónde puede cargarse y ejecutarse código. | default-src 'self'; script-src 'self' 'nonce-…'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'. Evita unsafe-inline y unsafe-eval. |
X-Frame-Options / frame-ancestors | Clickjacking: tu página embebida en un iframe invisible. | DENY (Spring lo pone por defecto). frame-ancestors de CSP es el sustituto moderno y tiene prioridad. |
X-Content-Type-Options | MIME sniffing: que el navegador ejecute como script algo que subiste como imagen. | nosniff (por defecto en Spring Security). |
Referrer-Policy | Fuga de URLs con tokens o identificadores en el Referer. | strict-origin-when-cross-origin o no-referrer. |
Strict-Transport-Security | Degradación a HTTP y stripping de TLS. | max-age=31536000; includeSubDomains (y preload si estás seguro). |
Cache-Control | Datos privados cacheados en un proxy o en el disco del navegador. | no-store en respuestas con datos personales (Spring Security lo pone agresivo por defecto). |
Permissions-Policy | Acceso innecesario a cámara, micrófono, geolocalización. | geolocation=(), camera=(), microphone=() |
X-XSS-Protection: Spring Security 6 la envía como 0, es decir,
desactiva el filtro anti-XSS heredado de los navegadores. Es lo correcto: ese filtro
llegó a introducir vulnerabilidades propias y todos los navegadores modernos lo han retirado. La
protección real hoy es CSP más escapado correcto en la plantilla.
Gestión de sesión
FIJACIÓN DE SESIÓN (session fixation) — por qué Spring cambia el id al autenticarse
1. El atacante obtiene un JSESSIONID válido (basta con visitar el sitio) → "SID=ABC".
2. Consigue que la víctima use ESE id (enlace con el id en la URL, cookie inyectada
desde un subdominio, XSS…).
3. La víctima se autentica. Si el servidor CONSERVA el id ABC, ahora ABC está
asociado a una sesión autenticada… y el atacante también tiene ABC.
DEFENSA: al autenticarse, se genera un id NUEVO (changeSessionId, por defecto en
Spring Security desde la versión 4 sobre Servlet 3.1+). El id antiguo queda inútil.
Complementos: nunca el id en la URL (DisableEncodeUrlFilter), cookie HttpOnly+Secure.
# Sesión en Redis: obligatorio si escalas horizontalmente sin sesiones "pegajosas".
# Dependencia: spring-session-data-redis
spring:
session:
store-type: redis
timeout: 30m
redis:
namespace: pedidos:sesion
flush-mode: on-save
data:
redis:
host: ${REDIS_HOST}
ssl:
enabled: true
server:
servlet:
session:
cookie:
name: SESION # renombrar oculta la tecnología (mínima ofuscación, gratis)
http-only: true
secure: true
same-site: lax
Con sesión centralizada, el logout es real e inmediato (borras la entrada de Redis) y puedes invalidar todas las sesiones de un usuario tras un cambio de contraseña o un incidente. Es, de hecho, la principal ventaja de las sesiones frente al JWT.
Excepciones y respuestas de error que no filtran información
// ✅ 401 para API: sin redirección a /login, con la cabecera correcta y sin detalles
@Component
public class PuntoEntradaApi implements AuthenticationEntryPoint {
private final ObjectMapper mapper;
@Override
public void commence(HttpServletRequest req, HttpServletResponse res,
AuthenticationException ex) throws IOException {
// AUDITA el detalle en el log (con traceId), NO en la respuesta
log.warn("auth_fallida ruta={} ip={} tipo={}", req.getRequestURI(),
req.getRemoteAddr(), ex.getClass().getSimpleName());
res.setStatus(HttpStatus.UNAUTHORIZED.value());
res.setHeader(HttpHeaders.WWW_AUTHENTICATE, "Bearer realm=\"pedidos\"");
res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
var problema = ProblemDetail.forStatusAndDetail(HttpStatus.UNAUTHORIZED,
"Credenciales ausentes o no válidas"); // mensaje GENÉRICO, RFC 9457
problema.setProperty("traceId", MDC.get("traceId"));
mapper.writeValue(res.getOutputStream(), problema);
}
}
// ✅ 403 con el mismo criterio
@Component
public class ManejadorDenegado implements AccessDeniedHandler {
@Override
public void handle(HttpServletRequest req, HttpServletResponse res,
AccessDeniedException ex) throws IOException {
log.warn("acceso_denegado usuario={} ruta={} ",
SecurityContextHolder.getContext().getAuthentication().getName(),
req.getRequestURI());
res.setStatus(HttpStatus.FORBIDDEN.value());
res.setContentType(MediaType.APPLICATION_PROBLEM_JSON_VALUE);
res.getWriter().write("""
{"type":"about:blank","title":"Forbidden","status":403,
"detail":"No tienes permiso para esta operación"}""");
}
}
// ❌ Lo que NUNCA debe salir al cliente
// {"error":"org.postgresql.util.PSQLException: ERROR: relation \\"pedido\\" does not exist
// ... at com.miempresa.PedidoRepository.java:87 ... Hibernate 6.4.1 ... PostgreSQL 15.2"}
// → versiones, esquema, rutas internas y un mapa de tu arquitectura, gratis para el atacante.
3.10 Probar la seguridad (si no hay test, no existe)
// Dependencia: org.springframework.security:spring-security-test (scope test)
// Imports estáticos clave:
// SecurityMockMvcRequestPostProcessors.{csrf, jwt, user, httpBasic, oauth2Login}
// SecurityMockMvcResultMatchers.{authenticated, unauthenticated}
@WebMvcTest(PedidoController.class)
@Import(ConfiguracionSeguridad.class) // sin esto, la cadena real no se aplica
class PedidoControllerSeguridadTest {
@Autowired MockMvc mvc;
@MockitoBean ServicioPedidos servicio;
@Test
void anonimo_recibe_401() throws Exception {
mvc.perform(get("/api/pedidos/1"))
.andExpect(status().isUnauthorized());
}
@Test
@WithMockUser(username = "ana", roles = "USER") // crea el Authentication en el contexto
void usuario_sin_permiso_recibe_403() throws Exception {
mvc.perform(delete("/api/pedidos/1").with(csrf())) // .with(csrf()) si la cadena lo exige
.andExpect(status().isForbidden());
}
@Test
void resource_server_valida_scopes() throws Exception {
mvc.perform(get("/api/pedidos")
.with(jwt().jwt(j -> j.claim("scope", "pedidos.read")
.subject("12345678A")
.claim("roles", List.of("USER")))))
.andExpect(status().isOk());
mvc.perform(post("/api/pedidos")
.with(jwt().jwt(j -> j.claim("scope", "pedidos.read"))) // le falta pedidos.write
.contentType(MediaType.APPLICATION_JSON).content("{}"))
.andExpect(status().isForbidden());
}
@Test
@WithMockUser("ana")
void no_puede_ver_pedidos_de_otro() throws Exception { // el test que evita el IDOR
given(servicio.ver(99L)).willThrow(new PedidoNoEncontrado(99L));
mvc.perform(get("/api/pedidos/99"))
.andExpect(status().isNotFound()); // 404, no 403: no confirma existencia
}
@Test
void cabeceras_de_seguridad_presentes() throws Exception {
mvc.perform(get("/api/publico/ping"))
.andExpect(header().string("X-Content-Type-Options", "nosniff"))
.andExpect(header().exists("Content-Security-Policy"));
}
}
// Para la capa de servicio (@PreAuthorize), test de integración con el contexto real:
@SpringBootTest
class ServicioPedidosSeguridadTest {
@Autowired ServicioPedidos servicio;
@Test
@WithMockUser(roles = "USER")
void cancelarTodos_exige_admin() {
assertThatThrownBy(() -> servicio.cancelarTodos(LocalDate.now()))
.isInstanceOf(AccessDeniedException.class);
}
}
Checklist — Spring Security 6
4. Autenticación con tokens: sesión, JWT y refresh
4.1 Sesión con cookie vs JWT: la tabla honesta
HttpOnly+Secure+SameSite es la opción más segura y más
sencilla. Elegir JWT “porque es moderno” es el origen de la mitad de los incidentes de esta sección.
| Criterio | Sesión con cookie (estado en servidor) | JWT autocontenido (sin estado) |
|---|---|---|
| Dónde está el estado | En el servidor (memoria, Redis, BD). La cookie solo lleva un identificador opaco. | En el propio token, en el cliente. El servidor no guarda nada. |
| Revocación inmediata | Trivial: borras la sesión y el usuario está fuera. | Imposible por diseño. El token vale hasta su exp. Requiere infraestructura extra. |
| Cambio de permisos | Inmediato en la siguiente petición. | Solo cuando se emita un token nuevo: hay una ventana en la que el usuario sigue con los permisos viejos. |
| Escalado horizontal | Necesita almacén compartido (Redis) o sesiones pegajosas. | Cualquier instancia valida el token sin estado compartido. |
| Coste por petición | Una lectura de Redis (~0,3 ms) o de BD. | Una verificación de firma (HMAC ~µs; RSA algo más). La clave pública se cachea. |
| Tamaño en cada petición | ~30 bytes. | 500–2000 bytes. Con muchos claims y roles se puede rozar el límite de cabeceras del proxy. |
| Vulnerable a CSRF | Sí (es una cookie) → token CSRF obligatorio. | No, si viaja en la cabecera Authorization. Sí, si lo guardas en una cookie. |
| Vulnerable a XSS | La cookie HttpOnly no es legible por JavaScript. El XSS sigue siendo grave, pero no se roba el identificador. | Si está en localStorage, cualquier XSS lo exfiltra. |
| Cross-domain / móvil / servicio a servicio | Incómodo (cookies, dominios, SameSite). | Natural: es una cabecera HTTP. |
| Cuándo elegirlo | Monolito o BFF con navegador; requisitos de revocación inmediata; equipo pequeño. | APIs para móvil y terceros, microservicios, federación con un authorization server, OAuth2/OIDC. |
HttpOnly (inmune a robo por XSS, revocable al instante) y es el BFF quien guarda los tokens
OAuth2 y los usa contra las APIs internas. Te quedas con lo mejor de ambos mundos y ningún token toca el
JavaScript. Spring Security lo soporta de forma directa con oauth2Login() y el
OAuth2AuthorizedClientManager.
4.2 Anatomía de un JWT
Un JWS compacto son TRES bloques en Base64URL separados por puntos:
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCIsImtpZCI6IjIwMjYtMDEifQ ← header
.eyJpc3MiOiJodHRwczovL2F1dGgubWllbXByZXNhLmNvbSIsInN1YiI6… ← payload (claims)
.Kx7fQ9mB2rT8vN1wLpZ3sYcH0dJ4gE6uA5iO7nQ2xR8 ← firma
┌─ HEADER ────────────────────────────────────────────────────────────────────┐
│ { "alg": "RS256", ← algoritmo de firma │
│ "typ": "JWT", │
│ "kid": "2026-01" } ← qué clave del JWKS usar (permite rotar sin cortes) │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ PAYLOAD ───────────────────────────────────────────────────────────────────┐
│ { "iss": "https://auth.miempresa.com", ← emisor: ¿es MI emisor? │
│ "sub": "12345678A", ← sujeto: el usuario │
│ "aud": ["pedidos-api"], ← audiencia: ¿este token es PARA MÍ?│
│ "exp": 1774951200, ← expira (epoch en SEGUNDOS) │
│ "iat": 1774950300, ← emitido en │
│ "nbf": 1774950300, ← no válido antes de │
│ "jti": "b3f1…", ← id único: permite revocar ESTE │
│ "scope": "pedidos.read pedidos.write", ← permisos delegados (OAuth2) │
│ "roles": ["USER"] } ← claim propio │
└─────────────────────────────────────────────────────────────────────────────┘
┌─ FIRMA ─────────────────────────────────────────────────────────────────────┐
│ RSASSA-PKCS1-v1_5( SHA256( base64url(header) + "." + base64url(payload) ) ) │
└─────────────────────────────────────────────────────────────────────────────┘
⚠️ EL PAYLOAD NO ESTÁ CIFRADO. Base64 NO es cifrado: cualquiera con el token lee
todo su contenido (pruébalo con `echo … | base64 -d`, o en jwt.io con un token
de PRUEBA, nunca con uno real). La firma garantiza INTEGRIDAD, no confidencialidad.
→ No metas nunca datos personales innecesarios, direcciones, DNI o secretos.
→ Si de verdad necesitas confidencialidad del contenido, existe JWE (cifrado),
pero suele ser más simple usar un token OPACO y consultar /introspect.
| Algoritmo | Tipo | Quién puede firmar | Cuándo usarlo |
|---|---|---|---|
| HS256 (HMAC-SHA256) | Simétrico | Todo el que pueda verificar (misma clave). | Solo si emisor y verificador son el mismo componente. Con varios servicios, todos podrían falsificar tokens: mal. |
| RS256 (RSA-SHA256) | Asimétrico | Solo el emisor, con su clave privada. | El estándar de facto en OIDC. Los resource servers solo necesitan la pública (JWKS). |
| ES256 (ECDSA P-256) | Asimétrico | Solo el emisor. | Igual que RS256 pero con tokens y claves más pequeños. Buena elección moderna. |
none | Ninguno | Cualquiera. | Nunca. Existe en la especificación y es el origen de una clase entera de vulnerabilidades. |
4.3 Validación correcta (aquí es donde se cometen los fallos)
“Verifico la firma” es menos de la mitad del trabajo. Estas son las comprobaciones obligatorias y los ataques que evita cada una:
| Comprobación | Ataque que evita | Detalle |
|---|---|---|
| Firma válida con la clave correcta | Token fabricado por el atacante. | Obtén la clave del jwks_uri del emisor, selecciónala por kid y cachéala con TTL. |
| Algoritmo esperado | alg confusion y alg: none. | El clásico: el atacante cambia alg de RS256 a HS256 y firma con la clave pública (que es… pública) esperando que la librería la use como secreto HMAC. Fija la lista de algoritmos aceptados; no la leas del token. |
iss exacto | Token emitido por otro emisor (¡el atacante puede montar su propio Keycloak!). | Comparación exacta de cadena con tu issuer configurado. |
aud contiene tu API | Reutilización de un token legítimo emitido para otro servicio (token passing). | El fallo más común y más grave: si el servicio A acepta tokens destinados al servicio B, cualquier usuario de B puede llamar a A. |
exp y nbf | Tokens caducados o aún no válidos. | Con un margen de reloj pequeño (30–60 s). Sincroniza los relojes (NTP): un contenedor desfasado provoca 401 aleatorios. |
kid conocido | Key injection: jku/jwk en el header apuntando a una clave del atacante. | Nunca confíes en URLs de clave que vengan dentro del token. Solo el JWKS del emisor configurado. |
| Claims mínimos presentes | Tokens degenerados (sin sub, sin scope). | Rechaza lo que no encaje con tu contrato; no asumas valores por defecto. |
# ✅ La forma correcta y aburrida: que lo haga Spring Boot por ti.
spring:
security:
oauth2:
resourceserver:
jwt:
# Descubre jwks_uri vía /.well-known/openid-configuration y valida iss, exp, nbf y firma
issuer-uri: https://auth.miempresa.com/realms/pedidos
audiences: pedidos-api # ← Spring Boot 3.1+: valida aud. NO LO OLVIDES.
# authorization-server-metadata (alternativa) o jwk-set-uri si no hay discovery
// Cuando necesitas control fino (validadores adicionales, claves propias, algoritmo fijo)
@Bean
JwtDecoder jwtDecoder(@Value("${jwt.issuer}") String emisor,
@Value("${jwt.jwks-uri}") String jwksUri) {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri(jwksUri)
.jwsAlgorithms(algs -> { algs.clear(); algs.add(SignatureAlgorithm.RS256); }) // lista blanca
.build();
OAuth2TokenValidator<Jwt> porDefecto = JwtValidators.createDefaultWithIssuer(emisor); // exp, nbf, iss
OAuth2TokenValidator<Jwt> audiencia = new JwtClaimValidator<List<String>>(
JwtClaimNames.AUD, aud -> aud != null && aud.contains("pedidos-api"));
OAuth2TokenValidator<Jwt> noRevocado = jwt -> listaRevocacion.contiene(jwt.getId())
? OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_token", "Token revocado", null))
: OAuth2TokenValidatorResult.success();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
porDefecto, audiencia, noRevocado));
return decoder;
}
// ❌ Lo que se ve en tutoriales y NO debes copiar
// 1) Decodificar sin verificar, "solo para leer el usuario":
String sub = new String(Base64.getUrlDecoder().decode(token.split("\\.")[1])); // ¡sin firma!
// 2) Aceptar el algoritmo que diga el token (API antigua sin fijar algoritmo).
// 3) Escribir tu propio validador de JWT "porque es fácil": no lo es. Usa Nimbus/Spring.
4.4 El problema de la revocación y sus tres soluciones
Un JWT es una afirmación firmada con fecha de caducidad. Nadie puede “desfirmarla”. Si
despides a alguien, si un token se filtra o si un usuario cambia su contraseña, el token sigue siendo
matemáticamente válido hasta su exp. Las soluciones reales:
- Access tokens de vida muy corta + refresh token. El access token vive 5–15 minutos y no se comprueba contra ningún estado; el refresh token vive días, es opaco y se valida contra la base de datos del emisor, donde sí puedes revocarlo. La ventana de exposición es igual a la vida del access token. Es la solución por defecto.
-
Lista de revocación (denylist) por
jti. Guardas en Redis los identificadores revocados con TTL igual a suexprestante. Recuperas la revocación inmediata a cambio de una consulta por petición: has reintroducido el estado que querías evitar, pero solo para los casos excepcionales (la lista es pequeña). -
Versión de credenciales. El token incluye
cver: 7; el usuario tiene una columnacredential_version. Al cambiar la contraseña o revocar accesos, incrementas la versión y todos los tokens anteriores dejan de validar. Es barato (una consulta cacheable) y muy efectivo para el caso “invalidar todas las sesiones de este usuario”.
4.5 ¿Dónde guardo el token en el cliente?
| Ubicación | Riesgo XSS | Riesgo CSRF | Veredicto |
|---|---|---|---|
localStorage / sessionStorage |
Total. Cualquier script en la página lo lee y lo puede enviar a un servidor del atacante. Y basta con una dependencia npm comprometida. | Ninguno (el navegador no lo envía solo). | Evítalo para tokens de sesión. Es la opción cómoda de los tutoriales y la causa de muchos incidentes reales. |
| Variable en memoria de JavaScript | Alto, pero el token muere al recargar y no queda en disco. | Ninguno. | Aceptable para el access token de corta vida, con el refresh en cookie HttpOnly. |
Cookie HttpOnly + Secure + SameSite |
No es legible por JavaScript. Un XSS puede seguir haciendo peticiones en tu nombre desde tu navegador, pero no puede exfiltrar la credencial para usarla desde otro sitio y de forma persistente. | Sí → token CSRF obligatorio. | La mejor opción para aplicaciones web. Es el patrón BFF. |
| Almacén seguro del sistema (Keychain / Keystore / EncryptedSharedPreferences) | Bajo, aislado por aplicación. | No aplica. | Lo correcto en móvil nativo. Nunca en un fichero de preferencias en claro. |
HttpOnly no anula el XSS. Con un XSS activo, el
atacante ejecuta peticiones autenticadas desde el navegador de la víctima. Lo que sí consigue
HttpOnly es que no pueda robarse la credencial para usarla desde su máquina,
durante días y sin que la víctima esté conectada. Eso reduce enormemente el impacto. La defensa
real contra XSS es escapar salidas y una CSP estricta; el almacenamiento del token es defensa en
profundidad.
4.6 Refresh tokens y detección de reutilización
ROTACIÓN DE REFRESH TOKENS CON DETECCIÓN DE REUSO (reuse detection)
Cliente Authorization Server
│ │
│─ POST /token (grant=refresh, RT1) ────────────►│ RT1 válido → lo MARCA COMO USADO
│◄─ { access_token: AT2, refresh_token: RT2 } ───│ y emite el par (AT2, RT2)
│ │ familia: RT1 → RT2 (misma cadena)
│─ POST /token (grant=refresh, RT2) ────────────►│ ok → RT3
│ │
│ ⚠️ ALGUIEN usa RT1 otra vez (lo había robado) │
│─ POST /token (grant=refresh, RT1) ────────────►│ RT1 YA ESTABA USADO
│◄─ 400 invalid_grant ──────────────────────────│ → REVOCA TODA LA FAMILIA
│ │ (RT1, RT2, RT3…) y audita
│ Resultado: tanto el atacante como el │
│ usuario legítimo tienen que reautenticarse. │
│ Molesto, pero has DETECTADO y CORTADO el robo.│
Sin rotación, un refresh token robado da acceso silencioso durante semanas.
Con rotación + detección de reuso, el segundo uso delata el robo.
Requisitos: almacenar los RT (su hash) con estado usado/no usado y su familia.
Keycloak, Spring Authorization Server, Auth0 y Okta lo implementan de serie.
4.7 Emitir y validar JWT en Spring Boot 3
// Dependencia: spring-boot-starter-oauth2-resource-server (trae Nimbus JOSE + JWT)
@Configuration
public class ConfiguracionJwt {
/** Par de claves RSA. En producción: cargado de un KMS o de un keystore montado, NUNCA generado al
* arrancar (si se genera al arrancar, cada réplica firma con una clave distinta y cada reinicio
* invalida todos los tokens emitidos). */
@Bean
RSAKey claveFirma(@Value("${jwt.key-id}") String kid,
KeyStore keyStore,
@Value("${jwt.key-alias}") String alias,
@Value("${jwt.key-password}") char[] password) throws Exception {
var privada = (RSAPrivateKey) keyStore.getKey(alias, password);
var certificado = (X509Certificate) keyStore.getCertificate(alias);
return new RSAKey.Builder((RSAPublicKey) certificado.getPublicKey())
.privateKey(privada)
.keyID(kid) // aparecerá como "kid" en el header y en el JWKS
.algorithm(JWSAlgorithm.RS256)
.build();
}
@Bean
JWKSource<SecurityContext> jwkSource(RSAKey clave) {
// Publica SOLO la parte pública en /oauth2/jwks para que otros servicios validen
return new ImmutableJWKSet<>(new JWKSet(clave));
}
@Bean
JwtEncoder jwtEncoder(JWKSource<SecurityContext> jwks) {
return new NimbusJwtEncoder(jwks);
}
@Bean
JwtDecoder jwtDecoder(RSAKey clave, @Value("${jwt.issuer}") String emisor) throws Exception {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withPublicKey(clave.toRSAPublicKey())
.signatureAlgorithm(SignatureAlgorithm.RS256) // fijado: sin alg confusion
.build();
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(
JwtValidators.createDefaultWithIssuer(emisor),
new JwtClaimValidator<List<String>>(JwtClaimNames.AUD,
aud -> aud != null && aud.contains("pedidos-api"))));
return decoder;
}
}
@Service
public class EmisorTokens {
private static final Duration VIDA_ACCESS = Duration.ofMinutes(10); // corta: la revocación depende de esto
private static final Duration VIDA_REFRESH = Duration.ofDays(14);
private final JwtEncoder encoder;
private final RSAKey clave;
private final RepositorioRefreshTokens refrescos;
public ParTokens emitir(UsuarioAplicacion usuario) {
Instant ahora = Instant.now();
JwtClaimsSet claims = JwtClaimsSet.builder()
.issuer("https://auth.miempresa.com")
.subject(usuario.getUsername())
.audience(List.of("pedidos-api"))
.issuedAt(ahora)
.notBefore(ahora)
.expiresAt(ahora.plus(VIDA_ACCESS))
.id(UUID.randomUUID().toString()) // jti: permite revocar ESTE token
.claim("roles", usuario.getAuthorities().stream()
.map(GrantedAuthority::getAuthority).toList())
.claim("cver", usuario.getVersionCredenciales()) // versión de credenciales
.build();
JwsHeader header = JwsHeader.with(SignatureAlgorithm.RS256)
.keyId(clave.getKeyID()) // para que el verificador elija la clave
.build();
String accessToken = encoder.encode(JwtEncoderParameters.from(header, claims)).getTokenValue();
// El refresh token NO es un JWT: es un valor opaco de alta entropía, guardado HASHEADO.
byte[] bytes = new byte[32];
new SecureRandom().nextBytes(bytes);
String refresh = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
refrescos.guardar(new RefreshToken(
sha256(refresh), // guardamos el HASH, no el token
usuario.getUsername(),
ahora.plus(VIDA_REFRESH),
UUID.randomUUID())); // id de familia, para la detección de reuso
return new ParTokens(accessToken, refresh, VIDA_ACCESS.toSeconds());
}
}
Resource Server: convertir claims en GrantedAuthority
// Por defecto Spring toma el claim "scope" (o "scp") y prefija las authorities con SCOPE_
// scope: "pedidos.read" → authority "SCOPE_pedidos.read" → hasAuthority("SCOPE_pedidos.read")
// Si tus roles vienen en otro claim (o anidados, como en Keycloak), necesitas un convertidor.
@Bean
JwtAuthenticationConverter convertidorJwt() {
var scopes = new JwtGrantedAuthoritiesConverter(); // mantiene SCOPE_* a partir de "scope"
JwtAuthenticationConverter convertidor = new JwtAuthenticationConverter();
convertidor.setPrincipalClaimName("sub");
convertidor.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> autoridades = new ArrayList<>(scopes.convert(jwt));
// Roles de Keycloak: { "realm_access": { "roles": ["USER","ADMIN"] } }
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null && realmAccess.get("roles") instanceof Collection<?> roles) {
roles.stream()
.map(String::valueOf)
.map(rol -> new SimpleGrantedAuthority("ROLE_" + rol)) // prefijo para hasRole()
.forEach(autoridades::add);
}
return autoridades;
});
return convertidor;
}
@Bean
SecurityFilterChain apiConversion(HttpSecurity http, JwtAuthenticationConverter convertidor)
throws Exception {
return http
.securityMatcher("/api/**")
.csrf(AbstractHttpConfigurer::disable)
.sessionManagement(s -> s.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(HttpMethod.GET, "/api/pedidos/**").hasAuthority("SCOPE_pedidos.read")
.requestMatchers(HttpMethod.POST, "/api/pedidos").hasAuthority("SCOPE_pedidos.write")
.requestMatchers("/api/admin/**").hasRole("ADMIN") // busca ROLE_ADMIN
.anyRequest().authenticated())
.oauth2ResourceServer(oauth -> oauth.jwt(jwt -> jwt.jwtAuthenticationConverter(convertidor)))
.build();
}
scope=pedidos.write usada por un usuario sin el rol adecuado no debe poder escribir.
Comprueba las dos cosas.
Checklist — tokens
5. OAuth 2 / OAuth 2.1 y OpenID Connect
5.1 El problema que resuelve (y el que no)
Imagina 2008: una web quiere mostrar tus fotos de otro servicio. La única forma era que le dieras tu usuario y tu contraseña. Consecuencias: la web tenía acceso total (podía borrar tu cuenta), guardaba tu contraseña en su base de datos, no podías revocarle el acceso sin cambiar la contraseña y no podías limitar qué hacía. Ese antipatrón tenía nombre propio: el password antipattern.
OAuth 2 es un protocolo de delegación de autorización: permite que una aplicación obtenga acceso limitado y revocable a unos recursos, en nombre del usuario, sin conocer nunca sus credenciales.
id_token con reglas de validación estrictas. OAuth 2 para autorizar, OIDC para autenticar.
5.2 Los cuatro roles
| Rol | Quién es | Ejemplo en nuestra API de pedidos |
|---|---|---|
| Resource Owner | El dueño de los datos: normalmente la persona. | Ana, la clienta. |
| Client | La aplicación que quiere acceder en nombre del dueño. Público si no puede guardar un secreto (SPA, móvil); confidencial si sí (backend). | La SPA de React, la app de iOS, un servicio de facturación. |
| Authorization Server (AS) | Autentica al dueño, pide su consentimiento y emite tokens. Publica su configuración y sus claves. | Keycloak en https://auth.miempresa.com/realms/pedidos. |
| Resource Server (RS) | La API que protege los datos y solo valida tokens. No sabe de contraseñas. | Nuestro pedidos-api de Spring Boot. |
5.3 Authorization Code + PKCE: el flujo correcto para casi todo
AUTHORIZATION CODE + PKCE (RFC 7636) — para SPA, móvil y web con servidor
Antes de empezar, el cliente genera en local:
code_verifier = 43–128 caracteres aleatorios de SecureRandom
code_challenge = BASE64URL( SHA-256( code_verifier ) ) method = S256
Navegador/App Authorization Server (Keycloak) Resource Server (API)
│ │ │
(1) │ GET /authorize? │ │
│ response_type=code │ │
│ client_id=spa-pedidos │ │
│ redirect_uri=https://app.miempresa.com/callback │
│ scope=openid pedidos.read │
│ state=xyz789 │ ← anti-CSRF del propio flujo │
│ nonce=abc123 │ ← liga el id_token a ESTA sesión│
│ code_challenge=E9Me… │ │
│ code_challenge_method=S256 │
│─────────────────────────►│ │
│ │ │
(2) │ El AS autentica al USUARIO (contraseña + MFA) y pide │
│ consentimiento. La contraseña NUNCA pasa por el cliente. │
│ │ │
(3) │◄─ 302 a redirect_uri?code=SplxlOB…&state=xyz789 ───────────│
│ El cliente VERIFICA que state coincide con el que envió. │
│ │ │
(4) │ POST /token │ │
│ grant_type=authorization_code │
│ code=SplxlOB… │ │
│ redirect_uri=… │ ← debe ser IDÉNTICO al de (1) │
│ client_id=spa-pedidos │ │
│ code_verifier=dBjftJ… │ ← el AS calcula SHA-256 y lo │
│─────────────────────────►│ compara con code_challenge │
│ │ │
(5) │◄─ { access_token, id_token, refresh_token, expires_in } ────│
│ (canal directo, POST, no pasa por la barra de direcciones) │
│ │ │
(6) │ GET /api/pedidos │
│ Authorization: Bearer <access_token> │
│────────────────────────────────────────────────────────────►│
│ │◄── GET /.well-known/jwks.json ───│ (una vez, cacheado)
│◄─ 200 [ … ] ───────────────────────────────────────────────│
¿QUÉ APORTA PKCE? El "code" viaja por la barra de direcciones y por el historial. En móvil
puede ser interceptado por otra app registrada en el mismo esquema de URL. PKCE hace que el
code sea INÚTIL sin el code_verifier, que solo existe en la memoria del cliente legítimo.
→ En OAuth 2.1 PKCE es OBLIGATORIO para TODOS los clientes, también los confidenciales.
state vs nonce (pregunta habitual):
· state → protege contra CSRF en el propio flujo: garantiza que la respuesta corresponde
a una petición que TÚ iniciaste. Es de OAuth 2.
· nonce → va dentro del id_token y garantiza que ese id_token se emitió para ESTA
solicitud concreta (anti-replay). Es de OpenID Connect.
5.4 Client Credentials: servicio a servicio
CLIENT CREDENTIALS — no hay usuario, la identidad ES la aplicación
Servicio facturación Authorization Server API pedidos
│ │ │
│─ POST /token ─────────────────────►│ │
│ grant_type=client_credentials │ │
│ client_id=facturacion │ │
│ client_secret=••• (o mTLS, │ │
│ o private_key_jwt: mejor) │ │
│ scope=pedidos.read │ │
│◄─ { access_token, expires_in } ────│ │
│ │ │
│─ GET /api/pedidos Bearer … ──────────────────────────────────►│
│◄─ 200 ───────────────────────────────────────────────────────── │
· No hay redirect_uri, ni consentimiento, ni refresh token (se pide otro y punto).
· El "sub" del token es la APLICACIÓN, no una persona → los logs de auditoría deben
distinguir "actor=servicio:facturacion" de "actor=usuario:12345678A".
· Autenticación del cliente, de peor a mejor: client_secret_post < client_secret_basic
< private_key_jwt < tls_client_auth (mTLS). Con mTLS no hay secreto que filtrar.
· Cachea el token hasta su expiración menos un margen; NO pidas uno por petición.
# Cliente de Client Credentials en Spring Boot (para llamar a otra API)
spring:
security:
oauth2:
client:
registration:
facturacion:
provider: keycloak
client-id: facturacion
client-secret: ${OAUTH_CLIENT_SECRET} # de un secreto de la plataforma
authorization-grant-type: client_credentials
scope: pedidos.read
provider:
keycloak:
issuer-uri: https://auth.miempresa.com/realms/pedidos
// Y el cliente HTTP que adjunta el token automáticamente y lo refresca solo:
@Bean
RestClient clientePedidos(RestClient.Builder builder,
OAuth2AuthorizedClientManager gestor) {
var interceptor = new OAuth2ClientHttpRequestInterceptor(gestor);
interceptor.setClientRegistrationIdResolver(request -> "facturacion");
return builder
.baseUrl("https://pedidos.interno.miempresa.com")
.requestInterceptor(interceptor)
.build();
}
// ❌ Lo que NO debes hacer: propagar el token del USUARIO a otro servicio "porque funciona".
// Ese token tiene aud=pedidos-api; si facturacion-api lo acepta, está saltándose la
// validación de audiencia y cualquiera con un token de pedidos entra en facturación.
// Si necesitas actuar en nombre del usuario, usa Token Exchange (RFC 8693).
5.5 Device Code y Refresh Token
DEVICE AUTHORIZATION GRANT (RFC 8628) — televisores, CLI, IoT: sin teclado o sin navegador
Dispositivo (o CLI) Authorization Server Móvil del usuario
│ │ │
│─ POST /device_authorization ──────►│ │
│◄─ { device_code, user_code: "WDJB-MJHT", │
│ verification_uri: "https://auth…/device", │
│ interval: 5, expires_in: 600 } │ │
│ │ │
│ Muestra en pantalla: │ │
│ "Ve a auth.miempresa.com/device │ │
│ e introduce WDJB-MJHT" │◄── el usuario se autentica ──│
│ │ y teclea el código │
│─ POST /token (device_code) ───────►│ │
│◄─ 400 authorization_pending ───────│ (sondeo cada `interval` s) │
│─ POST /token (device_code) ───────►│ │
│◄─ { access_token, refresh_token } ─│ ¡aprobado! │
Ejemplo cotidiano: `az login`, `gh auth login`, activar Netflix en la tele.
REFRESH TOKEN GRANT — renovar sin volver a molestar al usuario
│─ POST /token grant_type=refresh_token&refresh_token=… ──────────►│
│◄─ { access_token nuevo, refresh_token nuevo (rotación) } ─────────│
· Los clientes PÚBLICOS deben usar rotación con detección de reuso (ver 4.6).
· El refresh token es la credencial de larga vida: trátalo como una contraseña.
5.6 Flujos obsoletos y por qué
| Flujo | Estado | Por qué se retiró | Qué usar |
|---|---|---|---|
Implicit (response_type=token) |
Prohibido en OAuth 2.1 | Devolvía el access token en el fragmento de la URL: queda en el historial, en los logs del proxy, en el Referer, accesible a cualquier script de la página, y sin posibilidad de refresh token seguro. Se creó solo porque los navegadores no tenían CORS. |
Authorization Code + PKCE. |
| Resource Owner Password Credentials (“password grant”) | Prohibido en OAuth 2.1 | La aplicación vuelve a manejar la contraseña del usuario: es exactamente el antipatrón que OAuth vino a eliminar. Además impide MFA, federación, SSO y captcha, y no funciona con proveedores externos. | Authorization Code + PKCE, incluso en móvil (con navegador del sistema, no un WebView). |
| Authorization Code sin PKCE | Desaconsejado | El código interceptado se puede canjear. PKCE lo impide. | Siempre con PKCE (S256, nunca plain). |
5.7 Scopes, roles y permisos: tres capas distintas
| Concepto | Responde a | Quién lo decide | Dónde vive | Ejemplo |
|---|---|---|---|---|
| Scope | ¿Qué le he permitido a esta aplicación? | El usuario, al dar consentimiento (o la configuración del cliente). | Claim scope del access token. | pedidos.read, openid, profile |
| Rol | ¿Qué es esta persona en la organización? | Administradores del sistema / directorio corporativo. | Claim propio (roles, realm_access.roles) o base de datos local. | ADMIN, SOPORTE, CLIENTE |
| Permiso (autoridad fina) | ¿Puede hacer esta acción concreta? | El modelo de dominio de tu aplicación. | Derivado del rol, o tabla de permisos por recurso. | pedidos:cancelar, facturas:anular |
Consejo de diseño: mete en el token lo estable y pequeño (identidad y scopes) y
resuelve en la aplicación lo fino y cambiante (permisos por recurso). Un token con 200 permisos
dentro es enorme, se queda obsoleto en el momento en que se emite y acaba superando el límite de tamaño de
cabeceras de algún proxy. Y sobre todo: autoriza siempre por permiso, no por rol, en el
código (hasAuthority("pedidos:cancelar")), para que cambiar quién tiene un permiso no exija
tocar código.
5.8 OpenID Connect: autenticación de verdad
OIDC añade a OAuth 2 tres piezas: el id_token (un JWT con la identidad del
usuario, con reglas de validación obligatorias), el endpoint /userinfo (más
datos del perfil, con el access token) y el discovery
(/.well-known/openid-configuration) que hace que un cliente se configure con una sola URL.
# Discovery: la URL que te ahorra configurar diez cosas a mano
curl -s https://auth.miempresa.com/realms/pedidos/.well-known/openid-configuration | jq
# {
# "issuer": "https://auth.miempresa.com/realms/pedidos",
# "authorization_endpoint": ".../protocol/openid-connect/auth",
# "token_endpoint": ".../protocol/openid-connect/token",
# "userinfo_endpoint": ".../protocol/openid-connect/userinfo",
# "jwks_uri": ".../protocol/openid-connect/certs",
# "end_session_endpoint": ".../protocol/openid-connect/logout",
# "introspection_endpoint": ".../protocol/openid-connect/token/introspect",
# "code_challenge_methods_supported": ["plain","S256"],
# "id_token_signing_alg_values_supported": ["RS256","ES256"],
# "grant_types_supported": ["authorization_code","refresh_token","client_credentials"]
# }
# Es lo que Spring Boot consume con spring.security.oauth2.*.issuer-uri
| Token | Para quién es | Qué contiene | Uso correcto |
|---|---|---|---|
id_token | El cliente | iss, sub, aud = client_id, exp, nonce, auth_time, amr, y datos de perfil. | Que el cliente sepa quién ha iniciado sesión. Nunca lo envíes a una API como si fuera un access token. |
access_token | El resource server | aud = la API, scope, sub. | Autorizar llamadas a la API. El cliente lo trata como opaco: no debe inspeccionar su contenido. |
refresh_token | El authorization server | Opaco. | Obtener nuevos access tokens. Guardado como una contraseña. |
5.9 Keycloak en 15 minutos y conectarlo a Spring
# 1) Arrancar Keycloak para desarrollo (start-dev: sin TLS, con BD en memoria H2)
docker run -d --name keycloak -p 8081:8080 \
-e KC_BOOTSTRAP_ADMIN_USERNAME=admin \
-e KC_BOOTSTRAP_ADMIN_PASSWORD=admin \
quay.io/keycloak/keycloak:26.0 start-dev
# En versiones anteriores a 26 las variables eran KEYCLOAK_ADMIN / KEYCLOAK_ADMIN_PASSWORD.
# ⚠️ start-dev NO es apto para producción: sin HTTPS, sin caché distribuida, con H2.
# En producción: `start` con --hostname, --db=postgres y TLS terminado en el ingress.
# 2) Consola de administración → http://localhost:8081 (admin/admin)
# 3) Lo que hay que crear (por la interfaz o con kcadm.sh):
# a) REALM "pedidos" ← frontera de aislamiento: usuarios, roles, clientes
# b) ROLES de realm: USER, ADMIN, SOPORTE
# c) CLIENTE "spa-pedidos" (público, para la SPA)
# Client authentication: OFF ← cliente público: no puede guardar secreto
# Standard flow: ON · Direct access grants: OFF ← ¡desactiva el password grant!
# Valid redirect URIs: https://app.miempresa.com/callback (NUNCA "*")
# Valid post logout redirect URIs: https://app.miempresa.com/
# Web origins: https://app.miempresa.com (NUNCA "*")
# PKCE: Advanced → Proof Key for Code Exchange Code Challenge Method = S256
# d) CLIENTE "pedidos-api" (audiencia de los tokens; sin flujos activos)
# e) CLIENTE "facturacion" (confidencial, Service accounts roles: ON → client_credentials)
# f) USUARIO de prueba + contraseña + asignación de roles
# g) MAPPER de audiencia en spa-pedidos:
# Dedicated scopes → Add mapper → By configuration → Audience
# Included Client Audience = pedidos-api ← para que aud contenga tu API
# 4) Comprobar los tokens que emite (solo en desarrollo)
curl -s -X POST http://localhost:8081/realms/pedidos/protocol/openid-connect/token \
-d grant_type=client_credentials \
-d client_id=facturacion -d client_secret=$SECRETO | jq -r .access_token
# Pega el resultado en jwt.io SOLO si es un token de desarrollo. Nunca uno de producción.
# 5) Exportar la configuración del realm para versionarla en git (infraestructura como código)
docker exec keycloak /opt/keycloak/bin/kc.sh export --dir /tmp/export --realm pedidos
docker cp keycloak:/tmp/export/pedidos-realm.json ./infra/keycloak/
# A) Nuestra API como RESOURCE SERVER (solo valida tokens)
# Dependencia: spring-boot-starter-oauth2-resource-server
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: http://localhost:8081/realms/pedidos
audiences: pedidos-api # imprescindible: sin esto aceptas tokens de otras APIs
---
# B) Una aplicación web como CLIENT (login delegado en Keycloak, sesión propia)
# Dependencia: spring-boot-starter-oauth2-client
spring:
security:
oauth2:
client:
registration:
keycloak:
client-id: web-pedidos
client-secret: ${OAUTH_CLIENT_SECRET}
authorization-grant-type: authorization_code
scope: openid,profile,email,pedidos.read
redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}"
provider:
keycloak:
issuer-uri: http://localhost:8081/realms/pedidos
user-name-attribute: preferred_username
// Cadena de la aplicación cliente: login OIDC + logout federado (RP-Initiated Logout)
@Bean
SecurityFilterChain webOidc(HttpSecurity http, ClientRegistrationRepository registros) throws Exception {
var logoutHandler = new OidcClientInitiatedLogoutSuccessHandler(registros);
logoutHandler.setPostLogoutRedirectUri("{baseUrl}/"); // cierra sesión también en Keycloak
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/error", "/css/**").permitAll()
.anyRequest().authenticated())
.oauth2Login(login -> login.userInfoEndpoint(ui -> ui.oidcUserService(servicioUsuarioOidc())))
.logout(logout -> logout.logoutSuccessHandler(logoutHandler))
.build();
}
// Leer la identidad del usuario autenticado por OIDC
@GetMapping("/panel")
public String panel(@AuthenticationPrincipal OidcUser usuario, Model modelo) {
modelo.addAttribute("nombre", usuario.getFullName());
modelo.addAttribute("email", usuario.getEmail());
modelo.addAttribute("sub", usuario.getSubject());
modelo.addAttribute("expira", usuario.getIdToken().getExpiresAt());
return "panel";
}
5.10 Propagar la identidad entre microservicios
| Estrategia | Cómo funciona | Cuándo usarla | Riesgos |
|---|---|---|---|
| Reenviar el token del usuario | El servicio A pasa el mismo Authorization al servicio B. |
Solo si el token tiene a B en su aud y el modelo de confianza lo contempla. |
Un servicio comprometido puede usar el token contra cualquier API que lo acepte. Fomenta que las APIs dejen de validar aud. |
| Token Exchange (RFC 8693) | A canjea el token del usuario en el AS por otro token con aud=B y scopes reducidos, que conserva la identidad original (act). |
La opción correcta cuando B necesita saber quién es el usuario final. | Un salto extra al AS (cacheable). Requiere soporte en el AS (Keycloak lo tiene). |
| Token de servicio (client_credentials) | A se identifica como servicio y pasa el id de usuario como dato de negocio. | Cuando B autoriza por servicio y no necesita autorizar por usuario. | Se pierde la autorización por usuario en B: si te equivocas, tienes un “superusuario de servicio”. |
| mTLS + malla de servicio | Cada pod tiene su certificado; la malla autentica y autoriza a nivel de conexión (identidad SPIFFE). | Siempre, como capa adicional de identidad de carga de trabajo. | Autentica el servicio, no al usuario: complementa los tokens, no los sustituye. |
Los detalles de gateway, malla de servicio y comunicación entre servicios están en módulo 08; el despliegue y las políticas de red, en módulo 09.
iss, aud, exp), rechaza lo inválido y añade cabeceras de
contexto. Pero cada servicio interno vuelve a validar el token que recibe: eso es
confianza cero. Si el gateway es el único que valida, cualquiera que llegue a la red interna (un SSRF,
un pod comprometido) habla con tus servicios sin credenciales.
5.11 API keys, MFA, passkeys y SSO
API keys: son aceptables en un caso concreto y con condiciones.
- Cuándo sí: integraciones servidor-a-servidor con terceros, donde no hay usuario y montar OAuth2 sería desproporcionado; identificación de tenant para cuotas y facturación.
- Cuándo no: nunca en un cliente que ejecuta en el navegador o en una app móvil (una API key en una app es una API key pública), y nunca como sustituto de la autorización por usuario.
- Condiciones: generada con
SecureRandom(≥ 256 bits); guardada hasheada (SHA-256) en tu base de datos; con prefijo identificable (pk_live_…) para poder detectarla con gitleaks; con scopes, restricción por IP y caducidad; rotable sin cortes (dos claves activas); en la cabeceraAuthorizationo una propia, nunca en la query string (queda en logs y en el historial); y con rate limiting por clave.
| Segundo factor | Resistente a phishing | Notas |
|---|---|---|
| SMS / OTP por correo | No | Vulnerable a SIM swapping y a que el usuario teclee el código en una web falsa. Mejor que nada, pero es el factor más débil. |
| TOTP (aplicaciones autenticadoras) | No | Barato y sin dependencia de la red. Se puede phishear en tiempo real. Guarda el secreto cifrado y ofrece códigos de recuperación. |
| Notificación push con número a comparar | Parcialmente | Mitiga la fatiga de aprobaciones (MFA fatigue) frente al simple “Aprobar”. |
| Passkeys / WebAuthn / FIDO2 | Sí | La clave privada no sale del dispositivo (o del TPM) y está ligada al origen: una web falsa no puede usarla, porque el navegador se niega. Es el único mecanismo que elimina el phishing de credenciales por diseño. |
Por qué las passkeys son el futuro: eliminan el secreto compartido. No hay nada que
filtrar en un volcado de base de datos (el servidor solo guarda una clave pública), no hay nada
que reutilizar en otro sitio, no hay nada que teclear en una web falsa y no hay nada que el usuario pueda
olvidar. Spring Security incorpora soporte de passkeys/WebAuthn desde la versión 6.4 con
el DSL webAuthn(), y también one-time tokens para enlaces de acceso por correo.
SSO empresarial: si la organización ya tiene un proveedor de identidad, no inventes nada.
Con OIDC lo conectas con issuer-uri. Si el proveedor es antiguo y solo habla
SAML 2.0 (muy común en entornos corporativos y en administraciones públicas), Spring
Security tiene spring-security-saml2-service-provider: mismo concepto (delegas la
autenticación en un IdP), sintaxis XML y metadatos en lugar de JSON y discovery. Migra a OIDC cuando
puedas; conviven bien mientras tanto.
Checklist — OAuth2 y OIDC
6. OWASP Top 10 aplicado a Java y Spring
El OWASP Top 10 no es un estándar de cumplimiento ni una lista exhaustiva: es un documento de concienciación sobre las categorías de riesgo más extendidas, construido con datos reales de cientos de miles de aplicaciones. Para requisitos verificables existe OWASP ASVS; para recetas concretas, las Cheat Sheets.
owasp.org/Top10 para la numeración vigente y aprende los mecanismos, no las etiquetas.
A01 · Control de acceso roto
Qué es: el usuario hace algo que no debería poder hacer. Incluye IDOR (acceder a un recurso ajeno cambiando el identificador), escalada vertical (usuario → admin), escalada horizontal (usuario A → datos de usuario B), forzado de navegación a endpoints “ocultos”, manipulación de metadatos (cookies, JWT, campos ocultos) y falta de comprobación en operaciones de escritura. Es la categoría número uno y la que más dinero cuesta.
// ❌ VULNERABLE — IDOR en cadena. Los tres endpoints tienen el mismo fallo.
@RestController
@RequestMapping("/pedidos")
public class PedidoControllerVulnerable {
@GetMapping("/{id}") // GET /pedidos/4712 → pedido de otro cliente
public PedidoDto ver(@PathVariable Long id) {
return mapper.aDto(repositorio.findById(id).orElseThrow());
}
@GetMapping("/{id}/factura") // ¡y su factura en PDF, con su dirección!
public ResponseEntity<byte[]> factura(@PathVariable Long id) {
return ResponseEntity.ok(generador.pdf(repositorio.findById(id).orElseThrow()));
}
@DeleteMapping("/{id}") // y puede CANCELAR pedidos ajenos
public void cancelar(@PathVariable Long id) {
servicio.cancelar(id);
}
}
// EXPLOTACIÓN (resumida y sin herramientas): el usuario legítimo abre su propio pedido, ve
// /pedidos/4711 en la barra de direcciones y prueba 4710, 4712, 4713… Los identificadores
// secuenciales convierten un fallo de autorización en una descarga masiva de la base de datos.
// No hace falta ninguna habilidad técnica: es un bucle de 20 líneas o incluso el navegador a mano.
// ✅ CORREGIDO — la propiedad forma parte de la consulta y el 404 no confirma la existencia
@RestController
@RequestMapping("/pedidos")
public class PedidoController {
private final RepositorioPedidos repositorio;
@GetMapping("/{id}")
public PedidoDto ver(@PathVariable Long id, @AuthenticationPrincipal Jwt jwt) {
return repositorio.findByIdAndClienteNif(id, jwt.getSubject())
.map(mapper::aDto)
.orElseThrow(() -> new PedidoNoEncontrado(id)); // → 404 siempre
}
@GetMapping // listar SOLO lo propio
public Page<PedidoDto> mios(@AuthenticationPrincipal Jwt jwt,
@PageableDefault(size = 20) Pageable pagina) {
return repositorio.findAllByClienteNif(jwt.getSubject(), pagina).map(mapper::aDto);
}
@DeleteMapping("/{id}")
@PreAuthorize("@guardiaPedidos.puedeCancelar(#id, authentication)") // segunda barrera
public void cancelar(@PathVariable Long id) {
servicio.cancelar(id);
}
}
| Mitigación | Detalle |
|---|---|
| Deny by default | Todo denegado salvo lo explícitamente permitido, en la cadena y en los servicios. |
| Autorización centralizada y reutilizable | Un AuthorizationManager o un bean de reglas, no un if copiado en 40 controladores. |
| Identificadores no adivinables | UUID v4 o ULID en las URLs públicas. No es la mitigación (es ofuscación), pero sube muchísimo el coste de la enumeración masiva. |
| La propiedad, en la consulta | findByIdAndClienteNif: imposible de olvidar y no carga datos ajenos. |
| 404 en lugar de 403 | Cuando la existencia del recurso ya es información sensible. |
| Test por endpoint | “Usuario ajeno → 404”. En CI, para siempre. |
Qué aporta Spring: authorizeHttpRequests,
@PreAuthorize/@PostAuthorize, AuthorizationManager, derivación de
consultas de Spring Data y spring-security-test para los tests.
A02 · Fallos criptográficos
Qué es: datos sensibles expuestos por ausencia de cifrado o por criptografía mal empleada. Antes se llamaba “exposición de datos sensibles”, y se renombró porque el síntoma era la exposición pero la causa es criptográfica.
// ❌ VULNERABLE
@Entity
public class ClienteVulnerable {
private String numeroTarjeta; // PAN en claro: incumple PCI-DSS y es una bomba de relojería
private String password; // sin hashear, o con MD5
private String iban; // dato financiero en claro
}
// application.yml en el repositorio:
// spring.datasource.password: Prod2024! ← secreto versionado para siempre en git
// Cliente HTTP:
HttpClient.newBuilder().sslContext(contextoQueAceptaTodo()).build(); // TLS sin validar
// Cifrado:
Cipher.getInstance("AES/ECB/PKCS5Padding"); // patrón visible
// ✅ CORREGIDO
@Entity
public class Cliente {
@Column(name = "password_hash")
private String passwordHash; // {bcrypt}$2a$12$… (sección 2.2)
@Convert(converter = ConvertidorCifrado.class) // cifrado a nivel de campo, AES-GCM
@Column(name = "iban_cifrado")
private String iban;
private String tarjetaUltimos4; // "4242": suficiente para la interfaz
private String tarjetaToken; // token del PSP. El PAN NO ESTÁ AQUÍ.
}
@Converter
public class ConvertidorCifrado implements AttributeConverter<String, String> {
private final CifradoCampo cifrado; // clave del KMS, IV aleatorio por valor
@Override public String convertToDatabaseColumn(String claro) {
return claro == null ? null : cifrado.cifrar(claro.getBytes(UTF_8), CONTEXTO);
}
@Override public String convertToEntityAttribute(String bd) {
return bd == null ? null : new String(cifrado.descifrar(bd, CONTEXTO), UTF_8);
}
}
// ⚠️ Consecuencia de cifrar un campo: dejas de poder buscar por él con LIKE o índices normales.
// Si necesitas buscar por igualdad, guarda además un HMAC determinista del valor (blind index).
Mitigaciones: clasifica los datos antes de decidir controles; no almacenes lo que no necesitas (el dato que no guardas no se filtra); TLS obligatorio en tránsito y cifrado en reposo; algoritmos y modos actuales (sección 2); secretos fuera del repositorio; claves en un KMS con rotación.
Qué aporta Spring: PasswordEncoder y
DelegatingPasswordEncoder, SSL bundles, AttributeConverter de JPA,
spring-cloud-vault / integración con Secrets Manager para secretos.
A03 · Inyección
Qué es: datos no confiables se interpretan como código o como parte de una instrucción. La causa raíz siempre es la misma: mezclar datos con instrucciones. La solución también: separarlos mediante parámetros, y validar con listas blancas lo que no se puede parametrizar.
// ─── 1) SQL con JDBC ────────────────────────────────────────────────────────────
// ❌ VULNERABLE
String sql = "SELECT * FROM usuarios WHERE email = '" + email + "' AND activo = true";
// Con un email que cierre la comilla y añada OR '1'='1' la condición se vuelve siempre
// verdadera y devuelve toda la tabla; con más creatividad se llega a otras tablas.
// ✅ CORRECTO: PreparedStatement. El parámetro NUNCA se interpreta como SQL.
var consulta = "SELECT id, email FROM usuarios WHERE email = ? AND activo = true";
jdbcTemplate.query(consulta, filaMapper, email);
// ─── 2) JPA / JPQL ──────────────────────────────────────────────────────────────
// ❌ VULNERABLE — que sea JPA no te salva si concatenas
em.createQuery("SELECT p FROM Pedido p WHERE p.cliente.nif = '" + nif + "'").getResultList();
// ✅ CORRECTO: parámetros nombrados
em.createQuery("SELECT p FROM Pedido p WHERE p.cliente.nif = :nif", Pedido.class)
.setParameter("nif", nif).getResultList();
// ✅ Y en Spring Data
@Query("SELECT p FROM Pedido p WHERE p.estado = :estado AND p.total > :minimo")
List<Pedido> buscar(@Param("estado") EstadoPedido estado, @Param("minimo") BigDecimal minimo);
// ⚠️ CASO ESPECIAL: el ORDEN y los nombres de columna NO se pueden parametrizar.
// ❌
var sqlOrden = "SELECT * FROM pedidos ORDER BY " + campo + " " + direccion;
// ✅ lista blanca estricta
private static final Set<String> ORDENABLES = Set.of("fecha", "total", "estado");
if (!ORDENABLES.contains(campo)) throw new IllegalArgumentException("campo no permitido");
Sort orden = Sort.by(Sort.Direction.fromString(direccion), campo); // fromString ya valida ASC/DESC
// ─── 3) Inyección de comandos del sistema ───────────────────────────────────────
// ❌ VULNERABLE: el shell interpreta ; | && $() como separadores/sustituciones
Runtime.getRuntime().exec("convert " + nombreRecibido + " salida.png");
new ProcessBuilder("sh", "-c", "gzip " + rutaRecibida).start();
// ✅ CORRECTO: sin shell, argumentos como lista, rutas validadas
Path base = Path.of("/var/subidas").toAbsolutePath().normalize();
Path entrada = base.resolve(nombreRecibido).normalize();
if (!entrada.startsWith(base)) throw new SecurityException("ruta fuera del directorio permitido");
var pb = new ProcessBuilder("/usr/bin/convert", entrada.toString(), "/var/salida/out.png");
pb.redirectErrorStream(true);
Process p = pb.start();
if (!p.waitFor(10, TimeUnit.SECONDS)) p.destroyForcibly(); // timeout: también es seguridad
// ─── 4) LDAP ────────────────────────────────────────────────────────────────────
// ❌ VULNERABLE: con caracteres especiales en el usuario, el filtro se convierte en otro filtro
String filtro = "(uid=" + usuario + ")";
// ✅ CORRECTO: parámetros con {0}, que Spring escapa según RFC 4515
ldapTemplate.search("", "(uid={0})", new Object[]{usuario}, mapper);
// ─── 5) SpEL: inyección que da EJECUCIÓN DE CÓDIGO ──────────────────────────────
// ❌ CRÍTICO: evaluar una expresión que viene del usuario = RCE
var parser = new SpelExpressionParser();
Object r = parser.parseExpression(expresionDelUsuario).getValue(new StandardEvaluationContext(obj));
// ✅ Si de verdad necesitas expresiones dinámicas, contexto restringido y sin acceso a tipos
EvaluationContext contexto = SimpleEvaluationContext.forReadOnlyDataBinding().build();
Object valor = parser.parseExpression(expresion).getValue(contexto, obj);
// ✅ Mejor todavía: no evalúes expresiones del usuario. Ofrece un enum de operaciones soportadas.
// ─── 6) Plantillas (SSTI) ───────────────────────────────────────────────────────
// ❌ CRÍTICO: el NOMBRE de la vista o el contenido de la plantilla viene del usuario
return "redirect:" + destinoDelUsuario; // + open redirect
return new ModelAndView(nombreDeVistaDelUsuario); // SSTI → RCE en Thymeleaf/FreeMarker
// ✅ Lista blanca de vistas y de destinos.
// ─── 7) Log injection / CRLF ────────────────────────────────────────────────────
// ❌ El usuario mete saltos de línea y falsifica una línea de log completa
log.info("Intento de login de " + emailRecibido);
// ✅ Logging estructurado (JSON: los saltos de línea van escapados en el valor del campo)
// y saneado explícito si el destino es texto plano
log.info("login_intento email={}", emailRecibido.replaceAll("[\\r\\n]", "_"));
Qué aporta Spring: JdbcTemplate/JdbcClient
y Spring Data parametrizan por defecto; Bean Validation (@Valid, @Pattern,
@Size) valida en la frontera; LdapTemplate escapa filtros; Thymeleaf escapa la
salida.
A04 · Diseño inseguro
Qué es: fallos que no se arreglan con código porque el diseño era incorrecto: no se pensó en el abuso. No hay una función mal escrita; falta un control que nadie especificó.
// ─── Caso 1: falta rate limiting → fuerza bruta, enumeración y abuso de coste ───
// ❌ Un endpoint de login (o de "recuperar contraseña", o de OTP, o de búsqueda costosa)
// sin límite permite millones de intentos por hora. El código es "correcto"; el diseño no.
// ✅ Rate limiting con Bucket4j (por IP y por cuenta, porque solo por IP se sortea con proxies)
@Component
public class FiltroRateLimit extends OncePerRequestFilter {
private final Cache<String, Bucket> cubos = Caffeine.newBuilder()
.expireAfterAccess(Duration.ofMinutes(10)).maximumSize(100_000).build();
private static Bucket nuevoCubo() {
return Bucket.builder()
.addLimit(limite -> limite.capacity(10).refillIntervally(10, Duration.ofMinutes(1)))
.build();
}
@Override
protected void doFilterInternal(HttpServletRequest req, HttpServletResponse res,
FilterChain chain) throws IOException, ServletException {
String clave = clavePorIpYUsuario(req);
Bucket cubo = cubos.get(clave, k -> nuevoCubo());
ConsumptionProbe sonda = cubo.tryConsumeAndReturnRemaining(1);
if (!sonda.isConsumed()) {
res.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); // 429
res.setHeader("Retry-After",
String.valueOf(Duration.ofNanos(sonda.getNanosToWaitForRefill()).toSeconds()));
log.warn("rate_limit_superado clave={} ruta={}", clave, req.getRequestURI());
return;
}
res.setHeader("X-RateLimit-Remaining", String.valueOf(sonda.getRemainingTokens()));
chain.doFilter(req, res);
}
@Override protected boolean shouldNotFilter(HttpServletRequest req) {
return !req.getRequestURI().startsWith("/api/auth"); // aplícalo donde importa
}
}
// Además: bloqueo temporal de cuenta con retroceso exponencial, captcha tras N fallos,
// y el límite duro en el ingress/WAF (un filtro Java no te protege de un DDoS volumétrico).
// ─── Caso 2: lógica de negocio explotable ───────────────────────────────────────
// ❌ Cupón de descuento sin límite de usos: el mismo código aplicado 50 veces → total negativo.
// ❌ Devolución que reembolsa el precio ACTUAL en lugar del pagado → comprar en oferta, devolver caro.
// ❌ "Envío gratis a partir de 50 €": pedir 60 €, devolver una línea y quedarse el envío gratis.
// ✅ Invariantes en el dominio: importe nunca negativo, un cupón por pedido, reembolso ≤ pagado,
// y recálculo del envío en cada modificación. Y tests de esos casos de abuso.
// ─── Caso 3: race condition en pagos (doble gasto) ──────────────────────────────
// ❌ VULNERABLE: leer-comprobar-escribir sin atomicidad.
// Dos peticiones simultáneas leen saldo=100, ambas ven que 100 >= 80 y ambas cobran.
@Transactional
public void pagar(Long cuentaId, BigDecimal importe) {
Cuenta c = repositorio.findById(cuentaId).orElseThrow();
if (c.getSaldo().compareTo(importe) < 0) throw new SaldoInsuficiente();
c.setSaldo(c.getSaldo().subtract(importe)); // se pierde una de las dos restas
}
// ✅ CORRECTO: tres capas de defensa
// (a) La condición, en la propia sentencia atómica de la base de datos
@Modifying
@Query("UPDATE Cuenta c SET c.saldo = c.saldo - :importe " +
"WHERE c.id = :id AND c.saldo >= :importe")
int debitarSiHaySaldo(@Param("id") Long id, @Param("importe") BigDecimal importe);
// filas afectadas = 0 → no había saldo. Atómico, sin condición de carrera.
// (b) Restricción en el esquema: la última línea de defensa es la base de datos
// ALTER TABLE cuenta ADD CONSTRAINT saldo_no_negativo CHECK (saldo >= 0);
// (c) Idempotencia para evitar el doble cobro por reintentos de red
// Cabecera Idempotency-Key + índice UNIQUE en (cuenta_id, idempotency_key)
// El segundo intento choca con la restricción y devuelve el resultado del primero.
// Alternativas: @Version (bloqueo optimista) o SELECT … FOR UPDATE (pesimista). Ver módulo 06.
Mitigaciones estructurales: modelado de amenazas en la fase de diseño (sección 1.3); historias de usuario “negativas” (“como atacante, quiero…”); límites y cuotas por defecto; invariantes en el dominio y restricciones en el esquema; y tests de casos de abuso, no solo de camino feliz.
A05 · Configuración de seguridad incorrecta
| Fallo | Consecuencia | Corrección |
|---|---|---|
Actuator expuesto (include: "*") |
/actuator/env y /actuator/configprops revelan variables de entorno y propiedades (a menudo con secretos); /heapdump descarga toda la memoria del proceso, con tokens y contraseñas dentro; /threaddump, /mappings y /beans dan un mapa completo del sistema. |
Solo health e info públicos; el resto autenticado y, mejor, en un puerto distinto no publicado en el ingress. |
| Stack traces al cliente | Versiones, rutas, esquema de BD y estructura interna, gratis. | server.error.include-stacktrace: never y un @ControllerAdvice con ProblemDetail. |
| CORS abierto | Cualquier web lee respuestas autenticadas del usuario. | Sección 3.8. |
| Credenciales por defecto | admin/admin en Keycloak, Grafana, Kafka UI, bases de datos de desarrollo publicadas. |
Sin valores por defecto en producción; arranque que falla si falta el secreto. |
| Listado de directorios y ficheros de más en el JAR | .git, .env, copias de seguridad, application-local.yml con secretos. |
Desactivar el listado en el servidor web y revisar qué entra en la imagen (.dockerignore). |
| Perfiles de desarrollo activos en producción | Consola H2, spring.jpa.show-sql, seguridad relajada, debug: true. |
SPRING_PROFILES_ACTIVE explícito y un test que falle si un perfil de desarrollo está activo. |
| Cabeceras de seguridad ausentes | XSS, clickjacking y degradación a HTTP más fáciles. | Sección 3.9. |
| Verbos HTTP y endpoints innecesarios habilitados | TRACE, PUT en rutas estáticas, consola H2, Swagger UI en producción. |
Superficie mínima: desactiva lo que no usas. |
# ✅ Configuración correcta de Actuator en producción
management:
server:
port: 9090 # puerto separado: NO se publica en el ingress
endpoints:
web:
exposure:
include: health,info,prometheus # lista blanca explícita
exclude: "*"
base-path: /interno
endpoint:
health:
show-details: when-authorized
probes:
enabled: true # /health/liveness y /health/readiness para Kubernetes
env:
show-values: never # aunque esté activo, no muestra los valores
configprops:
show-values: never
info:
env:
enabled: false # no expongas propiedades arbitrarias en /info
spring:
jpa:
show-sql: false # las consultas con parámetros acaban en los logs
h2:
console:
enabled: false # NUNCA en producción: es una consola SQL abierta
server:
error:
include-stacktrace: never
include-message: never
whitelabel:
enabled: false
// Test que evita el despiste más caro: publicar con un perfil de desarrollo
@SpringBootTest
class ConfiguracionProduccionTest {
@Autowired Environment entorno;
@Autowired WebEndpointProperties actuator;
@Test
void no_hay_perfiles_de_desarrollo_activos() {
assertThat(entorno.getActiveProfiles())
.doesNotContain("dev", "local", "test-datos");
}
@Test
void actuator_no_expone_endpoints_sensibles() {
assertThat(actuator.getExposure().getInclude())
.doesNotContain("*", "env", "heapdump", "configprops", "beans");
}
@Test
void no_hay_secretos_con_valor_por_defecto() {
assertThat(entorno.getProperty("spring.datasource.password"))
.isNotIn("", "postgres", "password", "changeme", "admin");
}
}
A06 · Componentes vulnerables y desactualizados
Una aplicación Spring Boot típica arrastra entre 150 y 400 dependencias transitivas. Tú escribes el 2% del código que ejecutas. Esta categoría no se arregla programando mejor: se arregla con proceso y automatización.
| Caso | Qué pasó | Lección |
|---|---|---|
| Log4Shell CVE-2021-44228 (dic. 2021) |
Log4j 2 interpretaba expresiones ${…} dentro del mensaje de log. Una expresión JNDI hacía que la librería fuese a buscar y cargase una clase remota: ejecución de código con solo loguear una cadena controlada por el usuario (un User-Agent, un nombre de usuario). CVSS 10.0. Corregido en 2.17.1 (y en las ramas 2.12.4 / 2.3.2). |
El logging es superficie de ataque. Una función “interna” de una librería puede convertir un dato en código. Y hacía falta saber en minutos qué versión de Log4j había en cada artefacto: eso es exactamente para lo que sirve un SBOM. |
| Spring4Shell CVE-2022-22965 (mar. 2022) |
El enlace de datos de Spring MVC permitía llegar a propiedades del ClassLoader mediante nombres de parámetro anidados y modificar la configuración de logs de Tomcat para escribir un fichero ejecutable. Requería una combinación concreta (JDK 9+, despliegue WAR en Tomcat, enlace a POJO). Corregido en Spring Framework 5.3.18 / 5.2.20. |
El data binding automático a objetos es potente y peligroso (ver también mass assignment). Y las condiciones de explotabilidad importan: no todo CVSS 9 te afecta, pero hay que comprobarlo, no suponerlo. |
# 1) Saber qué tienes de verdad (incluidas las transitivas)
mvn dependency:tree -Dverbose
mvn dependency:tree -Dincludes=org.apache.logging.log4j # ¿tengo log4j? ¿en qué versión y por quién?
./gradlew dependencies --configuration runtimeClasspath
# 2) Análisis de composición (SCA) en CI: falla el build si hay CVEs graves
mvn org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=7
trivy fs --scanners vuln,secret,misconfig --severity HIGH,CRITICAL --exit-code 1 .
trivy image --severity HIGH,CRITICAL --exit-code 1 miempresa/pedidos:1.4.2
grype dir:. --fail-on high
# 3) SBOM (inventario firmado de lo que compone el artefacto)
mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom # → target/bom.json (CycloneDX)
syft packages miempresa/pedidos:1.4.2 -o cyclonedx-json > sbom.json
# Con el SBOM guardado por versión, la pregunta "¿estamos afectados?" se responde con un grep.
# 4) Actualizar con criterio, no a ciegas
mvn versions:display-dependency-updates
mvn versions:display-plugin-updates
# Y sobre todo: el BOM de Spring Boot decide las versiones coherentes. Sube el PADRE,
# no dependencias sueltas, o acabarás con combinaciones que nadie ha probado.
# .github/dependabot.yml — actualizaciones automáticas agrupadas y revisables
version: 2
updates:
- package-ecosystem: maven
directory: "/"
schedule: { interval: weekly }
open-pull-requests-limit: 10
groups:
spring:
patterns: ["org.springframework*", "org.springframework.boot*"]
test:
patterns: ["*junit*", "*mockito*", "*testcontainers*"]
labels: ["dependencias", "seguridad"]
- package-ecosystem: docker
directory: "/"
schedule: { interval: weekly }
- package-ecosystem: github-actions # las propias acciones de CI también tienen CVEs
directory: "/"
schedule: { interval: monthly }
LATEST ni rangos; (2) SCA en cada pull request, con el build en rojo
para CRITICAL/HIGH; (3) actualizaciones pequeñas y continuas —el salto de tres años es lo
que hace imposible parchear con urgencia—; y (4) elimina dependencias: la mejor forma de no tener un CVE
es no tener la librería. Prioriza con EPSS (probabilidad real de explotación) y con la
lista KEV de CISA, no solo con el CVSS.
A07 · Fallos de identificación y autenticación
// ❌ VULNERABLE — cuatro fallos en catorce líneas
@PostMapping("/login")
public TokenDto login(@RequestBody LoginDto dto) {
Usuario u = repositorio.porEmail(dto.email())
.orElseThrow(() -> new NotFound("No existe ningún usuario con ese correo")); // (1)
if (!u.getPassword().equals(sha256(dto.password()))) { // (2) (3)
throw new Unauthorized("Contraseña incorrecta"); // (1)
}
return new TokenDto(emitirToken(u, Duration.ofDays(30))); // (4)
}
// (1) ENUMERACIÓN DE USUARIOS: dos mensajes distintos revelan qué correos están registrados.
// (2) SHA-256 rápido y sin salt (sección 2.2).
// (3) Comparación con equals: canal lateral de tiempo. Y sin límite de intentos: fuerza bruta libre.
// (4) Token de 30 días para un cliente público: ventana de exposición enorme.
// ✅ CORREGIDO
@PostMapping("/login")
public ResponseEntity<TokenDto> login(@Valid @RequestBody LoginDto dto, HttpServletRequest req) {
limitador.comprobar(dto.email(), req.getRemoteAddr()); // 429 con retroceso exponencial
var usuario = repositorio.porEmail(dto.email());
// Hash de compensación: si el usuario no existe, gastamos el mismo tiempo → no hay canal lateral
String hashAComparar = usuario.map(Usuario::getPasswordHash).orElse(HASH_SEÑUELO);
boolean valido = encoder.matches(dto.password(), hashAComparar) && usuario.isPresent();
if (!valido) {
limitador.registrarFallo(dto.email(), req.getRemoteAddr());
auditoria.loginFallido(dto.email(), req.getRemoteAddr());
// MENSAJE IDÉNTICO en ambos casos, y sin pistas en el cuerpo ni en el código de estado
throw new BadCredentialsException("Credenciales no válidas");
}
Usuario u = usuario.get();
if (u.estaBloqueado()) throw new BadCredentialsException("Credenciales no válidas");
limitador.limpiar(dto.email());
auditoria.loginCorrecto(u.getId(), req.getRemoteAddr());
return ResponseEntity.ok(emisor.emitir(u)); // access 10 min + refresh rotativo
}
Otros puntos de esta categoría que se olvidan:
- Enumeración en registro y en “he olvidado mi contraseña”. El mensaje debe ser siempre “si el correo existe, recibirás un enlace”. Y el correo de recuperación debe llegar igual de rápido en ambos casos (envíalo de forma asíncrona).
- Política de contraseñas moderna (NIST SP 800-63B): longitud mínima 8 (recomendado 12+), máxima al menos 64, permite todos los caracteres y espacios, comprueba contra listas de contraseñas filtradas (es lo que de verdad reduce el riesgo) y no obligues a rotaciones periódicas ni a reglas de composición absurdas: producen
Verano2026!y notas adhesivas en el monitor. - Tokens de recuperación: de un solo uso, con caducidad de 15–30 minutos, generados con
SecureRandom, guardados hasheados, invalidados al usarse y al cambiar la contraseña. Y al cambiar la contraseña, invalida todas las sesiones. - Regeneración del identificador de sesión al autenticarse (sección 3.9) y cierre de sesión que invalide de verdad en el servidor.
- MFA obligatorio para roles administrativos, sin excepciones.
A08 · Fallos de integridad de software y datos
// ─── Deserialización insegura: el caso más grave del ecosistema Java ───────────
// ❌ CRÍTICO: deserializar datos que vienen de fuera con la serialización nativa.
// Al leer el objeto se ejecutan métodos (readObject, readResolve…) de las clases que haya
// EN TU CLASSPATH. Encadenando esos métodos (gadget chains) se consigue ejecución de código.
// No hace falta que TU código haga nada raro: basta con tener ciertas librerías.
ObjectInputStream ois = new ObjectInputStream(peticion.getInputStream());
Object objeto = ois.readObject(); // RCE potencial
// ❌ CRÍTICO: tipado polimórfico abierto en Jackson.
// enableDefaultTyping() (obsoleto) y activateDefaultTyping con un validador permisivo
// permiten que el JSON diga QUÉ CLASE instanciar → el atacante elige un gadget del classpath.
ObjectMapper mapper = new ObjectMapper();
mapper.enableDefaultTyping(); // nunca
mapper.activateDefaultTyping(LaissezFaireSubTypeValidator.instance, // nunca con datos externos
ObjectMapper.DefaultTyping.NON_FINAL);
// ✅ CORRECTO (1): no uses serialización nativa con datos externos. JSON con tipos explícitos.
record CrearPedidoDto(@NotBlank String referencia,
@NotEmpty List<LineaDto> lineas) { } // el tipo lo decides TÚ
CrearPedidoDto dto = mapper.readValue(cuerpo, CrearPedidoDto.class);
// ✅ CORRECTO (2): si necesitas polimorfismo, ciérralo con una lista blanca
@JsonTypeInfo(use = JsonTypeInfo.Id.NAME, property = "tipo")
@JsonSubTypes({ @JsonSubTypes.Type(value = Tarjeta.class, name = "tarjeta"),
@JsonSubTypes.Type(value = Transferencia.class, name = "transferencia") })
public sealed interface MetodoPago permits Tarjeta, Transferencia { }
// o, a nivel de ObjectMapper:
var validador = BasicPolymorphicTypeValidator.builder()
.allowIfBaseType(MetodoPago.class)
.allowIfSubType("com.miempresa.pagos.")
.build();
// ✅ CORRECTO (3): si estás OBLIGADO a la serialización nativa (RMI, JMX, sistemas antiguos),
// filtro de deserialización (JEP 290, Java 9+) con lista blanca
ObjectInputFilter filtro = ObjectInputFilter.Config.createFilter(
"com.miempresa.dominio.*;java.util.*;java.lang.*;!*"); // "!*" prohíbe todo lo demás
ois.setObjectInputFilter(filtro);
// A nivel de JVM, para todo el proceso:
// -Djdk.serialFilter=com.miempresa.**;java.base/**;!*
// -Djdk.serialFilterFactory=… (filtros por contexto, Java 17+)
// Y limita la profundidad y el número de referencias: maxdepth=20;maxrefs=1000;maxbytes=100000
La otra mitad de A08 es la cadena de suministro y las actualizaciones: aceptar un artefacto, un plugin, una imagen de contenedor o una actualización automática sin verificar su origen ni su integridad. Se desarrolla en la sección 7.
A09 · Fallos de logging y monitorización
No es una vulnerabilidad que un atacante explote directamente: es lo que hace que no te enteres. Y el tiempo medio de detección de una brecha se sigue midiendo en meses. Un ataque detectado en minutos es un incidente; el mismo ataque detectado en seis meses es una catástrofe regulatoria.
✅ Qué SÍ hay que registrar
- Autenticaciones correctas y fallidas, con identidad, IP, user agent y resultado.
- Fallos de autorización (403): quién intentó qué. Es la señal más temprana de enumeración.
- Cambios de credenciales, de correo, de MFA, de permisos y de roles.
- Operaciones de negocio críticas: pagos, devoluciones, cancelaciones masivas, exportaciones de datos.
- Uso de funciones administrativas y accesos a datos personales (obligatorio para auditoría RGPD).
- Errores de validación de entrada llamativos y repetidos (indicio de sondeo).
- Activación de límites: 429, bloqueos de cuenta, rechazos del WAF.
- Siempre con
traceIdcorrelacionable y en JSON estructurado astdout.
❌ Qué NUNCA debe entrar en un log
- Contraseñas, ni siquiera las incorrectas (los usuarios se equivocan… con la contraseña de otra cuenta).
- Tokens completos, cookies de sesión, API keys, claves privadas.
- Números de tarjeta, CVV, datos de salud, credenciales de terceros.
- Datos personales innecesarios: DNI, dirección completa, teléfono. Usa identificadores internos.
- Cuerpos de petición y respuesta completos en producción.
- El resultado de
toString()de una entidad JPA con todos sus campos.
// Evento de auditoría estructurado y separado del log de aplicación
@Component
public class Auditoria {
private static final Logger AUDIT = LoggerFactory.getLogger("AUDITORIA");
public void registrar(String accion, String recurso, String resultado) {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
// Campos estables y consultables; nada de frases libres
AUDIT.info("evento=auditoria actor={} actor_tipo={} accion={} recurso={} resultado={} ip={} trace={}",
auth != null ? auth.getName() : "anonimo",
esServicio(auth) ? "servicio" : "usuario",
accion, recurso, resultado,
MDC.get("clientIp"), MDC.get("traceId"));
}
}
// Escuchar los eventos que Spring Security ya publica: auditoría sin tocar tu código
@Component
public class EscuchaSeguridad {
@EventListener
void onExito(AuthenticationSuccessEvent e) {
log.info("evento=login resultado=ok usuario={}", e.getAuthentication().getName());
}
@EventListener
void onFallo(AbstractAuthenticationFailureEvent e) {
log.warn("evento=login resultado=fallo usuario={} causa={}",
e.getAuthentication().getName(), e.getException().getClass().getSimpleName());
}
@EventListener
void onDenegado(AuthorizationDeniedEvent<?> e) {
log.warn("evento=autorizacion resultado=denegado usuario={}",
e.getAuthentication().get().getName());
}
}
// Enmascarado: filtro de Logback para que un descuido no acabe en el SIEM
// (además de la disciplina de no loguear lo que no toca)
public class EnmascararSensibles extends MessageConverter {
private static final Pattern PATRONES = Pattern.compile(
"(?i)(password|passwd|secret|token|authorization|api[_-]?key)\"?\\s*[:=]\\s*\"?([^\",;\\s]+)");
@Override public String convert(ILoggingEvent evento) {
return PATRONES.matcher(super.convert(evento)).replaceAll("$1=***");
}
}
Alertas que deberías tener configuradas (en Prometheus/Grafana, CloudWatch o el SIEM): picos de 401/403 desde una misma IP o contra un mismo usuario; un usuario que accede a muchos recursos distintos en poco tiempo (patrón de IDOR o de exfiltración); logins correctos desde países o dispositivos nuevos; creación de usuarios administradores; uso de endpoints de administración fuera de horario; caída del volumen de logs (¿alguien ha desactivado el logging?); y despliegues sin pull request asociado.
A10 · SSRF (Server-Side Request Forgery)
Qué es: tu servidor hace una petición HTTP a una URL que controla (total o parcialmente)
el usuario. El atacante no llega a esa red, pero tu servidor sí: le sirves de proxy hacia
la red interna. Es especialmente devastador en la nube, donde el servicio de metadatos
(169.254.169.254) puede entregar credenciales de IAM del rol de la instancia.
// ❌ VULNERABLE — "importar una imagen desde una URL": el clásico
@PostMapping("/productos/{id}/imagen")
public void importar(@PathVariable Long id, @RequestParam String urlImagen) throws IOException {
try (InputStream in = new URL(urlImagen).openStream()) { // ← cualquier URL, cualquier red
almacen.guardar(id, in.readAllBytes());
}
}
// Lo que un atacante puede pedir (a modo ilustrativo, sin detallar explotación):
// · el servicio de metadatos de la nube (169.254.169.254) → credenciales temporales de IAM
// · servicios "internos" que confían en localhost (por ejemplo, Actuator en otro puerto)
// · la API del clúster de Kubernetes
// · un escaneo de puertos internos: el TIEMPO de respuesta y el error revelan qué hay abierto
// · otros esquemas de URL (file:, gopher:, jar:) que amplían el impacto
// ✅ CORREGIDO — lista blanca, resolución explícita y sin redirecciones
@Service
public class DescargadorSeguro {
private static final Set<String> HOSTS_PERMITIDOS =
Set.of("cdn.proveedor.com", "imagenes.socio.es");
private static final long TAMANO_MAXIMO = 5L * 1024 * 1024;
private final HttpClient cliente = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NEVER) // ⚠️ una redirección sortea la lista blanca
.connectTimeout(Duration.ofSeconds(3))
.build();
public byte[] descargar(String urlTexto) throws IOException, InterruptedException {
URI uri = URI.create(urlTexto);
// 1) Esquema: solo https
if (!"https".equalsIgnoreCase(uri.getScheme()))
throw new IllegalArgumentException("Solo se admite https");
// 2) Host: lista blanca EXACTA (no "contains", no "endsWith": cdn.proveedor.com.atacante.io)
String host = uri.getHost();
if (host == null || !HOSTS_PERMITIDOS.contains(host.toLowerCase(Locale.ROOT)))
throw new IllegalArgumentException("Host no permitido");
// 3) Resolver el DNS y comprobar que NINGUNA IP es interna
// (protege contra un dominio permitido que resuelva a una dirección privada)
for (InetAddress ip : InetAddress.getAllByName(host)) {
if (esInterna(ip)) throw new IllegalArgumentException("Destino interno no permitido");
}
// 4) Petición con timeout y límite de tamaño
HttpRequest peticion = HttpRequest.newBuilder(uri)
.timeout(Duration.ofSeconds(10))
.GET().build();
HttpResponse<byte[]> respuesta = cliente.send(peticion, BodyHandlers.ofByteArray());
if (respuesta.statusCode() != 200) throw new IOException("Respuesta " + respuesta.statusCode());
if (respuesta.body().length > TAMANO_MAXIMO) throw new IOException("Fichero demasiado grande");
if (!esImagenPorFirma(respuesta.body())) throw new IOException("No es una imagen válida");
return respuesta.body();
}
private static boolean esInterna(InetAddress ip) {
return ip.isLoopbackAddress() // 127.0.0.0/8, ::1
|| ip.isLinkLocalAddress() // 169.254.0.0/16 ← metadatos de la nube
|| ip.isSiteLocalAddress() // 10/8, 172.16/12, 192.168/16
|| ip.isAnyLocalAddress()
|| ip.isMulticastAddress()
|| esCgnat(ip); // 100.64.0.0/10
}
}
6.11 Otras vulnerabilidades que debes conocer
XSS en plantillas y en APIs
<!-- Thymeleaf ESCAPA por defecto: es su mayor virtud de seguridad -->
<p th:text="${comentario}"></p>
<!-- Si el comentario contiene una etiqueta <script>, se renderiza como TEXTO. Correcto. -->
<!-- ❌ th:utext NO escapa: "u" de unescaped. Solo con HTML ya saneado
(OWASP Java HTML Sanitizer) -->
<div th:utext="${descripcionHtml}"></div>
<!-- ❌ Contextos donde el escapado HTML NO BASTA porque el contexto no es HTML -->
<script>var usuario = "[[${nombre}]]";</script> contexto JS: usa th:inline="javascript"
<a th:href="${urlDelUsuario}">enlace</a> un esquema javascript: ejecuta código
<div th:attr="onclick=${accion}"> manejador de eventos: nunca datos del usuario
<style th:text="${cssDelUsuario}"> contexto CSS
✅ Reglas: escapa SEGÚN EL CONTEXTO, valida las URLs (solo http/https o rutas relativas),
nunca inyectes en manejadores de eventos ni dentro de <script> sin th:inline, y añade
una CSP estricta con nonce como red de seguridad.
En una API REST no hay XSS… salvo que lo provoques tú. Devolver JSON con
Content-Type: application/json es seguro (el navegador no lo ejecuta). Se rompe si: sirves
contenido subido por el usuario con un Content-Type adivinado (de ahí
X-Content-Type-Options: nosniff); devuelves HTML construido a mano; usas JSONP; o el frontend
hace innerHTML = respuesta.campo, que es XSS en el cliente y ninguna cabecera
del servidor lo evita.
| Vulnerabilidad | En qué consiste | Mitigación en Java/Spring |
|---|---|---|
| Path traversal | Un nombre de fichero con secuencias de subida de directorio (también codificadas, o con separadores de Windows) escapa del directorio previsto y lee o escribe donde no debe. | base.resolve(nombre).normalize() y comprobar startsWith(base). Mejor todavía: no uses el nombre del usuario: genera un UUID y guarda el nombre original solo como metadato. |
| XXE (entidades externas en XML) | Un XML declara una entidad que apunta a un fichero local o a una URL interna: lectura de ficheros, SSRF y denegación de servicio (billion laughs). | Endurecer la factoría del parser (código abajo) o, si puedes, no aceptar XML. |
| ReDoS | Una expresión regular con cuantificadores anidados tarda tiempo exponencial con una entrada construida a propósito: un núcleo al 100% con una sola petición. | Expresiones simples y ancladas; límite de longitud de entrada antes de aplicar la regex; cuantificadores posesivos; nunca aceptes una regex del usuario; Pattern.compile como constante. |
| Mass assignment | El JSON de entrada se enlaza directamente a la entidad y el atacante añade campos que no le corresponden ("rol":"ADMIN", "saldo":99999, "id":1). |
DTO explícito con solo los campos editables (un record es perfecto). Nunca @RequestBody Usuario sobre una entidad. Si usas la entidad, @JsonIgnore y setDisallowedFields en un @InitBinder. |
| Subida de ficheros insegura | Se acepta cualquier fichero, se confía en la extensión o en el Content-Type declarado, se guarda con el nombre original y se sirve desde el mismo dominio. |
Validar por firma de bytes (magic bytes), no por extensión; límite de tamaño; nombre generado; almacenar fuera del directorio servido (o en un bucket privado con URL prefirmada); servir desde otro dominio con Content-Disposition: attachment y nosniff; antivirus (ClamAV) para ficheros que otros usuarios descargarán. |
| Open redirect | /login?next=https://sitio-falso.example: tu dominio de confianza redirige a la web del atacante, lo que da credibilidad al phishing y puede filtrar tokens en el fragmento. |
Solo rutas relativas, o lista blanca de destinos absolutos. Valida también los redirect_uri de OAuth2 con coincidencia exacta. |
| Clickjacking | Tu página en un iframe transparente sobre otra: el usuario cree pulsar “Ver vídeo” y pulsa “Transferir”. | frame-ancestors 'none' en la CSP y X-Frame-Options: DENY (Spring Security ya lo pone). Confirmaciones que requieran interacción explícita. |
Fuga por caché y por Referer |
Datos privados cacheados en un CDN, o tokens en la URL que viajan en la cabecera Referer a terceros. |
Cache-Control: no-store en respuestas privadas; nunca secretos en la query string; Referrer-Policy. |
// XXE: endurecer un parser XML (haz un @Bean con esto y no crees factorías a mano)
DocumentBuilderFactory dbf = DocumentBuilderFactory.newInstance();
dbf.setFeature(XMLConstants.FEATURE_SECURE_PROCESSING, true);
dbf.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true); // lo esencial
dbf.setFeature("http://xml.org/sax/features/external-general-entities", false);
dbf.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_DTD, "");
dbf.setAttribute(XMLConstants.ACCESS_EXTERNAL_SCHEMA, "");
dbf.setXIncludeAware(false);
dbf.setExpandEntityReferences(false);
// Mass assignment: el DTO es la mitigación, no un capricho de arquitectura
// ❌
@PutMapping("/usuarios/{id}")
public Usuario actualizar(@PathVariable Long id, @RequestBody Usuario usuario) { … }
// Un PUT con {"nombre":"Ana","rol":"ADMIN","saldo":999999,"emailVerificado":true}
// escribiría TODOS esos campos.
// ✅
public record ActualizarUsuarioDto(@NotBlank @Size(max = 100) String nombre,
@Email String email) { } // SOLO lo que puede cambiar
@PutMapping("/usuarios/{id}")
public UsuarioDto actualizar(@PathVariable Long id,
@Valid @RequestBody ActualizarUsuarioDto dto,
@AuthenticationPrincipal Jwt jwt) {
return servicio.actualizarPerfil(id, dto, jwt.getSubject()); // y comprueba la propiedad
}
// Subida de ficheros: validar por contenido, no por lo que diga el cliente
private static final Map<String, byte[]> FIRMAS = Map.of(
"image/png", new byte[]{(byte) 0x89, 'P', 'N', 'G'},
"image/jpeg", new byte[]{(byte) 0xFF, (byte) 0xD8, (byte) 0xFF});
public String guardar(MultipartFile fichero) throws IOException {
if (fichero.getSize() > 5 * 1024 * 1024) throw new PayloadTooLarge();
byte[] cabecera = Arrays.copyOf(fichero.getBytes(), 8);
String tipoReal = FIRMAS.entrySet().stream()
.filter(e -> empiezaPor(cabecera, e.getValue()))
.map(Map.Entry::getKey).findFirst()
.orElseThrow(() -> new TipoNoPermitido("Solo PNG o JPEG"));
String nombre = UUID.randomUUID() + extensionDe(tipoReal); // el nombre del usuario NO se usa
almacen.subir(nombre, fichero.getInputStream(), tipoReal); // bucket privado
return nombre;
}
Checklist — OWASP en mi proyecto
7. Cadena de suministro y seguridad del repositorio
7.1 Dependencias: proceso, no heroísmo
Ya vimos las herramientas en A06. Aquí lo importante es el proceso, porque la seguridad de dependencias falla por falta de rutina, no por falta de herramientas.
| Práctica | Cómo se implementa | Por qué importa |
|---|---|---|
| Versiones fijadas y gestionadas por BOM | spring-boot-starter-parent o dependencyManagement con el BOM importado. Cero rangos, cero LATEST. | Builds reproducibles y combinaciones probadas por el equipo de Spring. Sin esto, dos builds del mismo commit pueden dar artefactos distintos. |
| Actualizaciones automatizadas | Dependabot o Renovate con agrupación por familia y ejecución semanal. | Diez PRs pequeños al mes son manejables; un salto de dos versiones mayores en plena crisis, no. |
| SCA en cada PR | dependency-check, Trivy, Grype o Snyk, con --exit-code 1 para HIGH/CRITICAL. | Detecta la vulnerabilidad antes de fusionar. |
| Repositorio interno como proxy | Nexus o Artifactory delante de Maven Central, con políticas de bloqueo. | Te protege de la retirada de un artefacto y del dependency confusion (un paquete público con el nombre de tu paquete interno). |
| Comprobación de checksums y firmas | mvn --strict-checksums; verificación de firmas PGP de los artefactos. | Detecta manipulación en el trayecto o en un espejo. |
| Presupuesto de dependencias | Revisión explícita al añadir una nueva: ¿está mantenida? ¿cuántas transitivas arrastra? ¿podríamos escribir esas 30 líneas? | Cada dependencia es código de terceros ejecutándose con los permisos de tu proceso. |
7.2 SBOM, firma de artefactos y procedencia
# SBOM: la "lista de ingredientes" del artefacto, generada en el build y ARCHIVADA por versión
mvn org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom # → target/bom.json y bom.xml
syft packages miempresa/pedidos:1.4.2 -o cyclonedx-json > sbom-1.4.2.json
# Con el SBOM archivado, responder a "¿nos afecta el CVE de X?" tarda segundos:
grep -c '"name":"log4j-core"' sbom-1.4.2.json
trivy sbom sbom-1.4.2.json --severity HIGH,CRITICAL # reevaluar SIN reconstruir la imagen
# Firma sin claves (identidad OIDC de tu CI: no hay secreto que rotar ni que filtrar)
cosign sign --yes ghcr.io/miempresa/pedidos:1.4.2
cosign attest --yes --predicate sbom-1.4.2.json \
--type cyclonedx ghcr.io/miempresa/pedidos:1.4.2
# Verificación EN EL CLÚSTER antes de admitir la imagen (Kyverno, Gatekeeper, política del runtime)
cosign verify ghcr.io/miempresa/pedidos:1.4.2 \
--certificate-identity-regexp 'https://github\.com/miempresa/.+' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com
# SLSA: niveles de garantía sobre CÓMO se construyó el artefacto
# L1: existe procedencia documentada
# L2: build en un servicio alojado, con procedencia firmada
# L3: build aislado y procedencia no falsificable
# Objetivo realista para un equipo de producto: L2 pronto, L3 en lo que se publica hacia fuera.
:1.4.2 puede apuntar
mañana a otra cosa); una firma verificada en el momento de la admisión, no. Es la diferencia entre confiar
en un nombre y confiar en una prueba criptográfica. Por eso también se despliega por
digest y no por tag.
7.3 Secretos: el fallo más común y más fácil de evitar
# Detección ANTES de que el secreto entre en el historial (hook local)
gitleaks protect --staged --redact --verbose
# Y en CI, sobre TODO el historial (por si ya entró hace meses):
gitleaks detect --source . --log-opts="--all" --redact
# Con pre-commit, para que lo tenga todo el equipo sin esfuerzo:
# .pre-commit-config.yaml
# repos:
# - repo: https://github.com/gitleaks/gitleaks
# rev: v8.21.0
# hooks: [ { id: gitleaks } ]
# Complementos útiles: trufflehog (comprueba si la credencial está VIVA), el escaneo de
# secretos y la "push protection" de GitHub, y detect-secrets con una línea base.
- ROTAR la credencial inmediatamente. Asume que está comprometida. Los bots escanean los repositorios públicos en segundos: una clave de acceso a la nube filtrada se usa en menos de un minuto. Este es el único paso urgente.
- Revisar los logs de uso de esa credencial (CloudTrail o el equivalente) para saber si se usó, desde dónde y para qué. Eso determina si hay un incidente que notificar.
- Reducir su alcance al reemitirla: si era una clave con permisos amplios, la nueva debe tener el mínimo.
- Limpiar el historial con
git filter-repo(o BFG) y coordinar el push forzado con todo el equipo. Esto no “deshace” la fuga: los forks, los clones locales y la caché de la plataforma pueden conservarla. Es higiene, no remediación. - Añadir la detección que faltaba (hook y CI) y escribir un postmortem sin culpables: la persona que hizo el commit no es el problema; la ausencia de una barrera automática, sí.
| Dónde poner un secreto | Veredicto | Comentario |
|---|---|---|
application.yml del repositorio | NUNCA | Queda en el historial para siempre y lo ve cualquiera con acceso de lectura, incluidos los forks y las herramientas de análisis. |
| Variable de entorno | Aceptable | Estándar y sencillo. Ojo: visible en /proc/PID/environ, en docker inspect, en kubectl describe pod y en cualquier volcado de memoria. No rota sin reinicio. |
Secret de Kubernetes | Aceptable con condiciones | Es Base64, no cifrado. Exige cifrado en reposo en etcd, RBAC estricto y, mejor, montarlo como fichero en lugar de como variable de entorno. |
| Sealed Secrets / SOPS | Bien para GitOps | Permite versionar el secreto cifrado en git. La clave de descifrado vive en el clúster o en un KMS. |
| Vault / Secrets Manager / Key Vault | Lo mejor | Rotación automática, credenciales dinámicas de corta vida, auditoría de cada acceso y revocación central. |
| Identidad de la carga de trabajo (IRSA, Workload Identity, Managed Identity) | Lo ideal | Elimina el secreto: el pod obtiene credenciales temporales por su propia identidad. No hay nada que filtrar ni que rotar. |
# Spring Boot leyendo secretos de Vault (spring-cloud-starter-vault-config)
spring:
config:
import: "vault://secret/pedidos"
cloud:
vault:
uri: https://vault.miempresa.com
authentication: KUBERNETES # el pod se autentica con su ServiceAccount: sin secreto inicial
kubernetes:
role: pedidos
service-account-token-file: /var/run/secrets/kubernetes.io/serviceaccount/token
kv:
enabled: true
backend: secret
datasource:
# El valor lo resuelve Vault en el arranque; el YAML del repositorio no contiene el secreto.
# Y SIN valor por defecto: si Vault no responde, la aplicación NO ARRANCA (fallar de forma segura).
password: ${db-password}
7.4 Endurecer el repositorio y la integración continua
# .github/workflows/ci.yml — permisos mínimos y controles de seguridad en el pipeline
name: CI
on:
pull_request:
push: { branches: [main] }
permissions:
contents: read # ← por defecto MÍNIMO; se amplía solo en el job que lo necesita
concurrency:
group: ${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true
jobs:
verificar:
runs-on: ubuntu-latest
steps:
# Acciones ancladas por SHA, no por tag: un tag se puede mover a código malicioso
- uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2
with: { fetch-depth: 0 }
- uses: actions/setup-java@8df1039502a15bceb9433410b1a100fbe190c53b # v4.5.0
with: { java-version: '21', distribution: temurin, cache: maven }
- name: Fugas de secretos
run: gitleaks detect --source . --redact
- name: Compilar y probar
run: mvn -B verify
- name: SAST
run: mvn -B com.github.spotbugs:spotbugs-maven-plugin:check # con find-sec-bugs
- name: SCA de dependencias
run: mvn -B org.owasp:dependency-check-maven:check -DfailBuildOnCVSS=7
- name: SBOM
run: mvn -B org.cyclonedx:cyclonedx-maven-plugin:makeAggregateBom
- name: Escaneo de la imagen
run: trivy image --severity HIGH,CRITICAL --exit-code 1 miempresa/pedidos:${{ github.sha }}
- uses: actions/upload-artifact@v4
with: { name: sbom, path: target/bom.json }
publicar:
needs: verificar
if: github.ref == 'refs/heads/main'
permissions:
contents: read
packages: write
id-token: write # necesario para firmar con cosign sin claves (OIDC)
environment: produccion # con revisores obligatorios configurados en el entorno
runs-on: ubuntu-latest
steps: [] # build, push y cosign sign
| Control del repositorio | Qué evita |
|---|---|
Protección de main: sin push directo, sin force push, sin borrado | Reescrituras del historial y saltarse la revisión. |
Revisión obligatoria (≥ 1 revisor, ≥ 2 en código sensible) y CODEOWNERS | Que un solo desarrollador —o un token robado— meta código en producción. |
| Status checks obligatorios y rama actualizada antes de fusionar | Fusionar con CI en rojo o con conflictos semánticos. |
| Commits firmados y verificación de firma | Suplantación de autoría, que es trivial: basta un git config user.email. |
| Permisos mínimos del token de CI, declarados por job | Que un paso comprometido publique artefactos o modifique el repositorio. |
| Secretos por entorno, con revisores obligatorios | Que un PR desde un fork alcance credenciales de producción. |
Prohibido pull_request_target con checkout del código del PR | Ejecutar código no revisado con los secretos del repositorio: vector clásico de compromiso de CI. |
| Acciones ancladas por SHA y limitadas a fuentes permitidas | Que una acción de terceros cambie bajo el mismo tag y exfiltre secretos. |
Checklist — cadena de suministro
8. Seguridad en la nube y en contenedores
Resumen con enfoque de seguridad; el detalle de Docker, Kubernetes, despliegue y observabilidad está en módulo 09.
# Dockerfile endurecido para una aplicación Spring Boot 3
# ─── Fase de construcción ─────────────────────────────────────────────────────
FROM maven:3.9-eclipse-temurin-21 AS build
WORKDIR /app
COPY pom.xml .
RUN mvn -B dependency:go-offline
COPY src ./src
RUN mvn -B clean package -DskipTests && \
java -Djarmode=tools -jar target/app.jar extract --layers --destination /app/extraido
# ─── Fase de ejecución ────────────────────────────────────────────────────────
FROM gcr.io/distroless/java21-debian12:nonroot # sin shell, sin gestor de paquetes, sin curl
WORKDIR /app
COPY --from=build --chown=nonroot:nonroot /app/extraido/dependencies/ ./
COPY --from=build --chown=nonroot:nonroot /app/extraido/application/ ./
USER nonroot # UID 65532: NUNCA root
EXPOSE 8080
ENTRYPOINT ["java","-XX:MaxRAMPercentage=75","org.springframework.boot.loader.launch.JarLauncher"]
POR QUÉ CADA DECISIÓN:
· imagen distroless o mínima → decenas de CVEs menos y superficie de ataque ínfima
· sin shell → un RCE no encuentra /bin/sh con el que moverse
· usuario no root → un escape del proceso no es root en el nodo
· multi-stage → el JDK, Maven, el código fuente y ~/.m2 NO viajan a producción
· capas de Spring Boot → mejor caché y despliegues más rápidos (menos ventana de parcheo)
# Kubernetes: contexto de seguridad restrictivo (Pod Security Standard "restricted")
apiVersion: apps/v1
kind: Deployment
metadata: { name: pedidos }
spec:
template:
spec:
serviceAccountName: pedidos # SA propia con RBAC mínimo (NUNCA la "default")
automountServiceAccountToken: false # si el pod no habla con la API de K8s, no le des token
securityContext:
runAsNonRoot: true
runAsUser: 65532
fsGroup: 65532
seccompProfile: { type: RuntimeDefault }
containers:
- name: app
image: ghcr.io/miempresa/pedidos@sha256:3f1c9a… # por DIGEST: inmutable y verificable
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true # nada de escribir en la imagen
capabilities: { drop: ["ALL"] } # ninguna capacidad de Linux
resources: # los límites TAMBIÉN son seguridad
requests: { cpu: 250m, memory: 512Mi }
limits: { cpu: "1", memory: 1Gi }
volumeMounts:
- { name: tmp, mountPath: /tmp } # el único directorio escribible
volumes:
- name: tmp
emptyDir: { medium: Memory, sizeLimit: 64Mi }
---
# Política de red: denegar el egreso y permitir solo lo necesario.
# Es LA mitigación arquitectónica del SSRF y del movimiento lateral.
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata: { name: pedidos-egreso }
spec:
podSelector: { matchLabels: { app: pedidos } }
policyTypes: [Egress]
egress:
- to: [{ podSelector: { matchLabels: { app: postgres } } }]
ports: [{ port: 5432 }]
- to: [{ namespaceSelector: { matchLabels: { name: kube-system } } }] # DNS
ports: [{ port: 53, protocol: UDP }]
- to: [{ ipBlock: { cidr: 0.0.0.0/0,
except: ["169.254.169.254/32", "10.0.0.0/8"] } }] # sin metadatos
ports: [{ port: 443 }]
| Área | Práctica | Por qué |
|---|---|---|
| Imagen | Base mínima, sin root, sistema de ficheros raíz de solo lectura, sin capacidades, escaneo en CI y en el registro. | Reduce lo que un atacante encuentra dentro y lo que puede hacer con ello. |
| Red | NetworkPolicy por defecto denegando ingreso y egreso; segmentación por namespace; sin IPs públicas en las cargas internas. | Convierte un compromiso puntual en un problema contenido, no en acceso a toda la red. |
| IAM | Roles en lugar de claves (IRSA, Workload Identity), un rol por servicio, permisos por recurso, sin comodines, sin usuarios con claves de larga vida. | Las credenciales estáticas se filtran; las temporales caducan solas. Y el mínimo privilegio limita el daño. |
| Cifrado | En reposo con claves gestionadas (y propias, CMK, para datos sensibles); en tránsito TLS en todas partes, incluido el tráfico interno. | Un disco, una copia de seguridad o un snapshot mal configurado dejan de ser una fuga. |
| Borde | WAF con reglas gestionadas, protección DDoS, rate limiting en el ingress, TLS con certificados renovados automáticamente. | Filtra el ruido genérico y absorbe el volumen antes de que llegue a tu aplicación. |
| Entornos | Cuentas o suscripciones separadas para desarrollo, preproducción y producción; sin datos reales fuera de producción. | Un error o un compromiso en desarrollo no puede tocar producción. |
| Auditoría | CloudTrail / Activity Log / Audit Logs activos en todas las regiones, en un destino inmutable y en otra cuenta. | Sin registros no hay investigación posible; y un atacante con permisos borra los registros que estén a su alcance. |
| Configuración | Infraestructura como código con revisión, más escaneo de IaC (Checkov, tfsec, trivy config). | La mayoría de las brechas en la nube son configuraciones incorrectas, no exploits. |
9. Privacidad y cumplimiento
9.1 RGPD: lo que un desarrollador necesita saber de verdad
El Reglamento General de Protección de Datos (RGPD, o GDPR) no es solo un asunto del departamento legal: varios de sus principios se traducen directamente en decisiones de código y de esquema. En España lo complementa la LOPDGDD y la autoridad de control es la AEPD.
| Principio / derecho | Qué exige | Traducción técnica |
|---|---|---|
| Minimización | Recoger solo los datos necesarios para la finalidad. | Revisa cada campo del formulario y de la tabla: ¿para qué se usa? Si nadie sabe responderlo, bórralo. El dato que no guardas no se puede filtrar. |
| Limitación de la finalidad | Los datos recogidos para X no se usan para Y. | No reutilices la tabla de clientes para entrenar un modelo o para enviar publicidad si no era la finalidad declarada. |
| Base legal | Todo tratamiento necesita una: consentimiento, contrato, obligación legal, interés legítimo… | Consentimiento granular (una casilla por finalidad, nunca premarcada) y registrado: qué, cuándo, versión del texto y desde qué IP. |
| Derecho de acceso y portabilidad | Entregar al interesado sus datos en formato legible por máquina. | Un endpoint o proceso de exportación que reúna los datos de todos los sistemas, no solo de la base de datos principal. |
| Derecho de supresión (“al olvido”) | Borrar cuando ya no haya base legal para conservarlos. | Aquí está la dificultad real: hay que borrar también en réplicas, copias de seguridad, índices de búsqueda, cachés, logs, almacén de datos analíticos y sistemas de terceros. Diséñalo desde el principio. |
| Rectificación y oposición | Corregir datos y oponerse a determinados tratamientos. | Datos editables por el usuario y banderas de preferencias respetadas en todos los procesos. |
| Seguridad del tratamiento (art. 32) | Medidas técnicas apropiadas al riesgo. | Es, literalmente, todo lo demás de este módulo: cifrado, seudonimización, control de acceso, pruebas. |
| Notificación de brechas | A la autoridad en 72 horas desde que se tiene conocimiento; a los afectados sin dilación si el riesgo es alto. | Necesitas detectar (A09) y poder determinar el alcance: qué datos, de cuántas personas. Sin logs de auditoría es imposible, y no poder determinarlo empeora tu posición. |
| Encargados del tratamiento | Contrato (DPA) con cada proveedor que trate datos por ti, y control de transferencias internacionales. | Inventario de subencargados: proveedor de nube, correo transaccional, analítica, soporte, monitorización, y ahora también proveedores de IA. |
| Privacidad por diseño y por defecto (art. 25) | La configuración más protectora es la predeterminada. | Perfiles privados por defecto, sin casillas premarcadas, retención mínima como valor inicial. |
9.2 Datos personales en logs y en entornos de prueba
| Técnica | Reversible | ¿Sigue siendo dato personal? | Cuándo usarla |
|---|---|---|---|
| Seudonimización (sustituir identificadores por tokens con tabla aparte) | Sí, con la tabla de correspondencia | Sí: el RGPD sigue aplicando, pero reduce el riesgo y es una medida reconocida. | Analítica interna, soporte, trazabilidad. |
| Anonimización (irreversible: agregación, supresión, generalización) | No | No, si de verdad es irreversible. | Estadística e informes. Cuidado con la reidentificación por combinación de campos. |
Enmascarado (ES••••••••3456) | No para quien lo ve | Depende de lo que quede visible. | Interfaces de soporte, exportaciones parciales. |
| Datos sintéticos (generados con Datafaker/Instancio, con el mismo esquema y distribución) | No aplica | No | La mejor opción para desarrollo, pruebas y demostraciones. |
// Generar datos de prueba realistas SIN datos reales (Datafaker + Testcontainers, módulo 07)
var faker = new Faker(new Locale("es", "ES"));
for (int i = 0; i < 1_000; i++) {
repositorio.save(new Cliente(
faker.name().fullName(),
faker.internet().emailAddress(),
faker.address().streetAddress(),
faker.number().numberBetween(18, 90)));
}
// Enmascarado en la capa de presentación (no en la de dominio: allí necesitas el valor)
public static String enmascararIban(String iban) {
if (iban == null || iban.length() < 8) return "••••";
return iban.substring(0, 4) + "•".repeat(iban.length() - 8) + iban.substring(iban.length() - 4);
}
// Serialización: que un DTO no arrastre por accidente un campo sensible
public record ClienteDto(
Long id,
String nombre,
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String password, // entra, nunca sale
@JsonIgnore String iban,
String ibanEnmascarado) { }
// Y en la entidad, un toString() que no filtre nada en un log
@Override
public String toString() {
return "Cliente[id=" + id + "]"; // ni nombre, ni email, ni dirección
}
9.3 PCI-DSS, tokenización y auditoría de acceso
Si tu aplicación toca datos de tarjetas, entras en el alcance de PCI-DSS. La estrategia correcta no es “cumplir PCI-DSS”, sino salir del alcance:
- No almacenes nunca el PAN completo. Guarda un token del proveedor de pagos, la marca y los cuatro últimos dígitos. Con eso puedes mostrar el método de pago, cobrar recurrentemente y hacer devoluciones.
- Nunca, bajo ninguna circunstancia, almacenes el CVV (ni cifrado ni “solo un rato”). Está prohibido explícitamente tras la autorización.
- Que el número no pase por tus servidores. Usa campos alojados por el PSP (iframes o elementos tipo Stripe Elements / Redsys): el dato viaja del navegador al proveedor y tú recibes un token. Esto reduce drásticamente tu cuestionario de cumplimiento (SAQ A en lugar de SAQ D).
- Nunca en logs. Ni el PAN, ni el CVV, ni la respuesta completa del PSP. Enmascara en el interceptor del cliente HTTP.
- Segmentación y auditoría: si algún componente sí maneja datos de tarjeta, aíslalo en su propia red, con acceso mínimo, cifrado a nivel de campo, registro de cada acceso y revisión periódica.
-- Auditoría de acceso a datos personales: obligatoria de facto para el RGPD
-- y la única forma de detectar el abuso por parte de usuarios internos.
CREATE TABLE auditoria_acceso_datos (
id BIGSERIAL PRIMARY KEY,
momento TIMESTAMPTZ NOT NULL DEFAULT now(),
actor VARCHAR(100) NOT NULL, -- quién (usuario o servicio)
actor_tipo VARCHAR(20) NOT NULL, -- 'usuario' | 'servicio'
accion VARCHAR(40) NOT NULL, -- 'LEER' | 'EXPORTAR' | 'MODIFICAR' | 'BORRAR'
entidad VARCHAR(60) NOT NULL, -- 'Cliente'
entidad_id VARCHAR(60) NOT NULL, -- el id, NO el contenido del dato
motivo VARCHAR(200), -- nº de ticket de soporte, por ejemplo
ip INET,
trace_id VARCHAR(40)
);
-- Índices para las consultas que de verdad harás en una investigación
CREATE INDEX idx_aud_actor_momento ON auditoria_acceso_datos (actor, momento DESC);
CREATE INDEX idx_aud_entidad ON auditoria_acceso_datos (entidad, entidad_id, momento DESC);
-- Reglas de oro de una tabla de auditoría:
-- · SOLO INSERT: el usuario de la aplicación no tiene UPDATE ni DELETE sobre ella
-- REVOKE UPDATE, DELETE ON auditoria_acceso_datos FROM app_pedidos;
-- · Retención definida y particionado por mes para poder archivar y purgar.
-- · Nunca guardes el VALOR del dato consultado: guardas quién vio qué registro, no su contenido.
-- · Copia hacia un destino externo e inmutable (WORM), fuera del alcance de la aplicación.
-- Consulta típica de detección: ¿alguien está mirando demasiados clientes?
SELECT actor, count(DISTINCT entidad_id) AS clientes_distintos
FROM auditoria_acceso_datos
WHERE entidad = 'Cliente' AND accion = 'LEER' AND momento > now() - interval '1 hour'
GROUP BY actor
HAVING count(DISTINCT entidad_id) > 50; -- umbral según tu operación normal
| Requisito de retención | Implementación |
|---|---|
| Cada categoría de dato tiene un plazo | Documentado en el registro de tratamientos y ejecutado por un proceso automático, no por la buena voluntad de nadie. |
| Borrado que respeta obligaciones legales | La normativa fiscal y contable obliga a conservar facturas durante años: se conserva el documento y se suprimen o seudonimizan los datos no necesarios. El “derecho al olvido” no es absoluto. |
| Borrado en cascada real | Base de datos, réplicas, copias de seguridad (con su propio plazo documentado), índices de búsqueda, cachés, almacén analítico, logs y proveedores externos. |
| Prueba del borrado | Un test de integración que cree un usuario, ejecute el borrado y verifique que no queda en ninguno de los almacenes. Si no está probado, no funciona. |
Checklist — privacidad
10. Proceso: SDLC seguro, incidentes y vulnerabilidades
10.1 Ciclo de vida seguro
SDLC SEGURO — qué se hace en cada fase y con qué coste de corregir
REQUISITOS DISEÑO DESARROLLO PRUEBAS PRODUCCIÓN
│ │ │ │ │
· requisitos · modelado de · linters + · DAST en · WAF y rate limiting
de seguridad amenazas SAST en el IDE staging · monitorización y alertas
explícitos (STRIDE) · revisión de · pruebas de · gestión de parches
· clasificación · elección de código con autorización · respuesta a incidentes
de datos controles checklist por rol · pentest periódico
· normativa · decisiones · SCA en cada · escaneo de · bug bounty
aplicable registradas PR imagen · postmortems
│ │ │ │ │
COSTE RELATIVO DE ARREGLAR EL MISMO FALLO:
1x ~5x ~10x ~30x ~100x (+ multa
+ reputación)
Eso es todo el argumento del "shift-left": no es una moda, es aritmética.
Checklist de revisión de código orientada a seguridad
- ¿Se valida toda la entrada en el servidor (tipo, rango, longitud, formato)?
- ¿Hay autorización, y comprueba la propiedad del recurso, no solo el rol?
- ¿Se construye alguna consulta, comando o plantilla por concatenación?
- ¿Se escapa la salida según el contexto (HTML, atributo, JS, URL)?
- ¿Hay algún secreto, clave o token en el diff?
- ¿Los logs nuevos contienen datos personales, tokens o cuerpos completos?
- ¿Los mensajes de error revelan información interna?
- ¿El endpoint nuevo tiene límite de tamaño, paginación con tope y rate limiting?
- ¿Hay tests de “anónimo → 401” y “usuario ajeno → 404/403”?
- ¿Se ha añadido alguna dependencia? ¿Está justificada y sin CVEs?
- ¿Se usa criptografía? ¿Con los modos y parámetros de la sección 2?
- ¿Alguna petición saliente usa una URL controlada por el usuario?
Tipos de prueba: qué encuentra cada una
- SAST (código estático: SpotBugs con find-sec-bugs, Semgrep, SonarQube, CodeQL): patrones peligrosos y flujos de datos contaminados. Rápido y temprano; muchos falsos positivos.
- SCA (dependencias: Dependency-Check, Trivy, Grype, Snyk): CVEs conocidos en librerías. Alta señal, poco ruido. Empieza por aquí.
- DAST (aplicación en ejecución: OWASP ZAP, Burp): configuración, cabeceras, autorización real, inyecciones reflejadas. Encuentra lo que el código no muestra.
- IAST (agente dentro de la aplicación durante las pruebas): combina ambos; menos falsos positivos, coste de licencia y de rendimiento.
- Pentest manual: lógica de negocio, cadenas de fallos y todo lo que ninguna herramienta entiende. Imprescindible antes de un lanzamiento importante.
- Bug bounty: cobertura continua y con incentivos alineados. Requiere madurez: una política pública (
security.txt), triaje ágil y capacidad de arreglar rápido.
10.2 Respuesta a incidentes
CICLO DE RESPUESTA A INCIDENTES (NIST SP 800-61)
PREPARACIÓN ──► DETECCIÓN ──► CONTENCIÓN ──► ERRADICACIÓN ──► RECUPERACIÓN ──► LECCIONES
(antes) y ANÁLISIS APRENDIDAS
│
└─────────────────────── realimenta la preparación ◄────────────────────────┘
PREPARACIÓN (lo único que se hace con calma, así que hazlo bien)
· Runbook escrito y accesible SIN acceso a los sistemas comprometidos.
· Roles: quién decide, quién comunica, quién investiga, quién habla con negocio y con legal.
· Canal de comunicación alternativo (si el atacante está dentro, no uses el chat corporativo).
· Contactos: proveedor de nube, seguro, asesoría jurídica, autoridad de control.
· Logs centralizados, con retención suficiente y FUERA del alcance del atacante.
· Simulacros: una vez al año como mínimo. Un runbook nunca ensayado no funciona.
DETECCIÓN Y ANÁLISIS
· ¿Qué sistemas? ¿Qué datos? ¿Desde cuándo? ¿Sigue el acceso activo?
· Preserva evidencias ANTES de tocar nada: snapshot del disco y de la memoria, copia de logs.
· Cronología escrita desde el primer minuto: hora, quién, qué observó, qué hizo.
CONTENCIÓN (rápido, pero pensando)
· Aislar (política de red, grupo de seguridad), NO apagar sin más: apagar destruye
la memoria y con ella la evidencia. Y a veces avisa al atacante.
· Rotar credenciales y revocar sesiones y tokens (aquí se paga el precio del JWT no revocable).
· Bloquear el vector: parchear, cerrar el endpoint, activar una regla de WAF.
ERRADICACIÓN
· Eliminar persistencia: cuentas creadas, claves SSH, webshells, tareas programadas,
imágenes o funciones modificadas, reglas de reenvío de correo.
· Reconstruir desde una imagen limpia y verificada. NO "limpiar" un servidor comprometido.
RECUPERACIÓN
· Restaurar servicio de forma controlada, con monitorización reforzada.
· Verificar integridad de datos y de copias de seguridad ANTES de restaurarlas.
LECCIONES APRENDIDAS (en 1–2 semanas, por escrito)
· SIN CULPABLES: se busca la causa sistémica, no la persona. Si hay culpa, no habrá informes
sinceros la próxima vez, y eso es peor que el incidente.
· Acciones concretas con dueño y fecha. Y al menos un TEST AUTOMÁTICO nuevo.
RELOJ REGULATORIO: 72 h para notificar a la autoridad de control desde que se CONOCE la
brecha de datos personales. Es un plazo corto: la decisión de notificar se toma con
información incompleta y debe estar prevista en el runbook, no improvisada.
10.3 Gestión de vulnerabilidades y formación
| Concepto | Qué mide | Cómo usarlo |
|---|---|---|
| CVSS (0–10) | Severidad técnica teórica en el peor caso. | Punto de partida, nunca la decisión final. Ajusta con las métricas de entorno: un CVSS 9.8 en una librería que ni cargas no es tu prioridad. |
| EPSS (0–1) | Probabilidad de que se explote en los próximos 30 días. | Combínalo con CVSS: severidad alta y probabilidad alta = arréglalo hoy. |
| KEV (CISA) | Lista de vulnerabilidades explotadas activamente. | Si algo tuyo está en KEV, deja lo que estés haciendo. |
| Contexto propio | ¿Es alcanzable? ¿Requiere autenticación? ¿Está expuesto a Internet? ¿Hay mitigación en el WAF? | Es lo que convierte una lista de 400 hallazgos en una lista de 6 acciones reales. |
| Prioridad | Criterio | Plazo objetivo (SLA típico) |
|---|---|---|
| Emergencia | En KEV, o crítica y explotable desde Internet sin autenticación (un Log4Shell). | Horas. Con mitigación temporal inmediata (WAF, apagar la función) si el parche tarda. |
| Crítica | CVSS ≥ 9 y alcanzable en tu configuración. | 72 horas. |
| Alta | CVSS 7–8,9 alcanzable. | 2 semanas. |
| Media / Baja | Resto, o no alcanzable. | Siguiente ciclo de actualización de dependencias. |
Formación del equipo: lo que funciona no es el curso anual de dos horas que todos hacen con el vídeo en otra pestaña. Funciona lo que está pegado al trabajo diario: un campeón de seguridad por equipo (una persona con interés y tiempo asignado, no un cargo honorífico); revisar los hallazgos reales del propio proyecto en la reunión semanal; laboratorios prácticos (PortSwigger Academy, Juice Shop, WebGoat) en horario laboral; sesiones de modelado de amenazas donde se aprende haciendo; y difundir los postmortems sin culpables, que son el material didáctico más eficaz que existe porque hablan de vuestro propio sistema.
Checklist — proceso
11. Errores comunes y soluciones
| Síntoma o error | Causa real | Solución |
|---|---|---|
403 inesperado en todo justo después de añadir spring-boot-starter-security |
Con la dependencia en el classpath, Spring Boot protege todos los endpoints por defecto (HTTP Basic con un usuario user y contraseña generada en el log). Si además haces POST desde una herramienta, CSRF lo bloquea. |
Declara tu propia SecurityFilterChain con reglas explícitas. Es la señal de que la configuración por defecto —correcta y restrictiva— está funcionando. |
| 403 en POST/PUT/DELETE de mi API, y en GET no | El CsrfFilter exige el token en métodos que cambian estado. |
Si es una API stateless con Bearer, desactiva CSRF solo en esa cadena con securityMatcher. Si es una SPA con cookie, envía el token en X-XSRF-TOKEN. Nunca lo desactives globalmente “para que funcione Postman”. |
403 solo al llamar a un método interno con @PreAuthorize |
Ausencia de @EnableMethodSecurity, o el método es private/final, o se llama con this.metodo() y no pasa por el proxy. |
Activa @EnableMethodSecurity; método public en un bean; extrae la lógica a otro bean si la llamada es interna. |
| “Error de CORS” en la consola del navegador | Casi nunca es CORS: el preflight OPTIONS recibe 401, o el origen no está en la lista, o hay dos configuraciones de CORS que se contradicen. |
http.cors(...) con un único CorsConfigurationSource y orígenes explícitos. Mira el código de estado real del OPTIONS en la pestaña de red, no el mensaje del navegador. |
| Arranque fallido: “When allowCredentials is true, allowedOrigins cannot contain the special value \"*\"” | Combinación prohibida por la especificación de CORS. | Lista explícita de orígenes, o allowedOriginPatterns si de verdad necesitas comodines. |
| Mi API acepta tokens de otra API del mismo emisor | No se valida el claim aud. Es el fallo de JWT más frecuente y más grave. |
spring.security.oauth2.resourceserver.jwt.audiences, o un JwtClaimValidator para aud. Y un test que lo demuestre. |
| 401 intermitentes tras un despliegue o de madrugada | Reloj del contenedor desincronizado (exp/nbf), o rotación de claves con caché de JWKS antigua, o varias réplicas firmando con claves distintas porque se generan al arrancar. |
NTP en los nodos; margen de reloj de 30–60 s; clave de firma persistente y compartida; respetar el kid y refrescar el JWKS al ver uno desconocido. |
| Un XSS ha robado las sesiones de todos los usuarios | Token en localStorage: cualquier script de la página lo lee y lo exfiltra. |
Cookie HttpOnly+Secure+SameSite (patrón BFF), CSP estricta, escapado por contexto y revisión de las dependencias del frontend. |
| Contraseñas con SHA-256 (o MD5, o en claro) | Se usó un hash rápido de propósito general, pensando que “hash = seguro”. | DelegatingPasswordEncoder y migración perezosa con upgradeEncoding en el login (sección 2.2). No obliga a nadie a cambiar de contraseña. |
| Secreto en el repositorio | Un application.yml con la contraseña “solo para probar” que se quedó, y nadie tenía detección automática. |
Rotar primero; revisar logs de uso; reducir permisos; limpiar el historial; añadir gitleaks en hook y CI (sección 7.3). |
| Actuator abierto en producción | management.endpoints.web.exposure.include: "*" copiado de un tutorial. |
Lista blanca (health,info,prometheus), puerto de gestión separado y no publicado, show-values: never, y un test de configuración (sección A05). |
| Un cliente ha visto los pedidos de otro (IDOR) | El endpoint carga por id sin comprobar la propiedad: autenticado ≠ autorizado. |
La propiedad, dentro de la consulta (findByIdAndClienteNif); 404 en lugar de 403; test de “usuario ajeno” por endpoint; revisión de todos los endpoints con {id}. |
| Miles de intentos de login y cuentas comprometidas | Sin rate limiting ni bloqueo temporal: fuerza bruta y credential stuffing gratis. | Límite por IP y por cuenta, retroceso exponencial, captcha tras N fallos, MFA, comprobación contra listas de contraseñas filtradas, y límite duro en el WAF. |
| Se puede saber qué correos están registrados | Mensajes de error distintos (o tiempos de respuesta distintos) en login, registro y recuperación. | Mensaje y código idénticos; hash de compensación para igualar el tiempo; envío del correo de recuperación siempre, de forma asíncrona. |
| Dependencia vulnerable en producción durante meses | Sin SCA en CI y sin rutina de actualización: se descubre por un aviso externo. | Dependency-Check o Trivy con --exit-code 1 en cada PR, Dependabot semanal, SBOM archivado y priorización con EPSS/KEV. |
| RCE por deserialización | ObjectInputStream con datos externos, o Jackson con default typing abierto (enableDefaultTyping). |
JSON con tipos explícitos y DTO; polimorfismo cerrado con @JsonSubTypes o BasicPolymorphicTypeValidator; filtro JEP 290 si estás obligado a la serialización nativa. |
| TLS deshabilitado “solo en desarrollo” que llegó a producción | Un TrustManager que acepta todo o un HostnameVerifier que devuelve true, dentro del código y no en la configuración. |
Nunca en el código de producción: usa un truststore con la CA interna, o cert-manager. Añade una regla de SAST que falle el build si aparece ese patrón. |
| Logs con datos personales | log.info("Usuario: " + usuario) con un toString() que imprime todos los campos; cuerpos de peticiones registrados en producción. |
toString() con solo el id; logging estructurado con campos elegidos; filtro de enmascarado en Logback; revisión de logs nuevos en la revisión de código. |
| Un pago se cobró dos veces (o el saldo quedó negativo) | Leer-comprobar-escribir sin atomicidad, más reintentos de red sin idempotencia. | UPDATE … WHERE saldo >= importe atómico, CHECK en el esquema, clave de idempotencia con índice único (sección A04). |
SecurityContextHolder devuelve anonymousUser o null dentro de un @Async |
El contexto vive en un ThreadLocal que no se propaga a otros hilos. |
DelegatingSecurityContextExecutor / …Runnable, o pasa la identidad como parámetro explícito al trabajo asíncrono. |
| El logout no cierra la sesión de verdad | Con JWT, el cliente borra el token pero este sigue siendo válido; o con OIDC no se cierra la sesión en el proveedor de identidad. | Access tokens cortos + revocación del refresh token en el servidor; OidcClientInitiatedLogoutSuccessHandler para el logout federado; sesión en Redis si necesitas cierre inmediato. |
12. Preguntas frecuentes
¿JWT o sesión? ¿Cuál elijo para mi proyecto?
Depende de la arquitectura, no de la seguridad: ninguno es “más seguro”. Si tienes una
aplicación web con navegador (monolito o BFF) y quieres poder expulsar a alguien al instante, usa
sesión con cookie HttpOnly+Secure+SameSite: es más
simple, revocable y no expone nada al JavaScript. Si tienes APIs consumidas por móvil o por terceros,
microservicios que deben validar sin estado compartido, o federación con un proveedor de identidad, usa
JWT y asume el coste: tokens cortos, refresh rotativo y una estrategia de revocación.
Elegir JWT en un monolito con navegador es complicarse la vida y perder revocación a cambio de nada.
¿Dónde guardo el token en el navegador?
Preferentemente en una cookie HttpOnly+Secure+SameSite
gestionada por un BFF, con token CSRF. La segunda mejor opción es el access token en una variable
en memoria (se pierde al recargar) con el refresh en cookie HttpOnly.
Evita localStorage: cualquier XSS —incluido el que llegue por una
dependencia npm comprometida— lo lee y lo exfiltra, y el atacante lo usa desde su máquina durante días.
Matiz honesto: HttpOnly no anula el XSS (con XSS activo se pueden hacer peticiones en tu
nombre), pero impide el robo de la credencial, que es lo que convierte un incidente en una
catástrofe.
¿Qué es PKCE y por qué es obligatorio ahora?
Proof Key for Code Exchange. El cliente genera un secreto aleatorio (code_verifier)
y envía solo su hash SHA-256 (code_challenge) al iniciar el flujo; al canjear el código
presenta el verificador original. Así, un código interceptado —en la barra de direcciones, en el
historial, en un log de proxy o por otra app registrada en el mismo esquema de URL en móvil— es
inútil sin el verificador, que nunca sale de la memoria del cliente legítimo. En
OAuth 2.1 es obligatorio para todos los clientes, también los confidenciales, porque protege
además contra la inyección de código de autorización. Usa siempre S256, nunca
plain.
¿Hace falta CSRF en una API que usa JWT?
La pregunta correcta no es “¿uso JWT?”, sino “¿el navegador envía la credencial
automáticamente?”. Si el token viaja en la cabecera Authorization puesta por tu
JavaScript, no hace falta: el navegador nunca añade esa cabecera por su cuenta y un
origen ajeno no puede fabricarla. Si guardas el JWT en una cookie, entonces
sí hace falta, porque se comporta como cualquier cookie. Y si tu aplicación tiene una
parte web con sesión, no desactives CSRF globalmente: separa las dos cadenas con
securityMatcher.
¿Roles o authorities? ¿Y qué pasa con el prefijo ROLE_?
Técnicamente son lo mismo: GrantedAuthority es una cadena. La diferencia está en la
convención: hasRole("ADMIN") añade el prefijo y busca literalmente
ROLE_ADMIN, mientras que hasAuthority("X") busca X exacto. Si tu
GrantedAuthority no empieza por ROLE_, hasRole nunca coincidirá:
es la causa de la mitad de los 403 “inexplicables”. Recomendación de diseño: usa
roles como agrupación para asignar (“es SOPORTE”) pero autoriza por permiso fino
en el código (hasAuthority("pedidos:cancelar")). Así, cambiar quién puede cancelar un pedido
es una modificación de datos, no un despliegue.
¿Cómo hasheo contraseñas correctamente?
Con Argon2id (primera opción en proyectos nuevos) o BCrypt con coste
10–12, siempre a través de PasswordEncoder. Lo práctico es declarar un
PasswordEncoderFactories.createDelegatingPasswordEncoder(): codifica con el algoritmo actual
y sabe verificar los formatos históricos gracias al prefijo {bcrypt}, lo que te permite
cambiar de algoritmo sin migraciones. Verifica siempre con matches(), nunca comparando
hashes a mano, y usa upgradeEncoding() en el login para rehashear de forma
transparente. Nunca MD5, SHA-1 ni SHA-256 “con sal”: son demasiado rápidos y una GPU prueba miles de
millones por segundo.
Se ha filtrado una clave o un secreto. ¿Qué hago exactamente?
Rotar es lo primero y lo urgente; todo lo demás va después. Emite una credencial
nueva con el mínimo privilegio, despliega, invalida la antigua. Después: revisa los logs de uso
(CloudTrail o equivalente) para determinar si se usó y desde dónde, valora si hay incidente notificable,
limpia el historial con git filter-repo —sabiendo que eso no deshace la fuga: los
forks y los clones la conservan— y añade la detección automática que faltaba. Si la clave protegía datos
cifrados, además hay que recifrar: por eso conviene diseñar con cifrado en sobre y
versionado de claves desde el principio.
¿Cómo protejo un endpoint interno que solo debe llamar otro servicio?
Por capas, y nunca solo por red: (1) que no esté publicado en el ingress y esté en un
puerto o ruta aparte; (2) políticas de red que solo permitan el tráfico de los pods autorizados;
(3) autenticación de la carga de trabajo: mTLS con la malla de servicio, o un token de
client credentials con un scope propio; (4) autorización explícita en la aplicación
(hasAuthority("SCOPE_interno.pedidos")). Lo que no vale como control de
seguridad: comprobar la IP de origen, confiar en una cabecera que puede falsificar cualquiera
(X-Internal: true) o suponer que “está en la VPN”. Eso es seguridad de cáscara de huevo.
¿Qué es mTLS y cuándo lo necesito?
TLS mutuo: además de que el cliente valide el certificado del servidor, el servidor pide y
valida el certificado del cliente. Resultado: identidad criptográfica en ambos extremos, a nivel
de conexión, sin secretos compartidos que rotar. Se usa para servicio a servicio (es la
base de Istio y Linkerd, que lo aplican de forma transparente y rotan los certificados solos), para
integraciones con socios en banca y sanidad, y para autenticar clientes OAuth2 sin
client_secret. Para usuarios finales es poco práctico (distribuir y renovar certificados en
cada dispositivo). Importante: mTLS autentica el servicio, no al usuario: complementa
los tokens, no los sustituye.
¿Cómo hago logout con JWT?
Aquí está la factura de haber elegido JWT: un token firmado no se puede “desfirmar”. Lo que se hace en
la práctica: (1) el cliente borra el token —necesario pero insuficiente, porque una copia robada sigue
valiendo—; (2) el servidor revoca el refresh token, que sí tiene estado, de modo que no
se puedan obtener más accesos; (3) el access token expira solo en 5–15 minutos, que es la ventana que has
aceptado al diseñar; (4) si necesitas corte inmediato, añade una denylist por jti en
Redis o una versión de credenciales en el token que invalide todos los anteriores. Y si
usas OIDC, llama al end_session_endpoint del proveedor
(OidcClientInitiatedLogoutSuccessHandler) o el usuario seguirá con sesión abierta allí y
volverá a entrar sin teclear nada.
¿Cómo evito los IDOR de forma sistemática?
Con un patrón, no con disciplina individual. Mete la propiedad en la consulta
(findByIdAndClienteNif) en lugar de cargar y comprobar después: así es imposible olvidarlo,
no cargas datos ajenos y devuelves 404 en lugar de 403, sin confirmar la existencia del recurso. Añade
una segunda barrera con @PreAuthorize o un AuthorizationManager; usa UUID en
las URLs públicas (ofuscación útil, no mitigación); y, sobre todo, escribe un test por
endpoint del tipo “usuario ajeno → 404”. Cuando revises un proyecto heredado, empieza buscando
todos los @PathVariable y @RequestParam que sean identificadores.
¿Cómo pruebo la seguridad de mi aplicación?
En cuatro niveles, del más barato al más caro. Tests automáticos con
spring-security-test: “anónimo → 401”, “sin permiso → 403”, “usuario ajeno → 404”, cabeceras
presentes, y tests de configuración (Actuator restringido, sin perfiles de desarrollo).
SCA en cada PR: el mejor retorno por esfuerzo invertido. SAST
(SpotBugs con find-sec-bugs, Semgrep, CodeQL) aceptando que habrá falsos positivos. Y DAST y
pentest sobre un entorno desplegado (OWASP ZAP en CI nocturna, Burp a mano) para lo que no se ve
en el código. Para practicar de forma legal y sin riesgo: PortSwigger Web Security Academy, OWASP Juice
Shop y WebGoat.
¿Qué es SSRF y por qué es tan grave en la nube?
Server-Side Request Forgery: consigues que el servidor haga una petición a
una URL que tú controlas. El atacante no tiene acceso a la red interna, pero tu servidor sí, así que le
sirves de proxy: servicios internos que confían en localhost, la API del clúster, escaneo de
puertos internos… En la nube es especialmente crítico porque el servicio de metadatos
(169.254.169.254) puede entregar credenciales temporales del rol de la instancia, y con eso
el atacante pasa de “leer una URL” a “tener permisos en tu cuenta”. Mitigación en dos planos: en código,
lista blanca de host exacta, solo https, sin seguir redirecciones y comprobando que
ninguna IP resuelta sea interna; y en arquitectura, proxy de egreso, políticas de red y bloqueo del
endpoint de metadatos (IMDSv2 con límite de saltos 1). La parte arquitectónica es la robusta, porque el
DNS rebinding puede burlar la validación en código.
En microservicios, ¿quién valida el token: el gateway o cada servicio?
Los dos. El gateway valida en el borde y rechaza pronto lo inválido, lo que ahorra trabajo y da un punto único para el rate limiting y el registro. Pero cada servicio vuelve a validar el token que recibe, incluida su audiencia: eso es confianza cero. Si solo valida el gateway, cualquiera que alcance la red interna —un SSRF, un pod comprometido, un trabajo programado mal configurado— habla con tus servicios sin credenciales. El coste de validar es una verificación de firma con la clave pública cacheada: microsegundos. No hay excusa de rendimiento.
¿Cómo gestiono secretos en Kubernetes?
Por orden de preferencia: (1) identidad de la carga de trabajo (IRSA, Workload
Identity, Managed Identity), que elimina el secreto porque el pod obtiene credenciales temporales por su
identidad; (2) un gestor externo (Vault, Secrets Manager, Key Vault) vía External Secrets
Operator o el CSI driver, con rotación automática y auditoría de accesos; (3) Sealed Secrets o
SOPS si trabajas con GitOps y necesitas versionar el secreto cifrado; (4) Secret
nativo, recordando que es Base64, no cifrado, y que requiere cifrado en reposo en etcd y
RBAC estricto. Preferiblemente montado como fichero y no como variable de entorno (las
variables aparecen en describe pod, en volcados y en los logs de arranque de muchos
frameworks). Y jamás en el application.yml del repositorio.
¿Es seguro poner datos en un JWT? He leído que va “cifrado”.
No va cifrado. Un JWS es Base64URL, que es codificación, no cifrado: cualquiera con el token lee todo el contenido con un comando. La firma garantiza integridad y autenticidad, no confidencialidad. Por tanto: no metas datos personales innecesarios, ni DNI, ni direcciones, ni secretos, ni datos de salud. Mete un identificador y los claims mínimos de autorización. Si de verdad necesitas confidencialidad del contenido, existe JWE (cifrado), pero casi siempre es más simple usar un token opaco y consultar el endpoint de introspección del emisor.
¿Necesito un WAF si mi código es seguro?
El WAF no sustituye al código seguro: es defensa en profundidad y, sobre todo, tiempo. Su valor real es que te permite mitigar en minutos una vulnerabilidad cuyo parche tardará días (fue exactamente lo que se hizo con Log4Shell), absorbe el ruido automatizado que llena tus logs y añade rate limiting y protección DDoS en el borde, donde el volumen se aguanta. Sus límites: genera falsos positivos que rompen funcionalidad legítima, se puede evadir con codificaciones creativas y no entiende nada de tu lógica de negocio, así que no detectará un IDOR ni un cupón reutilizable. Ponlo, y no lo uses como excusa para no arreglar el código.
¿Cada cuánto debo obligar a cambiar la contraseña?
Nunca, de forma periódica. La guía actual (NIST SP 800-63B, y también el criterio del
NCSC británico) descarta la rotación obligatoria: produce contraseñas predecibles
(Verano2026! → Otoño2026!), reutilización y notas adhesivas. Fuerza el cambio
solo cuando haya indicio de compromiso. Lo que sí funciona: longitud mínima de 8 (recomendado
12+) y máxima de al menos 64, permitir todos los caracteres y espacios, comprobar contra listas
de contraseñas filtradas, MFA (idealmente passkeys) y detección de accesos anómalos. Es menos
molesto para el usuario y objetivamente más seguro.
13. Ejercicios y retos
Ejercicios guiados
Retos
14. Resumen y recursos
Las 15 ideas que debes llevarte
- La seguridad es una propiedad del diseño, no una capa que se añade al final. Modelar amenazas una hora en la pizarra rinde más que un mes de parches.
- Todo requisito se expresa en términos de confidencialidad, integridad y disponibilidad, más autenticidad y no repudio.
- Defensa en profundidad y mínimo privilegio son los dos principios que más incidentes evitan. “Estamos detrás del firewall” no es un argumento.
- La mayoría de los fallos graves son de autorización, no de autenticación: el atacante suele ser un usuario legítimo mirando datos que no son suyos.
- No implementes criptografía. Elige bien (Argon2id o BCrypt para contraseñas, AES-GCM con IV aleatorio para datos, TLS 1.3 en el canal) y gestiona las claves en un KMS.
- En Spring Security 6, entender la cadena de filtros convierte los errores misteriosos en diagnósticos de treinta segundos.
- Configura con beans y lambda DSL, de lo específico a lo general, y con
anyRequest().authenticated()al final: deny by default. - Autoriza también en la capa de servicio: la autorización por URL es frágil y no cubre colas, tareas programadas ni llamadas internas.
- La propiedad del recurso va dentro de la consulta. Es la forma sistemática de eliminar los IDOR, que son la vulnerabilidad número uno.
- Un JWT no es más seguro que una sesión. Es una decisión de arquitectura: ganas escalado, pierdes revocación. Elige a conciencia.
- Validar un token es validar firma, algoritmo,
iss,aud,exp/nbfykid. Olvidaraudes el fallo más común. - OAuth 2 autoriza, OpenID Connect autentica. El flujo correcto para casi todo es Authorization Code + PKCE; Implicit y Password grant están fuera.
- El OWASP Top 10 no es una lista para memorizar: es un mapa de las diez formas en que tu aplicación va a fallar si no lo evitas a propósito.
- Tú escribes el 2% del código que ejecutas. La cadena de suministro —dependencias, SBOM, firma, secretos, CI— es tan importante como tu propio código.
- Si no hay logs, alertas y un test automático, la medida de seguridad no existe: nadie sabrá que ha fallado y volverá a romperse en el próximo refactor.
Recursos imprescindibles
- OWASP Top 10 (
owasp.org/Top10): el punto de partida y lo que preguntan en las entrevistas. - OWASP ASVS (Application Security Verification Standard): requisitos verificables por niveles. Úsalo como checklist de proyecto, es lo que el Top 10 no es.
- OWASP Cheat Sheet Series: la referencia práctica. Fíjate en las de Password Storage, Authentication, JWT for Java, SSRF Prevention, Deserialization y Secrets Management.
- Documentación de Spring Security: excelente y actualizada. Lee entera la sección de arquitectura y la de autorización.
- oauth.net y las RFC: 6749 (OAuth 2), 7636 (PKCE), 8693 (Token Exchange), 8252 (apps nativas), 9700 (buenas prácticas actuales) y el borrador de OAuth 2.1.
Para profundizar y practicar
- “OAuth 2 in Action” (Richer y Sanso, Manning): el mejor libro para entender OAuth de verdad, construyéndolo paso a paso.
- Documentación de Keycloak: guía del administrador y de securización de aplicaciones.
- PortSwigger Web Security Academy: gratuita, con laboratorios legales y de calidad excepcional. Es lo que más te va a enseñar por hora invertida.
- OWASP Juice Shop y WebGoat: aplicaciones vulnerables a propósito para practicar sin riesgo.
- Snyk, Trivy, Grype, Dependency-Check, gitleaks, cosign: las herramientas que deberías tener en CI mañana.
- NIST SP 800-63B (identidad y contraseñas), NIST SP 800-61 (respuesta a incidentes), CIS Benchmarks (endurecimiento), AEPD (guías de RGPD en español).