22. Seguridad Avanzada
🛡️ 22.1 Protección contra Ataques Comunes
Section titled “🛡️ 22.1 Protección contra Ataques Comunes”SQL Injection
Section titled “SQL Injection”// ❌ VULNERABLE: Concatenación de strings@Query("SELECT u FROM Usuario u WHERE u.email = '" + email + "'")Usuario findByEmailInseguro(String email);
// ✅ SEGURO: Parámetros nombrados@Query("SELECT u FROM Usuario u WHERE u.email = :email")Usuario findByEmail(@Param("email") String email);
// ✅ SEGURO: Query methods (Spring Data)Optional<Usuario> findByEmail(String email);
// ✅ SEGURO: Criteria APIpublic List<Usuario> buscar(String email) { CriteriaBuilder cb = entityManager.getCriteriaBuilder(); CriteriaQuery<Usuario> query = cb.createQuery(Usuario.class); Root<Usuario> root = query.from(Usuario.class);
query.where(cb.equal(root.get("email"), email));
return entityManager.createQuery(query).getResultList();}XSS (Cross-Site Scripting)
Section titled “XSS (Cross-Site Scripting)”// Dependencia para sanitización<dependency> <groupId>org.owasp.encoder</groupId> <artifactId>encoder</artifactId> <version>1.2.3</version></dependency>@Servicepublic class SanitizacionService {
public String sanitizarHTML(String input) { if (input == null) return null; return Encode.forHtml(input); }
public String sanitizarJS(String input) { if (input == null) return null; return Encode.forJavaScript(input); }}
// Validación en DTOpublic record ComentarioDTO( @NotBlank @Pattern(regexp = "^[a-zA-Z0-9\s.,!?áéíóúÁÉÍÓÚñÑ]+$", message = "Caracteres no permitidos") String contenido) {}
// Content Security Policy@Configurationpublic class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.headers(headers -> headers .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; script-src 'self'; style-src 'self'")) .xssProtection(xss -> xss.headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK)) ); return http.build(); }}CSRF (Cross-Site Request Forgery)
Section titled “CSRF (Cross-Site Request Forgery)”@Configurationpublic class SecurityConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // Para APIs REST con JWT, CSRF puede deshabilitarse .csrf(csrf -> csrf.disable())
// Para aplicaciones con sesión, habilitar CSRF .csrf(csrf -> csrf .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()) .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) );
return http.build(); }}
// En el frontend (con cookies)// El token CSRF se envía automáticamente en el header X-XSRF-TOKEN🌐 22.2 CORS (Cross-Origin Resource Sharing)
Section titled “🌐 22.2 CORS (Cross-Origin Resource Sharing)”Configuración global
Section titled “Configuración global”@Configurationpublic class CorsConfig {
@Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration();
// Orígenes permitidos configuration.setAllowedOrigins(List.of( "http://localhost:3000", "https://miapp.com" ));
// O con patrones configuration.setAllowedOriginPatterns(List.of( "https://*.miapp.com" ));
// Métodos permitidos configuration.setAllowedMethods(List.of( "GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS" ));
// Headers permitidos configuration.setAllowedHeaders(List.of( "Authorization", "Content-Type", "X-Requested-With" ));
// Headers expuestos al cliente configuration.setExposedHeaders(List.of( "X-Total-Count", "X-Page-Number" ));
// Permitir credenciales (cookies, auth headers) configuration.setAllowCredentials(true);
// Tiempo de caché del preflight configuration.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); source.registerCorsConfiguration("/api/**", configuration);
return source; }}CORS por controller
Section titled “CORS por controller”@RestController@RequestMapping("/api/public")@CrossOrigin(origins = "*", maxAge = 3600)public class PublicController {
@GetMapping("/info") public InfoDTO getInfo() { return new InfoDTO("v1.0"); }}
// Por método@RestController@RequestMapping("/api/productos")public class ProductoController {
@CrossOrigin(origins = "https://admin.miapp.com") @DeleteMapping("/{id}") public void eliminar(@PathVariable Long id) { // Solo desde admin }}⏱️ 22.3 Rate Limiting
Section titled “⏱️ 22.3 Rate Limiting”Con Bucket4j
Section titled “Con Bucket4j”<dependency> <groupId>com.bucket4j</groupId> <artifactId>bucket4j-core</artifactId> <version>8.7.0</version></dependency>@Componentpublic class RateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();
@Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String clientIP = getClientIP(request); Bucket bucket = buckets.computeIfAbsent(clientIP, this::createBucket);
if (bucket.tryConsume(1)) { filterChain.doFilter(request, response); } else { response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value()); response.setContentType("application/json"); response.getWriter().write(""" {"error": "Rate limit exceeded", "retryAfter": 60} """); } }
private Bucket createBucket(String key) { // 100 requests por minuto Bandwidth limit = Bandwidth.classic(100, Refill.greedy(100, Duration.ofMinutes(1))); return Bucket.builder().addLimit(limit).build(); }
private String getClientIP(HttpServletRequest request) { String xForwardedFor = request.getHeader("X-Forwarded-For"); if (xForwardedFor != null) { return xForwardedFor.split(",")[0].trim(); } return request.getRemoteAddr(); }}Rate limiting por usuario
Section titled “Rate limiting por usuario”@Servicepublic class RateLimitService {
private final Map<String, Bucket> userBuckets = new ConcurrentHashMap<>();
// Diferentes límites por rol public Bucket getBucketForUser(String userId, String role) { return userBuckets.computeIfAbsent(userId, id -> { if ("ADMIN".equals(role)) { return createBucket(1000, Duration.ofMinutes(1)); // 1000/min } else if ("PREMIUM".equals(role)) { return createBucket(500, Duration.ofMinutes(1)); // 500/min } else { return createBucket(100, Duration.ofMinutes(1)); // 100/min } }); }
private Bucket createBucket(int capacity, Duration period) { Bandwidth limit = Bandwidth.classic(capacity, Refill.greedy(capacity, period)); return Bucket.builder().addLimit(limit).build(); }
public boolean tryConsume(String userId, String role) { return getBucketForUser(userId, role).tryConsume(1); }}📝 22.4 Auditoría
Section titled “📝 22.4 Auditoría”Entidad auditable
Section titled “Entidad auditable”@MappedSuperclass@EntityListeners(AuditingEntityListener.class)@Getter @Setterpublic abstract class AuditableEntity {
@CreatedDate @Column(updatable = false) private LocalDateTime createdAt;
@LastModifiedDate private LocalDateTime updatedAt;
@CreatedBy @Column(updatable = false) private String createdBy;
@LastModifiedBy private String updatedBy;}
// Habilitar auditoría@Configuration@EnableJpaAuditing(auditorAwareRef = "auditorProvider")public class AuditConfig {
@Bean public AuditorAware<String> auditorProvider() { return () -> { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth == null || !auth.isAuthenticated()) { return Optional.of("SYSTEM"); } return Optional.of(auth.getName()); }; }}Log de auditoría
Section titled “Log de auditoría”@Entity@Table(name = "audit_log")@Getter @Setterpublic class AuditLog {
@Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id;
private String usuario; private String accion; private String entidad; private String entidadId; private String datosAnteriores; private String datosNuevos; private String ip; private LocalDateTime timestamp;}
@Aspect@Component@Slf4jpublic class AuditAspect {
private final AuditLogRepository auditRepository; private final ObjectMapper objectMapper;
@AfterReturning( pointcut = "@annotation(auditable)", returning = "result" ) public void audit(JoinPoint joinPoint, Auditable auditable, Object result) { try { AuditLog log = new AuditLog(); log.setUsuario(getCurrentUser()); log.setAccion(auditable.accion()); log.setEntidad(auditable.entidad()); log.setTimestamp(LocalDateTime.now()); log.setIp(getCurrentIP());
if (result != null) { log.setDatosNuevos(objectMapper.writeValueAsString(result)); }
auditRepository.save(log); } catch (Exception e) { log.error("Error en auditoría", e); } }}
// Anotación personalizada@Target(ElementType.METHOD)@Retention(RetentionPolicy.RUNTIME)public @interface Auditable { String accion(); String entidad();}
// Uso@Auditable(accion = "CREAR", entidad = "Producto")public Producto crear(ProductoDTO dto) { // ...}🔐 22.5 Encriptación de Datos
Section titled “🔐 22.5 Encriptación de Datos”Encriptar campos sensibles
Section titled “Encriptar campos sensibles”@Converterpublic class EncryptionConverter implements AttributeConverter<String, String> {
private static final String ALGORITHM = "AES/GCM/NoPadding"; private final SecretKey secretKey;
public EncryptionConverter(@Value("${encryption.key}") String key) { byte[] decodedKey = Base64.getDecoder().decode(key); this.secretKey = new SecretKeySpec(decodedKey, "AES"); }
@Override public String convertToDatabaseColumn(String attribute) { if (attribute == null) return null;
try { Cipher cipher = Cipher.getInstance(ALGORITHM); byte[] iv = new byte[12]; SecureRandom.getInstanceStrong().nextBytes(iv);
cipher.init(Cipher.ENCRYPT_MODE, secretKey, new GCMParameterSpec(128, iv)); byte[] encrypted = cipher.doFinal(attribute.getBytes(StandardCharsets.UTF_8));
byte[] combined = new byte[iv.length + encrypted.length]; System.arraycopy(iv, 0, combined, 0, iv.length); System.arraycopy(encrypted, 0, combined, iv.length, encrypted.length);
return Base64.getEncoder().encodeToString(combined); } catch (Exception e) { throw new RuntimeException("Error encriptando", e); } }
@Override public String convertToEntityAttribute(String dbData) { if (dbData == null) return null;
try { byte[] combined = Base64.getDecoder().decode(dbData); byte[] iv = Arrays.copyOfRange(combined, 0, 12); byte[] encrypted = Arrays.copyOfRange(combined, 12, combined.length);
Cipher cipher = Cipher.getInstance(ALGORITHM); cipher.init(Cipher.DECRYPT_MODE, secretKey, new GCMParameterSpec(128, iv));
return new String(cipher.doFinal(encrypted), StandardCharsets.UTF_8); } catch (Exception e) { throw new RuntimeException("Error desencriptando", e); } }}
// Uso en entidad@Entitypublic class Usuario {
@Convert(converter = EncryptionConverter.class) private String numeroTarjeta;
@Convert(converter = EncryptionConverter.class) private String ssn;}🔑 22.6 Gestión Segura de Contraseñas
Section titled “🔑 22.6 Gestión Segura de Contraseñas”Password Encoder
Section titled “Password Encoder”@Configurationpublic class PasswordConfig {
@Bean public PasswordEncoder passwordEncoder() { // BCrypt con strength 12 (recomendado) return new BCryptPasswordEncoder(12); }
// O con Argon2 (más seguro, más lento) @Bean public PasswordEncoder argon2Encoder() { return new Argon2PasswordEncoder(16, 32, 1, 65536, 3); }}Validación de contraseñas
Section titled “Validación de contraseñas”@Target({ElementType.FIELD})@Retention(RetentionPolicy.RUNTIME)@Constraint(validatedBy = PasswordValidator.class)public @interface ValidPassword { String message() default "Contraseña no cumple requisitos"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {};}
public class PasswordValidator implements ConstraintValidator<ValidPassword, String> {
@Override public boolean isValid(String password, ConstraintValidatorContext context) { if (password == null) return false;
// Mínimo 8 caracteres if (password.length() < 8) return false;
// Al menos una mayúscula if (!password.matches(".*[A-Z].*")) return false;
// Al menos una minúscula if (!password.matches(".*[a-z].*")) return false;
// Al menos un número if (!password.matches(".*\d.*")) return false;
// Al menos un carácter especial if (!password.matches(".*[@#$%^&+=!].*")) return false;
// No contiene espacios if (password.contains(" ")) return false;
return true; }}
// Usopublic record RegistroDTO( @Email String email, @ValidPassword String password) {}🔒 22.7 Headers de Seguridad
Section titled “🔒 22.7 Headers de Seguridad”@Configurationpublic class SecurityHeadersConfig {
@Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http.headers(headers -> headers // Prevenir clickjacking .frameOptions(frame -> frame.deny())
// Content Security Policy .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; " + "script-src 'self' 'unsafe-inline'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self'; " + "connect-src 'self' https://api.miapp.com"))
// Strict Transport Security (HTTPS) .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .maxAgeInSeconds(31536000))
// Prevenir MIME sniffing .contentTypeOptions(Customizer.withDefaults())
// XSS Protection .xssProtection(xss -> xss .headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
// Referrer Policy .referrerPolicy(referrer -> referrer .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN))
// Permissions Policy .permissionsPolicy(permissions -> permissions .policy("geolocation=(), microphone=(), camera=()")) );
return http.build(); }}
🐝