Swagger에서 CORS에러 해결
발생 상황:
이전 포스팅에서 API 제공을 위한 Stage 서버 구축을 한 후 Front와 App팀에 Swagger를 통해 API를 문서화하여 제공하였다. Front에서 진행하면서 CORS 에러가 발생하기에 아직 Front에서 도메인을 구매하지 않았으므로 localhost:3000에 대하여 CORS를 허용해달라는 요청이 왔고 Spring Security에 cors속성을 추가하고, CORS Configure구성을 다음과 같이 하였다.
// filter에도 http.cors() 추가
@Bean
CorsConfigurationSource corsConfigurationSource() {
CorsConfiguration configuration = new CorsConfiguration();
// TODO: front 웹 도메인으로 변경
configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000"));
configuration.setAllowedMethods(Arrays.asList("HEAD","POST","GET","DELETE","PUT", "PATCH"));
configuration.setAllowedHeaders(Arrays.asList("*"));
configuration.setAllowCredentials(true);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", configuration);
return source;
}
간단한 테스트를 통해 CORS가 발생하는지 체크하고 적용했을 때 CORS가 사라지는 것을 로컬 환경에서 테스트하고 PR을 올렸었다.
- ajax를 통해 api서버에 localhost:3000에서 요청을 보내는 테스트 코드
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<script src="https://code.jquery.com/jquery-3.5.1.min.js" integrity="sha256-9/aliU8dGd2tb6OSsuzixeV4y/faTqgFtohetphbbj0=" crossorigin="anonymous"></script>
<script>
$(function(){
$.ajax("https://mydomain/user/login")
.done(function(msg){
alert(msg);
})
.fail(function(){
alert("fail");
})
})
</script>
</body>
</html>
CORS 설정을 하고 나니 Swagger에서 GET Method는 정상적으로 작동하는데 POST Method에서 403이 뜨는 것이다..
조금 오랜만에 빌드한 것이라 무엇이 문제인지 모르고 여러 가지 검색을 진행하였다.
처음엔 nginx reverse proxy를 이용하고 있기 때문에 CORS문제라 생각하지 않았고 failed to load resource에 대한 검색으로 해결방안을 찾아보려고 했지만 해결되지 않았다..
하지만 사실 Response Headers에 이유가 담겨있었다.
- vary: Origin
- vary: Access-Control-Request-Method
- vary: Access-Control-Request-Headers
vary가 무슨 뜻인지 모르고 에러 해결을 위해 해 본 처리들은 다음과 같다.
초기 에러분석
- nginx log 확인
- nginx에서 에러 로그를 먼저 확인하였고 403에 대한 원인은 나타나지 않아 log format을 변경하여 좀 더 자세히 출력하도록 했으나 정보를 건지지 못했다.. (사실 Chrome에서 Safari로 트래픽이 오고 가는 게 정보였다.. (HTTP 프로토콜, 브라우저) CORS에 대한 개념이 부족했던 나였다 ㅠㅠ)
- nginx reverse proxy Header 추가
- resource에 접근하지 못하는 문제인데 request시 nginx는 upstream 서버로 proxy를 할 때 HTTP 버전을 1.0으로, Connection 헤더를 close 해서 전달한다고 해 Connection이 close 되면서 권한을 잃는 건가?라고 생각을 하게 되었고 header에 HTTP 1.1 버전으로 명시해주었지만 여전히 403이었다.
- Header에 접근 권한을 담아서 전달해주면 Resource에 접근할 수 있다고 하여 여러 Header를 set 해봤지만 여전히 403이었다.
- Postman, Curl명령어로 실행
- Postman과 Curl 명령어로 같은 api 호출 시 제대로 응답이 돌아왔다. 여기선 왜 되는 건지 이해를 못 했지만 이것 때문에 CORS문제는 아니라고 생각해버렸다..
Swagger의 설정 문제인지 3.0 버전이기에 문제가 있는 건지, nginx reverse proxy문제인지 계속 찾아봤지만 도저히 알 수가 없어 다시 처음부터 고민해보고 아무래도 cors 설정을 진행했기 때문에 안된 거라고 판단하고 cors 설정 이전으로 돌리자 Swagger가 잘 작동했다...
이전 버전으로 바로 롤백해보고 되는지부터 체크를 해봤어야 했다.. 경험이 없어서 서버 설정을 잘못했다고 생각한 게 가장 큰 실수 ㅠㅠ 잘 되던 게 새로 빌드하고 안 되는 거면 빌드한 게 문제인데 내가 구축해놓은 서버에 믿음이 없었던 것 같다.
그래서! CORS가 무엇인지 알아보고 Front와 Swagger에서 사용할 수 있게 CORS Config를 변경하였다!
해결방법은 CORS에 대한 개념을 먼저 정리하고 아래에 다시 설명하도록 하겠다.
그렇다면 먼저 CORS와 어떤 상황에서 발생하게 되는지 어떻게 해야 통과하는지에 대한 개념을 알아본다.
CORS는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제로, 웹 애플리케이션은 리소스가 자신의 출처(도메인, 프로토콜, 포트)와 다를 때 CORS HTTP 요청을 실행한다.
CORS의 예시: https://domain-a.com의 프론트 엔드 JavaScript 코드가 XMLHttpRequest를 사용하여 https://domain-b.com/data.json을 요청하는 경우.
보안 상의 이유로, 브라우저는 스크립트에서 시작한 교차 출처 HTTP 요청을 제한합니다. 예를 들어, XMLHttpRequest와 Fetch API는 동일 출처 정책을 따릅니다. 즉, 이 API를 사용하는 웹 애플리케이션은 자신의 출처와 동일한 리소스만 불러올 수 있으며, 다른 출처의 리소스를 불러오려면 그 출처에서 올바른 CORS 헤더를 포함한 응답을 반환해야 합니다.
참고: https://developer.mozilla.org/ko/docs/Web/HTTP/CORS
교차 출처 리소스 공유 (CORS) - HTTP | MDN
교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라
developer.mozilla.org
해석을 해보면 domain-a라는 웹 사이트가 있고 domain-a라는 웹사이트에서 자기 자신한테 resource 요청을 하는 게 Same-Origin requests, domain-b라는 사이트에서 domain-a에 resource요청을 하게 되면 Cross-Origin requests라고 한다.
Origin이 무엇이길래 뭐가 다르다고 CORS가 터지는 것일까?
Origin: URL구조에서 protocol, hostname, port를 합친 것입니다.
Cross-Origin 발생 조건
- protocol
- domain(hostname)
- port
무조건 브라우저간 통신이라고 해서 CORS 에러가 발생하는 것은 아니다 request는 simple request와 preflighted request로 나뉜다.
simple request란 무엇인가
- GET 요청, HEAD, POST 중의 한 가지 방식을 사용
- POST 방식일 경우 conte-type이 아래 셋 중 하나여야 한다.
- application/x-www-form-unlencoded
- multipart/form-data
- text/plain
simple request 과정
simple 요청은 다음과 같은 과정을 거친다.
1. 요청을 보낸다.
2. 브라우저는 Host와 같은 헤더를 추가하는 것 외에도 교차 출처 요청에 대해 Origin Request Header를 자동으로 추가
GET /products/ HTTP/1.1
Host: api.domain.com
Origin: https://www.domain.com
3. 서버에서 Origin 리퀘스트 헤더를 확인, Origin 값이 허용되면 Access-Control-Allow-Origin요청 헤더 Origin 값으로 설정한다.
Http/1.1 200 OK
Access-Control-Allow-Origin: https://www.domain.com
Content-Type: application/json
4. 응답을 받은 브라우저는 Access-Control-Allow-Origin 헤더가 탭의 출처와 일치하는지 확인한다. Access-Control-Allow-Origin 값이 정확히 출처와 일치하거나, "*" 와일드카드 연산자를 포함하는 경우 검사가 통과된다.
Preflighted request 과정 - 중요!
preflighted 요청은 simple request와는 다른 유형의 CORS 요청이다. 브라우저에서 진짜 요청을 보내기 전에 미리 확인 요청을 보낸다. 이 요청은 OPTIONS 메서드를 사용한다.
preflighted 요청은 다음과 같은 과정을 거친다.
1. ajax 요청을 보낸다.
OPTIONS /products/ HTTP/1.1
Host: api.domain.com
Origin: https://www.domain.com
Access-Control-Request-Method: POST
Access-Control-Request-Headers: Authorization, Content-Type
2. 서버는 허용된 메서드 및 헤더를 지정하여 응답한다.
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://www.domain.com
Access-Control-Allow-Method: GET, POST, OPTIONS, PUT
Access-Control-Allow-Headers: Authorization, Content-Type
Content-Type: application/json
3. 헤더와 메서드가 통과되면, 브라우저는 원래 CORS 요청을 보낸다.
POST /products/ HTTP/1.1
Host: api.domain.com
Authorization: token
Content-Type: application/json
Origin: https://www.domain.com
4. 응답은 Access-Control-Allow-Origin 헤더에 올바른 출처가 있으므로 검사를 통과한다.
과정 설명에 좋은 이미지가 있어서 퍼왔다.
다시 돌아와서 그렇다면 우리 서버에서는 왜 문제가 일어났을까?
https://mydomain의 swagger에서 https://mydomain/user로 요청을 한 것인데 그럼 같은 도메인 아닌가??..
일단 서버 구성을 다시 한번 보겠다. 그림이 없는 점 양해 바랍니다 ㅠㅠ
Swagger <------------>AWS 로드밸런서 --------> Nginx(Proxy) <--------------> tomcat
(https://mydomain/user/login:443)<---->80포트 리다이렉트 (http://localhost/user/login:8080)
Swagger에서 mydomain으로 request를 보내면 우선 AWS 로드밸런서에서 SSL인증서를 달고 80 포트로 리다이렉트 해준다. 그 후 Nginx 프록시에서 localhost:8080으로 요청하고, 응답 데이터를 받아 다시 domain으로 돌려주는 형식이다.
그런데 왜 Security에서 cors활성화를 하기 전엔 CORS 에러가 발생하지 않았을까??
위에서 정의한 바에 따르면 Origin이 다르기 때문에 나타나야 하는 게 아닌가?..
CORS는 기본적으로 브라우저 간 요청에서 발생하는 것이다!! (이 부분을 몰라서 삽질을 계속했다)
Nginx 프록시 서버에서의 요청은 서버에서의 요청이기 때문에 CORS 에러가 나타나지 않는다.
바로 이런 서버 간 통신에 대해서는 CORS가 발생하지 않기 때문에 postman에서 api 호출, curl 명령어로 호출하는 부분에서는 CORS에러가 발생하지 않았던 것이다.
CORS 해결을 위한 테스트 진행!
- 그렇다면 왜 Security에서 CORS 활성화를 하니까 작동하던 swagger에 문제가 생겼을까?
- 상황 : 프론트에서의 요청은 CORS가 해결되었지만 오히려 Nginx Reverse Proxy를 이용하는 부분에서 에러가 발생
- Security filter
- filter에서 CORS를 체크하기 때문에 서버에서의 요청까지 Cross-Origin을 체크해버렸다
- https://mydomain/user/login:80 -> http://localhost/user/login:8080 에서 Access-Control-Origin-Header가 없으니 filter에서 걸려서 돌아가지 응답하지 못한 것 같다.(사실 setAllowedOrigin에 등록되어있지 않아서 filter에서 거름)
- Nginx Proxy에서 Access-Control-Origin-Header를 달아주면 안 되는가?
- nginx.conf에 해당 설정을 추가했지만 여전히 api호출에 응답하지 않았다.
- Nginx Proxy에서 Access-Control-Origin-Header를 달아주면 안 되는가?
location \ {
// 이전 설정들... 생략
proxy_hide_header Access-Control-Allow-Origin;
add_header Access-Control-Allow-Origin $http_origin;
add_header 'Access-Control-Allow-Methods' '*';
add_header 'Access-Control-Allow-Headers' '*';
add_header 'Access-Control-Allow-Credentials' 'true';
Spring security에서 cors를 해제하면 Cross-Origin이 아닐 때 헤더는 잘 붙었지만 Cross-Origin에서 api를 호출하면 에러가 났다.
따라서 Security Filter가 어떻게 작동하는지 알아봐야 했다.
왜 Filter를 까 보니 isValid로 검증을 하는데 corsConfiguration을 넣는다. 그런데 위에서 우리는 AllowOrigin을 필요한 front 도메인만 넣어줬기 때문에 아무리 헤더를 달아도 Swagger에서 corsFilter를 통과하지 못했던 것이다.
- Spring Security에서 CORS를 비활성화하고, Nginx Proxy에서 Access Control Allow Header를 추가했을 땐 왜 CORS에러가 해결 안 됐을까?
- preflighted request 과정에서 보이듯 요청이 들어오면 서버는 허용된 메서드 및 헤더를 지정하여 응답한다. Security에서 cors 설정을 해제하면서 허용된 메서드 및 헤더를 지정한 게 없으니 아무리 헤더를 달아도 요청에 대해서 응답을 하지 못한 것이다.
- 헤더의 설정 여부와는 관계없이 브라우저는 prefliget요청을 보내게 되고 서버에서 cors설정이 제대로 되지 않는다면 브라우저의 prefliget요청은 에러를 내게 된다. 즉, 무의미한 설정이 된 것.
- 그럼 왜 본래 서버에서 배포한 Swagger에서는 api 호출이 되나요??
- 이건 정확히 확인한 것은 아니지만 내 개인적인 생각으로 https://domain.com/swagger에서 https://domain.com/user/login을 요청하면 nginx는 same-origin으로 판단되어 문제없이 응답한 게 아닐까..라고 생각한다.
해결
Swagger에서 response header에 달려왔던 Origin을 Security 설정에 추가하자.
configuration.setAllowedOriginPatterns(Arrays.asList("http://localhost:3000", "https://api.domain.com"));
편하게 Security에서 api 서버의 Origin을 추가하자.
또 하나의 방법으로 Front 쪽에서 Proxy를 활용하여 same-origin으로 요청하라고 하면 된다고 합니다.. ㅎㅎ Proxy 최고
느낀 점
CORS를 해결하는 방법에는 Spring Security, Cross-Origin에서 Proxy 활용, Nginx에서 설정이 있는 것 같다.
Spring Security가 제일 편하고 간단하며 커스텀하기 좋은 방법이라고 생각이 들었다.
CORS의 취지는 잘 알겠는데 열심히 설정해도 postman 같은 걸 이용하면 자원 탈취가 가능한 게 아닌가??
브라우저의 쿠키나 자원에 공격자가 공격 요소를 심어놓고 api호출을 하게 되면 서버에 치명적 공격을 취할 수 있는 건지 궁금해졌다.. CORS는 과연 좋은 보안정책인가 하는 생각이 들었고 대부분의 CORS해결 게시글을 보면 CORS를 피하려고 Allow Origin "*"을 많이 쓴다.. 그럼 모두가 접근할 수 있으니 CORS의 의미가 없는 거 아닌가 생각이 들었다..
보안적인 요소를 공부해봐야겠다.. 너무 어렵다...