Permite solicitudes cross-origin desde cualquier dominio, exponiendo endpoints sensibles a ataques CSRF y robo de sesión desde sitios maliciosos.
// ❌ Wildcard — cualquier origen puede llamar la API
config.setAllowedOrigins(List.of("*"));
config.setAllowCredentials(true); // Error: incompatible con *
@Bean
public CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(List.of(
"https://app.talentflow.io",
"https://admin.talentflow.io"
));
config.setAllowedMethods(List.of("GET","POST","PUT","DELETE"));
config.setAllowedHeaders(List.of("Authorization","Content-Type"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source =
new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", config);
return source;
}
JWT secret y contraseña de BD expuestos en el repositorio. Cualquier acceso al código fuente compromete toda la infraestructura.
# ❌ Nunca en el repo
spring:
datasource:
password: Talent2024!DB#Prod
app:
jwt:
secret: mySuper$ecretKey123!
expiration: 86400000
# ✅ Variables de entorno — nunca en git
spring:
datasource:
password: ${DB_PASSWORD}
url: ${DB_URL}
app:
jwt:
secret: ${JWT_SECRET} # min 256-bit
expiration: ${JWT_EXPIRATION_MS:3600000}
# .env.example (sí en git, sin valores)
# DB_PASSWORD=
# JWT_SECRET=
Query nativa con concatenación de strings permite inyección SQL. Un atacante puede extraer o modificar toda la base de datos de candidatos.
// ❌ Concatenación = SQL Injection
@Query(value = "SELECT * FROM candidates"
+ " WHERE name = '" + name + "'",
nativeQuery = true)
List<Candidate> searchByName(String name);
// ✅ Parámetro vinculado — safe
@Query(value = "SELECT * FROM candidates"
+ " WHERE name = :name",
nativeQuery = true)
List<Candidate> searchByName(
@Param("name") String name);
// ✅ Mejor aún: Spring Data derivado
List<Candidate> findByNameContainingIgnoreCase(
String name);
El endpoint de login no tiene throttling. Un atacante puede hacer miles de intentos por segundo para adivinar contraseñas.
@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
public class LoginRateLimitFilter extends OncePerRequestFilter {
private final Map<String, Bucket> loginBuckets = new ConcurrentHashMap<>();
private Bucket newLoginBucket() {
return Bucket.builder()
.addLimit(Bandwidth.classic(5, // 5 intentos
Refill.intervally(5, Duration.ofMinutes(15)))) // cada 15 min
.build();
}
@Override
protected void doFilterInternal(HttpServletRequest req,
HttpServletResponse res, FilterChain chain)
throws ServletException, IOException {
if (req.getMethod().equals("POST")
&& req.getRequestURI().equals("/api/auth/login")) {
String ip = req.getRemoteAddr();
Bucket bucket = loginBuckets.computeIfAbsent(ip, k -> newLoginBucket());
if (!bucket.tryConsume(1)) {
res.setStatus(429);
res.setHeader("Retry-After", "900");
res.getWriter().write("{\"error\":\"Too many login attempts\"}");
return;
}
}
chain.doFilter(req, res);
}
}
Los DTOs de candidato y oferta no tienen anotaciones Bean Validation. Un atacante puede enviar payloads maliciosos que se almacenan y ejecutan en el frontend.
// ✅ DTO con validación completa
public record CreateCandidateDto(
@NotBlank @Size(max = 100)
@Pattern(regexp = "^[\\p{L}\\s'-]+$", message = "Nombre inválido")
String name,
@NotBlank @Email @Size(max = 255)
String email,
@Size(max = 2000)
@NotNull
String bio, // sanitizar HTML antes de guardar
@NotNull @Min(0) @Max(100)
Integer experienceYears,
@Valid @NotEmpty
List<@NotBlank @Size(max=50) String> skills
) {}
// En el controlador:
@PostMapping("/candidates")
@PreAuthorize("hasAnyRole('ADMIN','RECRUITER')")
public ResponseEntity<CandidateDto> create(
@Valid @RequestBody CreateCandidateDto dto) {
return ResponseEntity.status(201).body(service.create(dto));
}
Sin Content-Security-Policy, X-Frame-Options ni HSTS. Expone la app a clickjacking, sniffing y ataques XSS reflejado.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
return http
.headers(headers -> headers
.contentSecurityPolicy(csp -> csp.policyDirectives(
"default-src 'self'; " +
"script-src 'self'; " +
"style-src 'self' 'unsafe-inline'; " +
"img-src 'self' data: https://storage.talentflow.io; " +
"frame-ancestors 'none'"))
.frameOptions(HeadersConfigurer.FrameOptionsConfig::deny)
.xssProtection(Customizer.withDefaults())
.httpStrictTransportSecurity(hsts -> hsts
.maxAgeInSeconds(31536000).includeSubDomains(true))
.referrerPolicy(rp -> rp.policy(
ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN))
)
.cors(cors -> cors.configurationSource(corsConfigurationSource()))
.csrf(csrf -> csrf.disable()) // API stateless con JWT
.sessionManagement(sm -> sm
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/auth/**", "/actuator/health").permitAll()
.anyRequest().authenticated()) // deny-by-default
.addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterBefore(loginRateLimitFilter, JwtAuthFilter.class)
.build();
}
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
// O Argon2 para mayor resistencia:
// return new Argon2PasswordEncoder(
// 16, 32, 1, 65536, 3);
}
<plugin>
<groupId>org.owasp</groupId>
<artifactId>dependency-check-maven</artifactId>
<version>9.2.0</version>
<configuration>
<failBuildOnCVSS>7</failBuildOnCVSS>
<formats>HTML,JSON</formats>
</configuration>
</plugin>
log.debug("Auth request: {}", dto);
// Imprime {email: x, password: secret123}
log.debug("Auth attempt for: {}", dto.email());
// Nunca loguear password, token ni datos PII sensibles
// Usar JSON logging estructurado con Logback+ECS