또 CORS 너야?
1. 문제
나는 Spring Cloud 기반 API 서버를 만들고, Swagger를 이용하여 API 명세서를 배포했다. 그런데..
스웨거에서 왜인지 403 Forbidden을 받았다.
Gateway의 로그를 살펴보았더니,
PRE Filter까진 통과했지만, POST Filter로 온 Response가 403 FORBIDDEN 이었다.
여기서 더 문제는
postman은 정상 작동하는 것이었다...
2. 해결
결론부터 말하자면 CORS 오류였다.
postman으로는 되는 요청이면 CORS문제라고 짐작은 했지만,
원래라면 개발자 모드에 이렇게 나와야하기 때문에 CORS 오류는 아닌 줄 알았다.
위 글을 보고 CORS 오류일 수도 있겠다는 생각으로 고치게 됐다.
결론은 CorsConfig에 위 한 줄을 추가하고 해결되었다.
3. 원인
그렇다면, 왜 CORS 오류일까? 브라우저는 왜 CORS 오류라고 알려주지 않았을까?
요청 과정을 자세히 살펴보았다.
서버 도메인으로 접속하여 Swagger를 통해 POST 요청을 보냈다.
요청을 받은 Gateway는 로드밸런서를 통해 user-service로 요청을 전달했다.
Gateway는 정상적으로 PRE Filter를 거쳐, 서비스로 전달했다.
하지만, 돌아오는 POST Filter 과정 로그에서 Response는 이미 403 Forbidden이었다.
과정을 그려보면 다음과 같다.
하지만 정상적으로 preflight 방식을 사용하면, 다음 시나리오가 맞다.
여기서 내가 생겼던 의문은,
나는 preflight는 브라우저가 실행하고 CORS 에러를 일으킨다는 것을 알고 있었기 때문에
Gateway가 preflight 방식을 사용한 것도 아닌데, 왜 403 Forbidden을 받았을까? 였다.
그렇다면 403을 뱉은 서비스의 CORS 설정을 살펴보겠다.
초기 CORS 설정은 org.springframework.web.cors.CorsConfiguration으로 진행된다.
사용할 CorsFilter를 추가합니다. corsFilter라는 이름의 빈이 제공되면 해당 CorsFilter가 사용됩니다. 그렇지 않으면 corsConfigurationSource가 정의된 경우 해당 CorsConfiguration이 사용됩니다. 그렇지 않으면 Spring MVC가 클래스 경로에 있는 경우 HandlerMappingIntrospector가 사용됩니다.
이 프로젝트에는 CorsConfiguration을 사용했다.
여기서 제공한 CorsConfigurationSource를 통해 CorsFilter 과정에서 걸린다.
필터 과정을 순서대로 살펴보겠다.
1. 클라이언트에서 요청이 들어온다.
2. FilterChainProxy의 doFilter 메서드에서 보안 필터 체인을 실행한다.
3. 보안 필터 체인에서 CorsFilter가 실행되며, doFilter 메서드에서 CORS 요청을 검사한다.
- org.springframework.web.filter.CorsFilter.java
해당 CorsFilter에서 필드고 갖고있는 CorsProcessor가 processRequest()메서드를 실행한다.
아래는 processRequest() 메서드이다.
- org.springframework.web.cors.DefaultCorsProcessor
@Override
public boolean processRequest(@Nullable CorsConfiguration config, HttpServletRequest request,
HttpServletResponse response) throws IOException {
// 중략..
return handleInternal(new ServletServerHttpRequest(request), new ServletServerHttpResponse(response), config, preFlightRequest);
}
여기서 마지막에, handleInternal() 메서드를 호출하게 된다.
(주석은 제가 작성했습니다)
protected boolean handleInternal(ServerHttpRequest request, ServerHttpResponse response,
CorsConfiguration config, boolean preFlightRequest) throws IOException {
//요청의 출처
String requestOrigin = request.getHeaders().getOrigin();
//허용된 출처인지 검사 -> 만약 허용되지 않은 출처면 null이 들어감
String allowOrigin = checkOrigin(config, requestOrigin);
HttpHeaders responseHeaders = response.getHeaders();
//서버에서 허용한 출처가 아닐 경우(null일경우)
if (allowOrigin == null) {
logger.debug("Reject: '" + requestOrigin + "' origin is not allowed");
rejectRequest(response);
return false;
}
HttpMethod requestMethod = getMethodToUse(request, preFlightRequest);
//허용된 메서드인지 검사 -> 만약 허용되지 않은 메서드면 null이 들어감
List<HttpMethod> allowMethods = checkMethods(config, requestMethod);
//서버에서 허용한 메서드가 아닐 경우(null일 경우)
if (allowMethods == null) {
logger.debug("Reject: HTTP '" + requestMethod + "' is not allowed");
rejectRequest(response);
return false;
}
List<String> requestHeaders = getHeadersToUse(request, preFlightRequest);
//허용된 헤더인지 검사 -> 만약 허용되지 않은 헤더면 null이 들어감
List<String> allowHeaders = checkHeaders(config, requestHeaders);
//Preflight 요청이고, 서버에서 허용한 Header가 아닐 경우(null일 경우)
if (preFlightRequest && allowHeaders == null) {
logger.debug("Reject: headers '" + requestHeaders + "' are not allowed");
rejectRequest(response);
return false;
}
//---------------- 허용된 출처, 메서드, 헤더라고 판단함 ----------------
//헤더의 Access-Control-Allow-Origin에 허용된 출처를 기입
responseHeaders.setAccessControlAllowOrigin(allowOrigin);
//만약 Preflight 요청이라면 헤더의 Access-Control-Allow-Method에 허용된 메서드를 기입
if (preFlightRequest) {
responseHeaders.setAccessControlAllowMethods(allowMethods);
}
//만약 Preflight 요청이고, 허용된 헤더인 경우(null이 아닌 경우)
//헤더의 Access-Control-Allow-Headers에 허용된 헤더를 기입
if (preFlightRequest && !allowHeaders.isEmpty()) {
responseHeaders.setAccessControlAllowHeaders(allowHeaders);
}
//CORS 설정 중 노출 허용인 헤더가 있을 경우
//헤더의 Access-Control-Expose-Header에 노출 허용 헤더를 기입
if (!CollectionUtils.isEmpty(config.getExposedHeaders())) {
responseHeaders.setAccessControlExposeHeaders(config.getExposedHeaders());
}
//CORS 설정의 Allow-Credentials가 TRUE인 경우
//헤더의 Access-Control-Allow-Credentials를 True로 기입
if (Boolean.TRUE.equals(config.getAllowCredentials())) {
responseHeaders.setAccessControlAllowCredentials(true);
}
//만약 CORS 설정에 Private 네트워크를 허용해두었고,
//요청 헤더에 ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK가 TRUE인 경우
//헤더의 Access-Control-Request-Private-Network를 True로 기입
if (Boolean.TRUE.equals(config.getAllowPrivateNetwork()) &&
Boolean.parseBoolean(request.getHeaders().getFirst(ACCESS_CONTROL_REQUEST_PRIVATE_NETWORK))) {
responseHeaders.set(ACCESS_CONTROL_ALLOW_PRIVATE_NETWORK, Boolean.toString(true));
}
//만약 Preflight요청이고, CORS에 Max Age를 설정해 두었을 경우
//헤더의 Access-Control-Max-Age에 Max Age를 기입
if (preFlightRequest && config.getMaxAge() != null) {
responseHeaders.setAccessControlMaxAge(config.getMaxAge());
}
response.flush();
return true;
}
정리해보면 다음과 같다.
거절 사유
- 서버에서 허용한 출처가 아닐 경우 거절
- 서버에서 허용되지 않은 메서드면 거절
- Preflight 요청이고, 허용한 Header가 아닐 경우 거절
세 가지 경우 중 하나라도 해당되면 거절하고, 아니라면 허용된다고 판단한다.
허용 후
만약에 허용됐다면 헤더의 Access-Control-Allow-Origin에 허용된 출처를 기입해준다.
추가로, 만약 해당 요청이 Preflight 요청이라면, 몇가지 내용을 헤더에 덧붙여준다.
- Access-Control-Allow-Method에 허용된 메서드를 기입
- Access-Control-Allow-Headers에 허용된 헤더를 기입
- CORS 설정에 Max Age를 설정해 두었을 경우 Access-Control-Max-Age에 Max Age를 기입
마지막으로, CORS 설정에 따라 몇가지 내용을 헤더에 덧붙여준다.
- CORS 설정 중 노출 허용인 헤더가 있을 경우 헤더의 Access-Control-Expose-Header에 노출 허용 헤더를 기입
- CORS 설정의 Allow-Credentails가 TRUE인 경우, 헤더의 Access-Control-Allow-Credentials를 True로 기입
- 만약 CORS 설정에 Private 네트워크를 허용해두었고, 요청 헤더에 Access-Control-Request-Private-Network=true인 경우, 헤더의 Access-Control-Request-Private-Network를 True로 기입
Preflight인지 검사 로직 : 메서드가 OPTION이고, Origin이 null이며, Access-Control-Request-Method가 null임
여기서, 상단에 위치한 checkOrigin() 메서드의 구현체를 보면,
전달한 CorsConfiguration을 하나씩 차례로 돌며 같은 출처(Origin)이 있으면 return하게 된다.
즉, CorsConfiguration에서 설정하지 않은 출처라면 null을 return하게 된다.
만약 allowOrigin이 null이라면, 요청을 Reject 하게된다.
근데 왜 굳이 null인지 검사하는 로직이지?
내 개인적인 생각으로는, 구현체에 요청의 Origin을 @Nullable로 처리해둔 것을 보아
Origin이 null일 경우 + 서버에서 설정한게 없을(null) 경우도 같이 처리한 의도인 것 같다.
rejectRequest 메서드의 구현체는 아래와 같다.
내가 보았던 403 Forbidden은 여기서 보았다.
결과적으로, Preflight 요청이 아니더라도 허용된 출처가 아니라면 response에 403 Forbidden을 기입한다.
결국 handleInternal가 return false -> processRequest가 return false 가 되고
isValid는 false가 된다.
!isValid == true이기 때문에, FilterChain을 더 진행하지 않고 return하게 된다.
결론
요청한 도메인의 출처(서버 도메인)와 서비스를 처리한 출처는 달랐다.
만약 사용자가 해당 서비스로 직접 요청했고 출처가 달랐다면, 브라우저가 preflight를 진행하여 CORS Error가 나타났을 것이다.
하지만 중간에 Gateway를 거쳤기 때문에, Gateway가 서비스에 전달한 요청은 403을 받았다.
Gateway는 받은 요청 그대로 403 Forbidden을 주었다.
새롭게 알게된 점 & 느낀점 🐜
1. 브라우저의 preflight 요청이 아니더라도 허용된 출처가 아니면 403Forbidden을 뱉는다.
2. CORS 중 Private Network 설정도 있었다.
3. CORS를 다 알고 있다고 생각했지만 아니었다.
4. CORS 처리를 아주 자세하게 살펴봐서 좋았다.
'Error' 카테고리의 다른 글
Amazon Linux uwsgi 설치 중 오류 (0) | 2023.09.05 |
---|---|
[Django & uwsgi] open() "/var/lib/nginx/tmp/uwsgi/1/00/0000000001" failed (13: Permission denied) while reading upstream (0) | 2023.09.05 |