발생 이슈
프로젝트를 진행하던 중 새로운 API를 구현해 배포했는데 프론트 분에게 해당 API로 요청을 보내면 CORS 문제로 403 응답을 받는다는 연락를 받았습니다. 분명히 CORS에 대한 security 설정을 했고 로그인 API에서는 일어나지 않은 문제여서 문제 파악부터 해야됐습니다.
우선 이슈 발생 당시 Config 설정과 API 요청, 응답을 확인해봤습니다.
SecurityConfig.java
@EnableWebSecurity
@Configuration
@RequiredArgsConstructor
public class SecurityConfig {
private final OAuth2LoginSuccessHandler oAuth2LoginSuccessHandler;
private final OAuth2LoginFailureHandler oAuth2LoginFailureHandler;
private final JwtFilter jwtFilter;
private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint;
private final CustomAccessDeniedHandler customAccessDeniedHandler;
private final HttpCookieOAuth2AuthorizationRequestRepository httpCookieOAuth2AuthorizationRequestRepository;
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.sessionManagement(sessionManager -> sessionManager
.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.oauth2Login(oauth2Login -> oauth2Login
.authorizationEndpoint(authorizationEndpointConfig -> authorizationEndpointConfig
.authorizationRequestRepository(httpCookieOAuth2AuthorizationRequestRepository))
.successHandler(oAuth2LoginSuccessHandler)
.failureHandler(oAuth2LoginFailureHandler))
.csrf(AbstractHttpConfigurer::disable)
.cors(withDefaults())
.httpBasic(AbstractHttpConfigurer::disable)
.formLogin(AbstractHttpConfigurer::disable)
.authorizeHttpRequests(authorize -> authorize
.requestMatchers("/docs/**", "/actuator/**", "/error/**", "/").permitAll()
.requestMatchers("/signup").authenticated()
.anyRequest().authenticated())
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.exceptionHandling(exceptionHandling -> exceptionHandling
.authenticationEntryPoint(customAuthenticationEntryPoint)
.accessDeniedHandler(customAccessDeniedHandler));
return http.build();
}
@Value("${cors.allow-origins}")
private List<String> corsOrigins;
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(corsOrigins);
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "PATCH"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
}
API 요청과 응답
허용하는 corsOrigins는 properites를 주입받아서 사용하고 있었고, 허용 url 경로는 /** 로 모두 열어두었고, Method로 위의 설정처럼 5가지를 열어놨습니다. 요청과 응답에서 이상한 점을 발견했는데 PATCH 요청이아니라 OPTIONS 요청을 보냈다는 것 입니다. 왜 이런 현상이 일어났는지 알아보기 위해선 CORS에 대해서 좀 더 자세히 알아봐야됩니다.
CORS
CORS란 Cross-Origin-Resource-Sharing의 약자로 교차 출처 리소스 공유 정책입니다.
추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 어플리케이션이 다른 출처의 선택한 자원에 접근 할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다.
보안 상의 이유로, 브라우저는 스크립트에서 시작한 교차 출처 HTTP 요청을 제한하고 XMLHttpRequest와 Fetch API는 해당 정책을 따르고 있습니다. 그래서 다른 출처의 리소스를 불러오기 위해서는 그 출처에서 올바른 CORS 헤더를 포함한 응답을 반환해야 합니다.
여기서 출처(Origin)은 프로토콜, 도메인, 포트를 합친 부분입니다.
아래 표는 URL https://a.domain.com/dir/index.html
의 출처를 비교한 예시입니다.
URL | 결과 | 이유 |
https://a.domain.com/dir/other.html | 동일 출처 | 경로만 다름 |
http://a.domain.com/dir/index.html | 다른 출처 | 다른 프로토콜 |
https://b.domain.com/dir/index.html | 다른 출처 | 다른 호스트 |
https://a.domain.com:8080/dir/index.html | 다른 출처 | 다른 포트 |
CORS 작동 원리
CORS는 웹 브라우저에서 해당 정보를 읽는 것이 허용된 출처인지를 서버에서 설명할 수 있는 HTTP 헤더를 추가해 작동합니다.
HTTP 헤더는 다음과 같습니다.
응답 헤더
- Access-Control-Allow-Origin: 응답이 공유될 수 있는지를 나타냅니다.
- Access-Control-Allow-Credentials: credentials 플래그가 true일 때 요청에 대한 응답이 노출될 수 있는지를 나타냅니다.
- Access-Control-Allow-Headers: 실제 요청을 만들 때 사용될 수 있는 HTTP 헤더를 preflight 요청에 대한 응답으로 사용합니다.
- Access-Control-Allow-Methods: preflight 요청에 대한 응답으로 리소스에 접근할 때 허용되는 메소드를 명시합니다.
- Access-Control-Expose-Headers: 헤더의 이름을 나열하여 어떤 헤더가 응답의 일부로 노출될 수 있는지 나타냅니다.
- Access-Control-Max-Age: preflight 요청의 결과가 캐시되는 기간을 나타냅니다.
요청 헤더
- Access-Control-Request-Headers: 실제 요청이 있을 때 사용될 HTTP 헤더를 서버에 알리기 위해 preflight 요청을 보낼때 사용됩니다.
- Access-Control-Request-Method: 실제 요청이 있을 때 사용될 HTTP 메소드를 서버에 알리기 위한 preflight 요청을 보낼때 사용됩니다.
- Origin: 요청이 시작된 위치를 나타냅니다.
접근 제어 시나리오
CORS 동작하는 방식은 Simple Requests, Preflight Requests, Credentialed Requests 세 가지가 있습니다.
Simple Requests
preflight 요청을 하지않고 CORS 헤더를 사용하여 권한을 처리하고 통신하는 방식입니다. 단순한 요청인 만큼 다음 조건을 모두 충족해야 해당 방식으로 작동합니다.
- 다음 중 하나의 메서드
- GET
- HEAD
- POST
- 유저 에이전트가 자동으로 설정한 헤더(Connection, User-Agent)외에, 수동으로 설정할 수 있는 "CORS-Safelisted request-header" 헤더
- Accept
- Accept-Language
- Content-Language
- Content-Type : 아래의 값들만 허용
- application/x-www-form-urlencoded
- multipart/form-data
- text/plain
- 요청에 ReadableStream 객체가 사용되지 않아야합니다.
Preflight Requests
simple requests 와 달리, 먼저 OPTIONS 메서드를 통해 HTTP 요청을 보내 실제 요청이 전송하기에 안전한지 확인합니다.
확인 후 200번대 응답을 받으면 브라우저는 다시 원래의 요청을 보냅니다.
Credentialed Requests
CORS는 마찬가지로 쿠키와 같은 인증과 관련된 데이터를 함부로 요청 데이터에 담지 않도록 되어있기 때문에 이를 허용하기 위해 사용됩니다. 클라이언트와 서버 둘다 Access-Control-Allow_Credentials 헤더를 true로 설정해 허용할 수 있습니다.
문제 상황 알아보기
CORS의 내용을 토대로 현재 이슈의 원인을 생각해보면 Preflight Requests 상황에서 생기는 문제라는 걸 알 수 있습니다.
문제가 생기는 API 는 PATCH /signup 으로 실제 요청이 나가기전에 다음과 같이 Preflight 요청을 보냅니다.
OPTIONS /signup
Host: domain
Accept: */*
Accept-Language: ko-KR, ko;q=0.9
Accept-Encoding: gzip,deflate
Connection: keep-alive
Content-Type: application/json
Referer: http://localhost:3000
Origin: http://localhost:3000
Cache-Control: no-cache
Access-Control-Request-Headers: authorization, content-type
Access-Control-Request-Method: PATCH
Access-Control-Request-* 헤더를 통해 실제 요청시 사용되는 Method 와 Header를 명시해 서버로 요청합니다. 이제 요청을 받은 서버에서는 CORS 설정과 맞는지 확인하고 응답을 하게 됩니다.
위의 security 설정을 보면 allowedOrigin과 allowedMethod에 대한 설정만 되어있는데, header에 대한 추가 설정이 없어서 preflight 요청이 제대로 처리되지 않는다 판단했습니다.
그래서 cors을 처리하는 CorsFilter의 헤더를 판단하는 부분에 브레이크 포인트를 걸고 디버그를 하며 과정을 살펴봤습니다.
CorsFilter
processRequest 메서드를 통해서 유효한 요청인지 확인을 하게 되는데 내부 로직을 더 살펴 보겠습니다.
우선 cors 응답을 위한 Vary 헤더를 세팅하고 cors 요청인지 판단을 합니다. 판단 세부로직이 궁금하신 분들은 간단한 로직이니 살펴보셔도 좋을 것 같습니다. 이 후 preFlightRequest인지 확인을 하고 config에 대한 로직이 추가되는데 파라미터로 넘어온 config는 SecurityConfig에서 설정한 CorsConfiguration입니다. 저의 상황에선 null이 아니기 때문에 handleInternal 메서드로 이동합니다.
드디어 헤더를 판단하는 브레이크 포인트 입니다. allowOrigin과 allowMethods의 검증은 통과되서 해당 브레이크 포인트에 도달한 것을 확인 할 수 있습니다. Access-Control-Request-Header 에는 실제 요청에서 사용할 Authorization, Content-type 을 담아서 Preflight 요청에 포함시킵니다. 아래의 사진을 추가적으로 살펴보면 CORS 요청 헤더는 잘 받아왔지만 제가 설정한 allowHeaders는 null로 할당되어있기 때문에 통과하지 못하고 rejectRequest 메서드로 이동하게 될 것 입니다.
그럼 마지막으로 rejectRequest 메서드를 살펴보겠습니다.
드디어 이슈 상황에서 왜 그런 응답이 왔는지 확인이 됐습니다. 응답객체에 403 Http status 코드와 "Invalid CORS request"를 Body에 넣어주는 것을 확인할 수 있습니다.
문제 해결
allowHeaders가 문제인 것을 확인 했으니, CorsConfiguration에 허용 헤더를 추가하면 문제가 해결됩니다.
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration corsConfiguration = new CorsConfiguration();
corsConfiguration.setAllowedOrigins(corsOrigins);
corsConfiguration.setAllowedHeaders(List.of("*"));
corsConfiguration.setMaxAge(3600L);
corsConfiguration.setAllowedMethods(Arrays.asList("GET", "POST", "DELETE", "PUT", "PATCH"));
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", corsConfiguration);
return source;
}
저는 * 로 모든 헤더를 허용하는 설정을 했지만 "authroization" ,"content-type" 같이 원하는 헤더만 추가할 수 있습니다.
추가적으로 MaxAge를 사용해 preflight 요청을 캐시할 수 있도록 했습니다.
출처
https://developer.mozilla.org/ko/docs/Web/HTTP/CORS
교차 출처 리소스 공유 (CORS) - HTTP | MDN
교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라
developer.mozilla.org
'spring' 카테고리의 다른 글
Java 17, Spring boot 3 알아보기 (0) | 2023.08.28 |
---|---|
Spring SQLIntegrityConstraintViolationException cannot add or update a child row 해결 (0) | 2023.08.20 |
Spring flyway 적용하기 (0) | 2023.08.19 |