Skip to content

22. Seguridad Avanzada

🛡️ 22.1 Protección contra Ataques Comunes

Section titled “🛡️ 22.1 Protección contra Ataques Comunes”
Prevención 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 API
public 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();
}
Dependencia OWASP Encoder
// Dependencia para sanitización
<dependency>
<groupId>org.owasp.encoder</groupId>
<artifactId>encoder</artifactId>
<version>1.2.3</version>
</dependency>
Prevención XSS
@Service
public 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 DTO
public record ComentarioDTO(
@NotBlank
@Pattern(regexp = "^[a-zA-Z0-9\s.,!?áéíóúÁÉÍÓÚñÑ]+$",
message = "Caracteres no permitidos")
String contenido
) {}
// Content Security Policy
@Configuration
public 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();
}
}
Configuración CSRF
@Configuration
public 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 CORS global
@Configuration
public 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
@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
}
}

Dependencia Bucket4j
<dependency>
<groupId>com.bucket4j</groupId>
<artifactId>bucket4j-core</artifactId>
<version>8.7.0</version>
</dependency>
Rate Limiting con Bucket4j
@Component
public 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
@Service
public 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);
}
}

Entidad auditable
@MappedSuperclass
@EntityListeners(AuditingEntityListener.class)
@Getter @Setter
public 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());
};
}
}
Sistema de auditoría
@Entity
@Table(name = "audit_log")
@Getter @Setter
public 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
@Slf4j
public 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) {
// ...
}

Encriptación de campos
@Converter
public 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
@Entity
public class Usuario {
@Convert(converter = EncryptionConverter.class)
private String numeroTarjeta;
@Convert(converter = EncryptionConverter.class)
private String ssn;
}

Password Encoder
@Configuration
public 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
@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;
}
}
// Uso
public record RegistroDTO(
@Email String email,
@ValidPassword String password
) {}

Headers de seguridad
@Configuration
public 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();
}
}
🐝