CORS와 CSRF를 위협 모델로 선택
CORS와 CSRF는 비슷한 browser 보안 용어로 보이지만 막는 문제가 다릅니다. CORS는 다른 origin의 JavaScript가 응답을 읽을 수 있는지를 제어하고, CSRF는 browser가 cookie 같은 자격 증명을 자동으로 보내는 특성을 악용한 요청을 막습니다. 우리 API의 인증 방식에 맞춰 두 규칙을 따로 결정합니다.
CORS와 CSRF는 막는 공격이 다르다
Bearer token은 JavaScript가 Authorization header에 명시적으로 넣어 전송하고 allowCredentials(false)로 자격 증명 자동 전송을 사용하지 않습니다. 이 위협 모델에서는 CSRF token을 끄는 것이 맞지만, 나중에 cookie session 인증을 추가한다면 다시 검토해야 합니다. CSRF disable은 API라는 이름 때문이 아니라 자격 증명의 전송 방식 때문입니다.
Bearer API의 허용 출처를 좁힌다
CORS는 보안 필터 안에서 활성화하고, 허용 origin·method·header는 CorsConfigurationSource에 모으니다. *로 열지 않고 실제 frontend origin만 등록합니다. If-Match와 X-API-Version은 5장에서 사용할 요청 header이고, Location과 ETag는 browser code가 읽을 응답 header이므로 미리 명시합니다.
package com.andongmin.studylog.security;
import java.util.List;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
@Configuration(proxyBeanMethods = false)
public class CorsConfiguration {
@Bean
CorsConfigurationSource corsConfigurationSource() {
var cors = new org.springframework.web.cors.CorsConfiguration();
cors.setAllowedOrigins(List.of("https://study.example.com"));
cors.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE"));
cors.setAllowedHeaders(List.of(
"Authorization", "Content-Type", "If-Match", "X-API-Version"));
cors.setExposedHeaders(List.of("Location", "ETag"));
cors.setAllowCredentials(false);
cors.setMaxAge(3600L);
var source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/api/**", cors);
source.registerCorsConfiguration("/graphql", cors);
return source;
}
}ch4-1의 SecurityConfiguration.java는 첫 보안 경계를 설명하기 위한 버전입니다. 별도 catch-all chain을 추가하지 말고 그 파일을 아래 단일 구성으로 교체합니다. 이 한 chain이 health, REST scope, GraphQL 인증, CORS와 기본 거부를 모두 소유합니다.
package com.andongmin.studylog.security;
import static org.springframework.security.config.Customizer.withDefaults;
import org.springframework.boot.actuate.endpoint.web.WebServerNamespace;
import org.springframework.boot.health.actuate.endpoint.HealthEndpoint;
import org.springframework.boot.security.autoconfigure.actuate.web.servlet.EndpointRequest;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
@Configuration(proxyBeanMethods = false)
public class SecurityConfiguration {
@Bean
SecurityFilterChain apiSecurity(HttpSecurity http) throws Exception {
return http
.cors(withDefaults())
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(requests -> requests
.requestMatchers(EndpointRequest.to(HealthEndpoint.class)).permitAll()
.requestMatchers(EndpointRequest.toAdditionalPaths(
WebServerNamespace.SERVER, HealthEndpoint.class)).permitAll()
.requestMatchers(HttpMethod.GET, "/api/study-sessions/**")
.hasAuthority("SCOPE_study.read")
.requestMatchers("/api/study-sessions/**")
.hasAuthority("SCOPE_study.write")
.requestMatchers("/graphql").authenticated()
.anyRequest().denyAll())
.oauth2ResourceServer(resourceServer -> resourceServer.jwt(withDefaults()))
.exceptionHandling(errors -> errors
.authenticationEntryPoint((request, response, failure) ->
response.sendError(401, "authentication required"))
.accessDeniedHandler((request, response, failure) ->
response.sendError(403, "insufficient authority")))
.build();
}
}preflight와 상태 변경 요청을 검증한다
terminal A의 서버를 유지한 채 terminal B에서 OPTIONS preflight를 보냅니다. preflight는 실제 POST가 아니므로 Bearer token을 요구하면 안 됩니다. 허용 origin으로 호출했을 때만 Access-Control-Allow-Origin 헤더가 있는지 보고, origin을 https://evil.example로 바꾸어 헤더가 사라지는지도 비교합니다.
export LOCAL_JWT_SECRET="study-log-local-development-key-2026"
SPRING_PROFILES_ACTIVE=local ./mvnw -q spring-boot:runcurl -i -X OPTIONS http://localhost:8080/api/study-sessions -H "Origin: https://study.example.com" -H "Access-Control-Request-Method: POST"허용 출처의 preflight에는 Access-Control-Allow-Origin이 있고 다른 출처에는 없어야 합니다.요청이 Controller에 도달하기 전의 보안 경계를 완성했습니다. 이제 임시 probe endpoint를 지우고 StudySession을 실제 HTTP resource로 노출합니다.