Files
2025-11-29 18:28:34 +08:00

18 KiB

Security Headers and CORS Configuration

Security Headers Configuration

Basic Security Headers

@Configuration
public class SecurityConfig {

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'self'; " +
                                     "script-src 'self' 'unsafe-inline'; " +
                                     "style-src 'self' 'unsafe-inline'; " +
                                     "img-src 'self' data:; " +
                                     "font-src 'self';")
                    .reportOnly(false))
                .frameOptions(frame -> frame
                    .sameOrigin()
                    .deny()) // Use sameOrigin() for same-origin iframes, deny() to completely block
                .httpStrictTransportSecurity(hsts -> hsts
                    .maxAgeInSeconds(31536000) // 1 year
                    .includeSubDomains(true)
                    .preload(true))
                .xssProtection(xss -> xss
                    .headerValue(XssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK))
                .contentTypeOptions(contentTypeOptions -> contentTypeOptions
                    .and())
            )
            .cors(cors -> cors
                .configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf.disable())
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/**").authenticated()
                .anyRequest().permitAll()
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOriginPatterns(List.of("*"));
        configuration.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        configuration.setAllowedHeaders(Arrays.asList("Authorization", "Content-Type", "X-Requested-With"));
        configuration.setExposedHeaders(Arrays.asList("X-Total-Count", "X-Content-Type-Options"));
        configuration.setAllowCredentials(true);
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

Enhanced Security Configuration

@Configuration
@EnableWebSecurity
@RequiredArgsConstructor
public class EnhancedSecurityConfig {

    private final JwtAuthenticationFilter jwtAuthenticationFilter;

    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        http
            .securityMatcher("/**")
            .headers(headers -> headers
                .contentSecurityPolicy(csp -> csp
                    .policyDirectives("default-src 'self'; " +
                                     "script-src 'self 'unsafe-inline' 'unsafe-eval'; " +
                                     "style-src 'self 'unsafe-inline'; " +
                                     "img-src 'self' data: https:; " +
                                     "font-src 'self'; " +
                                     "connect-src 'self' https:; " +
                                     "frame-src 'none'; " +
                                     "object-src 'none';"))
                .frameOptions(frameOptions -> frameOptions.sameOrigin())
                .httpStrictTransportSecurity(hsts -> hsts
                    .maxAgeInSeconds(31536000)
                    .includeSubDomains(true)
                    .preload(true)
                    .includeSubDomains(true))
                .permissionsPolicy(permissionsPolicy -> permissionsPolicy
                    .add("camera", "()")
                    .add("geolocation", "()")
                    .add("microphone", "()")
                    .add("payment", "()"))
                .referrerPolicy(referrerPolicy -> referrerPolicy.noReferrer())
                .and())
            .cors(cors -> cors.configurationSource(corsConfigurationSource()))
            .csrf(csrf -> csrf
                .ignoringRequestMatchers("/api/auth/**")
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
            .sessionManagement(session -> session
                .sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class)
            .authorizeHttpRequests(authz -> authz
                .requestMatchers("/api/public/**").permitAll()
                .requestMatchers("/api/admin/**").hasRole("ADMIN")
                .requestMatchers(HttpMethod.GET, "/api/users/**").hasAnyRole("USER", "ADMIN")
                .requestMatchers("/api/auth/**").permitAll()
                .anyRequest().authenticated()
            );

        return http.build();
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();

        // Allowed origins (consider restricting to specific domains in production)
        configuration.setAllowedOriginPatterns(List.of("https://yourdomain.com", "https://app.yourdomain.com"));

        // Allowed methods
        configuration.setAllowedMethods(Arrays.asList(
            "GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"
        ));

        // Allowed headers
        configuration.setAllowedHeaders(Arrays.asList(
            "Authorization",
            "Content-Type",
            "Accept",
            "X-Requested-With",
            "X-Content-Type-Options",
            "X-Total-Count",
            "Cache-Control"
        ));

        // Exposed headers to client
        configuration.setExposedHeaders(Arrays.asList(
            "X-Total-Count",
            "X-Content-Type-Options",
            "Cache-Control"
        ));

        // Allow credentials
        configuration.setAllowCredentials(true);

        // Cache preflight requests for 1 hour
        configuration.setMaxAge(3600L);

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/api/**", configuration);
        return source;
    }
}

Content Security Policy (CSP)

Basic CSP Configuration

@Configuration
public class ContentSecurityPolicyConfig {

    @Bean
    public WebMvcConfigurer corsConfigurer() {
        return new WebMvcConfigurer() {
            @Override
            public void addCorsMappings(CorsRegistry registry) {
                registry.addMapping("/api/**")
                    .allowedOrigins("https://yourdomain.com")
                    .allowedMethods("GET", "POST", "PUT", "DELETE", "OPTIONS")
                    .allowedHeaders("*")
                    .exposedHeaders("X-Total-Count")
                    .allowCredentials(true)
                    .maxAge(3600);
            }

            @Override
            public void addInterceptors(InterceptorRegistry registry) {
                registry.addInterceptor(new ContentSecurityPolicyInterceptor());
            }
        };
    }
}

@Component
public class ContentSecurityPolicyInterceptor implements HandlerInterceptor {

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response,
                          Object handler, ModelAndView modelAndView) throws Exception {
        response.setHeader("Content-Security-Policy",
            "default-src 'self'; " +
            "script-src 'self' 'unsafe-inline'; " +
            "style-src 'self' 'unsafe-inline'; " +
            "img-src 'self' data:; " +
            "font-src 'self'; " +
            "connect-src 'self'; " +
            "frame-src 'none'; " +
            "object-src 'none';");

        response.setHeader("X-Content-Type-Options", "nosniff");
        response.setHeader("X-Frame-Options", "DENY");
        response.setHeader("X-XSS-Protection", "1; mode=block");
    }
}

Advanced CSP with Nonce

@Component
@RequiredArgsConstructor
public class SecurityHeadersFilter extends OncePerRequestFilter {

    private final AtomicLong nonceCounter = new AtomicLong(0);

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                 HttpServletResponse response,
                                 FilterChain filterChain) throws ServletException, IOException {

        // Generate nonce for each request
        String nonce = String.valueOf(nonceCounter.incrementAndGet());

        // Set CSP header with nonce for inline scripts
        response.setHeader("Content-Security-Policy",
            "default-src 'self'; " +
            "script-src 'self' 'nonce-" + nonce + "'; " +
            "style-src 'self' 'unsafe-inline'; " +
            "img-src 'self' data:; " +
            "font-src 'self'; " +
            "connect-src 'self'; " +
            "frame-src 'none'; " +
            "object-src 'none';");

        // Add nonce to request attributes for templates
        request.setAttribute("cspNonce", nonce);

        // Set other security headers
        response.setHeader("X-Content-Type-Options", "nosniff");
        response.setHeader("X-Frame-Options", "SAMEORIGIN");
        response.setHeader("Strict-Transport-Security",
            "max-age=31536000; includeSubDomains; preload");
        response.setHeader("X-Permitted-Cross-Domain-Policies", "none");
        response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");

        filterChain.doFilter(request, response);
    }
}

CORS Configuration

Method-level CORS

@RestController
@RequestMapping("/api/users")
@CrossOrigin(origins = "https://yourdomain.com", methods = {RequestMethod.GET, RequestMethod.POST})
public class UserController {

    @GetMapping
    public ResponseEntity<List<User>> getAllUsers() {
        // CORS allowed for GET requests
        return ResponseEntity.ok(userService.findAll());
    }

    @PostMapping
    @CrossOrigin(origins = "https://app.yourdomain.com")
    public ResponseEntity<User> createUser(@RequestBody User user) {
        // CORS allowed with different origin for POST requests
        return ResponseEntity.status(HttpStatus.CREATED).body(userService.create(user));
    }
}

Dynamic CORS Configuration

@Configuration
public class DynamicCorsConfig {

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();

        // Development configuration
        CorsConfiguration devConfig = new CorsConfiguration();
        devConfig.setAllowedOriginPatterns(List.of("*"));
        devConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "DELETE", "OPTIONS"));
        devConfig.setAllowedHeaders(Arrays.asList("*"));
        devConfig.setAllowCredentials(true);
        source.registerCorsConfiguration("/api/**", devConfig);

        // Production configuration - restrict to specific domains
        CorsConfiguration prodConfig = new CorsConfiguration();
        prodConfig.setAllowedOriginPatterns(List.of(
            "https://yourdomain.com",
            "https://app.yourdomain.com",
            "https://api.yourdomain.com"
        ));
        prodConfig.setAllowedMethods(Arrays.asList("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
        prodConfig.setAllowedHeaders(Arrays.asList(
            "Authorization",
            "Content-Type",
            "Accept",
            "X-Requested-With"
        ));
        prodConfig.setExposedHeaders(Arrays.asList("X-Total-Count"));
        prodConfig.setAllowCredentials(true);
        source.registerCorsConfiguration("/api/**", prodConfig);

        return source;
    }
}

Security Headers Best Practices

Essential Headers for Production

  1. Content-Security-Policy: Mitigates XSS attacks
  2. X-Content-Type-Options: Prevents MIME type sniffing
  3. X-Frame-Options: Prevents clickjacking
  4. Strict-Transport-Security: Enforces HTTPS
  5. X-XSS-Protection: Legacy browser XSS protection
  6. Referrer-Policy: Controls referrer information

CSP Examples by Application Type

Blog/Content Site

response.setHeader("Content-Security-Policy",
    "default-src 'self'; " +
    "script-src 'self' https://cdn.jsdelivr.net; " +
    "style-src 'self' 'unsafe-inline'; " +
    "img-src 'self' https: data:; " +
    "font-src 'self'; " +
    "connect-src 'self'; " +
    "frame-src https://www.youtube.com; " +
    "media-src https://www.youtube.com;");

Single Page Application (SPA)

response.setHeader("Content-Security-Policy",
    "default-src 'self'; " +
    "script-src 'self' 'unsafe-inline' 'unsafe-eval'; " +
    "style-src 'self' 'unsafe-inline'; " +
    "img-src 'self' data:; " +
    "font-src 'self'; " +
    "connect-src 'self' wss:; " +
    "frame-src 'none'; " +
    "object-src 'none';");

API Only

response.setHeader("Content-Security-Policy",
    "default-src 'self'; " +
    "connect-src 'self'; " +
    "frame-src 'none'; " +
    "object-src 'none';");

Security Header Testing

import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.test.web.servlet.MockMvc;

import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;

@SpringBootTest
@AutoConfigureMockMvc
class SecurityHeadersTest {

    @Autowired
    private MockMvc mockMvc;

    @Test
    void securityHeaders_shouldBeSet() throws Exception {
        mockMvc.perform(get("/api/users"))
            .andExpect(header().string("Content-Security-Policy", notNullValue()))
            .andExpect(header().string("X-Content-Type-Options", "nosniff"))
            .andExpect(header().string("X-Frame-Options", notNullValue()))
            .andExpect(header().string("Strict-Transport-Security", notNullValue()));
    }
}

Rate Limiting

Basic Rate Limiting

@Component
public class RateLimitingFilter extends OncePerRequestFilter {

    private final ConcurrentHashMap<String, RateLimit> rateLimits = new ConcurrentHashMap<>();
    private static final long REQUEST_LIMIT = 100;
    private static final long TIME_WINDOW = 60_000; // 1 minute

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                 HttpServletResponse response,
                                 FilterChain filterChain) throws ServletException, IOException {

        String clientIp = request.getRemoteAddr();
        String path = request.getRequestURI();
        String key = clientIp + ":" + path;

        RateLimit rateLimit = rateLimits.computeIfAbsent(key, k -> new RateLimit());

        synchronized (rateLimit) {
            if (System.currentTimeMillis() - rateLimit.resetTime > TIME_WINDOW) {
                rateLimit.count = 0;
                rateLimit.resetTime = System.currentTimeMillis();
            }

            if (rateLimit.count >= REQUEST_LIMIT) {
                response.setStatus(HttpStatus.TOO_MANY_REQUESTS.value());
                response.getWriter().write("Rate limit exceeded");
                return;
            }

            rateLimit.count++;
        }

        filterChain.doFilter(request, response);
    }

    private static class RateLimit {
        long count = 0;
        long resetTime = System.currentTimeMillis();
    }
}

Token-based Authentication Headers

@Component
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {

    private final JwtTokenProvider jwtTokenProvider;

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                 HttpServletResponse response,
                                 FilterChain filterChain) throws ServletException, IOException {

        try {
            String jwt = getJwtFromRequest(request);

            if (StringUtils.hasText(jwt) && jwtTokenProvider.validateToken(jwt)) {
                UsernamePasswordAuthenticationToken authentication =
                    jwtTokenProvider.getAuthentication(jwt);
                SecurityContextHolder.getContext().setAuthentication(authentication);
            }

            filterChain.doFilter(request, response);
        } catch (Exception ex) {
            logger.error("Could not set user authentication in security context", ex);
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Unauthorized");
        }
    }

    private String getJwtFromRequest(HttpServletRequest request) {
        String bearerToken = request.getHeader("Authorization");
        if (StringUtils.hasText(bearerToken) && bearerToken.startsWith("Bearer ")) {
            return bearerToken.substring(7);
        }
        return null;
    }
}

WebSocket Security

@Configuration
@EnableWebSocketMessageBroker
public class WebSocketSecurityConfig implements WebSocketMessageBrokerConfigurer {

    @Override
    public void configureMessageBroker(MessageBrokerRegistry config) {
        config.enableSimpleBroker("/topic");
        config.setApplicationDestinationPrefixes("/app");
    }

    @Override
    public void registerStompEndpoints(StompEndpointRegistry registry) {
        registry.addEndpoint("/ws")
               .setAllowedOriginPatterns("https://yourdomain.com")
               .withSockJS();
    }

    @Override
    public void configureClientInboundChannel(ChannelRegistration registration) {
        registration.interceptors(new ChannelInterceptor() {
            @Override
            public Message<?> preSend(Message<?> message, MessageChannel channel) {
                StompHeaderAccessor accessor = MessageHeaderAccessor.getAccessor(message, StompHeaderAccessor.class);

                if (StompCommand.CONNECT.equals(accessor.getCommand())) {
                    // Validate token and authenticate
                    String token = accessor.getFirstNativeHeader("Authorization");
                    if (!isValidToken(token)) {
                        throw new UnauthorizedWebSocketException("Invalid token");
                    }
                }

                return message;
            }
        });
    }
}