View source on GitHub

Module 57 - Security Hardening

What this module covers

Spring Security configuration for a REST API, OWASP Top 10 defences in practice, OWASP Dependency Check for CVE scanning, and HashiCorp Vault for secrets management. Tests verify access-control policy using @WithMockUser and security headers using Spring MockMvc.


Project structure

src/main/java/com/javatraining/security/
├── SecurityHardeningApplication.java
├── config/
│   └── SecurityConfig.java      # Spring Security: auth, authz, headers, BCrypt
└── api/
    ├── RegisterRequest.java     # record with @NotBlank, @Size validation
    ├── PublicController.java    # GET /api/public/info  - no auth
    ├── AuthController.java      # POST /api/auth/register - no auth, @Valid
    ├── UserController.java      # GET /api/user/me - USER or ADMIN
    └── AdminController.java     # DELETE /api/admin/users/{id} - ADMIN only

src/test/java/com/javatraining/security/
├── AccessControlTest.java       # @WithMockUser: 401, 403, 200 (3 tests)
└── SecurityFeaturesTest.java    # security headers + input validation (2 tests)

OWASP Top 10 coverage

# Vulnerability Defence in this module
A01 Broken Access Control hasRole("ADMIN") on admin paths; 401 for unauthenticated, 403 for wrong role
A02 Cryptographic Failures BCryptPasswordEncoder(12) - ~300ms per hash, rainbow tables impractical
A03 Injection Bean Validation on RegisterRequest; Spring Data JPA uses parameterized queries
A05 Security Misconfiguration headers(Customizer.withDefaults()) - DENY framing, nosniff, cache control
A07 Identification & Auth Failures Spring Security rejects unauthenticated requests before reaching controllers
A09 Security Logging & Monitoring Spring Security logs auth failures; add audit log in AuthenticationEventPublisher

Spring Security configuration

@Bean
SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
    return http
        .authorizeHttpRequests(auth -> auth
            .requestMatchers("/api/public/**").permitAll()
            .requestMatchers("/api/auth/**").permitAll()
            .requestMatchers("/api/admin/**").hasRole("ADMIN")
            .anyRequest().authenticated()           // all other paths need a valid user
        )
        .httpBasic(Customizer.withDefaults())
        .csrf(AbstractHttpConfigurer::disable)      // stateless API - no session cookies
        .headers(Customizer.withDefaults())         // all security headers enabled
        .build();
}

CSRF note

CSRF attacks exploit session cookies. Stateless APIs authenticating with tokens (Bearer JWT, Basic Auth header) are not vulnerable to CSRF - disabling CSRF protection is safe and removes the overhead of CSRF token synchronisation. Session-based apps (Thymeleaf, MVC form submissions) must keep CSRF enabled.

Security headers set by headers(Customizer.withDefaults())

Header Value Protects against
X-Content-Type-Options nosniff MIME-type confusion attacks
X-Frame-Options DENY Clickjacking (iframes)
X-XSS-Protection 0 Disabled - modern browsers use CSP; this header is obsolete
Cache-Control no-store Sensitive data cached in browser history
Strict-Transport-Security max-age=31536000 HTTP downgrade (added for HTTPS requests only)

Password hashing - BCrypt

@Bean
PasswordEncoder passwordEncoder() {
    return new BCryptPasswordEncoder(12);  // work factor 12 ≈ 300ms per hash
}

BCrypt is a one-way adaptive hash - the work factor (cost) increases hashing time, and can be raised as hardware speeds up without invalidating existing hashes (the factor is stored in the hash string).

What not to use

Algorithm Problem
MD5, SHA-1 Not designed for passwords - microseconds to hash, rainbow tables exist
SHA-256 (unsalted) Same: fast, no salt by default
Plain text Obviously never

PasswordEncoderFactories.createDelegatingPasswordEncoder() returns a delegating encoder that stores the algorithm ID in the hash ({bcrypt}$2a$12$...), enabling migration to stronger algorithms without forcing a password reset.


Input validation - OWASP A03 (Injection prevention)

public record RegisterRequest(
    @NotBlank(message = "Username must not be blank")
    String username,

    @NotBlank
    @Size(min = 8, message = "Password must be at least 8 characters")
    String password
) {}

@PostMapping("/register")
@ResponseStatus(HttpStatus.CREATED)
public Map<String, String> register(@Valid @RequestBody RegisterRequest request) { ... }

@Valid triggers MethodArgumentNotValidException before the method body runs. Spring Boot’s default error handler maps this to HTTP 400 Bad Request.

Beyond this, JPA’s parameterized queries prevent SQL injection - user input is always bound as a parameter, never concatenated into SQL:

userRepository.findByUsername(username);     // safe: Spring Data generates a prepared statement
// Never: entityManager.createQuery("... WHERE u.name = '" + username + "'");

Testing with @WithMockUser

@SpringBootTest
@AutoConfigureMockMvc
class AccessControlTest {

    @Test
    void unauthenticated_request_to_protected_endpoint_returns_401() throws Exception {
        mockMvc.perform(get("/api/user/me"))
                .andExpect(status().isUnauthorized());
    }

    @Test
    @WithMockUser(roles = "USER")
    void user_role_cannot_access_admin_endpoint_returns_403() throws Exception {
        mockMvc.perform(delete("/api/admin/users/1"))
                .andExpect(status().isForbidden());
    }

    @Test
    @WithMockUser(roles = "ADMIN")
    void admin_role_can_access_admin_endpoint_returns_200() throws Exception {
        mockMvc.perform(delete("/api/admin/users/1"))
                .andExpect(status().isOk());
    }
}

@WithMockUser injects a synthetic SecurityContext directly - no HTTP credentials are submitted, the UserDetailsService is not called. This isolates the access-control policy under test from the authentication mechanism.

roles = "USER" → authority ROLE_USER. hasRole("ADMIN") → checks ROLE_ADMIN. No match → 403.


OWASP Dependency Check

<plugin>
    <groupId>org.owasp</groupId>
    <artifactId>dependency-check-maven</artifactId>
    <version>10.0.4</version>
    <configuration>
        <!-- Fail the build if any dependency has a CVSS score >= 7 (High) -->
        <failBuildOnCVSS>7</failBuildOnCVSS>
        <suppressionFile>dependency-check-suppressions.xml</suppressionFile>
    </configuration>
</plugin>

Run in CI (not bound to mvn test - it downloads the NVD database on first run):

mvn dependency-check:check

Output: target/dependency-check-report.html with each dependency’s CVE list.

Suppression file

When a CVE is a false positive or has been mitigated:

<!-- dependency-check-suppressions.xml -->
<suppressions>
    <suppress>
        <notes>CVE-2023-XXXXX: not exploitable - we don't use the affected feature</notes>
        <cve>CVE-2023-XXXXX</cve>
    </suppress>
</suppressions>

CI integration

Add to ci.yml as a separate step that runs after mvn verify:

- name: Dependency vulnerability scan
  run: mvn dependency-check:check
  continue-on-error: false # block merges on unacknowledged High/Critical CVEs

HashiCorp Vault

Vault provides centralised secrets management: database passwords, API keys, certificates. Spring Cloud Vault integrates Vault as a Spring PropertySource.

Dependency

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-vault-config</artifactId>
</dependency>

Configuration

# application.yml
spring:
  cloud:
    vault:
      host: vault
      port: 8200
      token: ${VAULT_TOKEN} # never hardcode
      kv:
        enabled: true
        backend: secret
        default-context: security-demo # reads secret/security-demo

Vault path secret/security-demo contains:

vault kv put secret/security-demo \
  db.password=s3cr3t-db-pw \
  jwt.secret=super-secret-key

Spring Cloud Vault makes these available as Spring properties:

@Value("${db.password}")
private String dbPassword;

Testing without a running Vault

Use @TestPropertySource to supply mock values:

@SpringBootTest
@TestPropertySource(properties = {
    "db.password=test-password",
    "jwt.secret=test-secret-key"
})
class MyServiceTest { ... }

Or, disable Vault for the test profile in application-test.properties:

spring.cloud.vault.enabled=false

Dynamic secrets (advanced)

Vault can generate short-lived, auto-rotating database credentials:

vault secrets enable database
vault write database/roles/myapp \
  db_name=mydb \
  creation_statements="CREATE USER '{{name}}'@'%' ..." \
  default_ttl=1h max_ttl=24h

The app requests a new credential on startup and Vault revokes it automatically after default_ttl. Compromised credentials expire without manual rotation.


Tests

Class OWASP Tests
AccessControlTest A01 3
SecurityFeaturesTest A03, A05 2

Run: JAVA_HOME=/opt/homebrew/opt/openjdk@21 mvn test Result: 5/5 pass


Key decisions

Decision Reason
@WithMockUser over httpBasic() + real credentials in tests Isolates the authorization policy from the authentication mechanism; no need to store real test passwords
BCryptPasswordEncoder(12) over default strength (10) Strength 12 ≈ 300ms; raises the cost of brute force from the default 100ms without being noticeable to users
csrf(AbstractHttpConfigurer::disable) Stateless API - CSRF requires browser-managed session cookies, which this API doesn’t use
headers(Customizer.withDefaults()) Enables all Spring Security headers in one call; explicit withDefaults() makes intent clear and prevents accidental omission
OWASP Dependency Check NOT bound to mvn test First run downloads the NVD CVE database (~few hundred MB); binding to test would make every local build slow - run in CI explicitly