
분명히 개발서버에서는 아무 문제 없던 CORS 문제가 등장했다. 서버에 Origin 설정을 안한 것 아니냐 라고 말한다면… 당연히 했다!
안했으면 개발서버에서 정상적으로 작동 되었을 리가 없다!
CURL로 CORS 확인도 해봤다! 통과 된다! 근데 웹에서는 안된다!
정말 너무 많이! 여러번! 계속! CORS 설정이 짜증나게 만들어서 체크 리스트를 만들었다.
1. 소스코드 CORS 설정 확인하기
CORS 설정은 어디에나 비슷하다. WebMvcConfig에서 addCorsMappings를 Override 하면 된다.
허용할 URL, 메소드를 정의 하고 쿠키 사용 여부에 따라 Credentials를 추가한다.
나같은 경우에는 서버 application.yml 파일에 도메인 정보를 넣고 서버마다 관리하는 것을 선호한다.
테스트를 위한 로컬 URL의 경우 변수 정도로 추가하는 편이다.
가급적 *를 활용하여 모든 URL을 허용하는 것은 하지 않는다. (뒤에서 이야기 하겠지만 이번 뻘짓의 원인 이기도 했다.)
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
private static final String LocalUrlHttp = "http://my-local.co.kr:3000";
private static final String LocalUrlHttps = "https://my-dev.co.kr:3000";
@Value("${app.domain.front}")
String frontUrl;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedMethods(
HttpMethod.HEAD.name(),
HttpMethod.OPTIONS.name(),
HttpMethod.GET.name(),
HttpMethod.POST.name()
)
.allowedHeaders("*")
.allowCredentials(true)
.allowedOrigins(getOrigins());
}
public String[] getOrigins() {
String profile = StringUtils.defaultIfEmpty(System.getProperty("spring.profiles.active"), "");
List<String> origins = new ArrayList<>();
origins.add(frontUrl);
if (!profile.equals("prod")) {
origins.add(LocalUrlHttp);
origins.add(LocalUrlHttps);
}
return origins.stream().toArray(String[]::new);
}
}
이때 허용 메소드에 OPTIONS는 추가해 주는 것이 좋다. 프론트 서버에서 허용되는 URL을 호출할때 OPTIONS를 호출하여 체크한다.
보안 규정에 OPTIONS를 막으라는 경우가 있는데
프론트와 백엔드가 분리 된 환경에서는 대부분 (거의 100%) OPTIONS가 필요하기 때문에 허용하도록 설정해야 한다.
2. cURL을 이용하여 설정된 CORS 확인하기
cURL은 리눅스나 맥에 기본 설치 되어 있는 라이브러리이다. 프로토콜을 이용하여 서버에서 url로 데이터를 전달할 수 있다고 하는데
이를 이용하여 원하는 url를 호출하여 테스트 할 수 있다. 심지어 반드시 해당 서버에서 호출을 하지 않아도 테스트는 할 수 있다.
맥이나 리눅스라면 콘솔(또는 터미널에서) 아래 명령어를 호출 하면 CORS가 허용되는지 알수 있다.
curl --verbose --request OPTIONS '요청을 받는 origin(API)' \
--header 'Origin: 요청하는 origin(서버)' \
--header 'Access-Control-Request-Headers: Origin, Accept, Content-Type' \
--header 'Access-Control-Request-Method: GET'
예시를 만들면 아래와 같다
curl --verbose --request OPTIONS 'https://aaa.com' \
--header 'Origin: http://localhost:8080' \
--header 'Access-Control-Request-Headers: Origin, Accept, Content-Type' \
--header 'Access-Control-Request-Method: GET'
이번 경우에 cURL을 호출 하면 결과가 HTTP/2 200 으로 나와
AAAA-api.co.kr 와 BBBBB.co.kr 의 CORS가 허용되어 있다는 것을 알 수 있다.
하지만 정작 웹에서 실행을 시키면 CORS 가 허용되었다고 나오지 않는다.
* Using HTTP2, server supports multiplexing
* Connection state changed (HTTP/2 confirmed)
* Copying HTTP/2 data in stream buffer to connection buffer after upgrade: len=0
* Using Stream ID: 1 (easy handle 0x7f7de680ca00)
> OPTIONS / HTTP/2
> Host: AAAA-api.co.kr
> user-agent: curl/7.79.1
> accept: */*
> origin: https://BBBBB.co.kr
> access-control-request-headers: Origin, Accept, Content-Type
> access-control-request-method: POST
>
* Connection state changed (MAX_CONCURRENT_STREAMS == 128)!
< HTTP/2 200
< date: Mon, 20 Feb 2023 06:22:23 GMT
< set-cookie: SCOUTER=z1h339ti138518; Expires=Sat, 10-Mar-2091 09:36:30 GMT; Path=/
< vary: Origin
< vary: Access-Control-Request-Method
< vary: Access-Control-Request-Headers
< access-control-allow-origin: https://BBBBB.co.kr
< access-control-allow-methods: HEAD,OPTIONS,GET,POST
< access-control-allow-headers: Origin, Accept, Content-Type
< access-control-allow-credentials: true
< access-control-max-age: 36000
< allow: GET, HEAD, POST, PUT, DELETE, TRACE, OPTIONS, PATCH
< x-content-type-options: nosniff
< x-xss-protection: 1; mode=block
< cache-control: no-cache, no-store, max-age=0, must-revalidate
< pragma: no-cache
< expires: 0
< server: JWS
< strict-transport-security: max-age=31536000
3. addMapping의 패스를 잘게 잘라라
위에서 addCorsMappings을 Override 한것을 확인해 보면 addMapping에 *를 이용하여 전체 허용을 한 것을 알수 있다.
registry.addMapping("/**")
전체 경로를 허용했으니 모든 URL이 허용될거 같지만 실제로는 아니다.
이번에 CORS가 발생했던 경로는 AAAA-api.co.kr/poll/CCCC/DDDD 와 같은 경로에서 발생하였다.
그래서 위에 cURL로 테스트 할떄 AAAA-api.co.kr/poll 을 host로 입력했다면 CORS가 허용되지 않았다고 나왔을 것이다.
문제는 apache 웹서버 에서는 “/**” 으로 매핑을 해도 전체 경로가 허용되지만 nginx 에서는 “/**” 을 허용하지 않아 발생한 문제였다.
개발서버는 apache로 되어 있어서 전체 매핑이 되었지만 운영서버는 nginx라 매핑이 안되었던 것!
결국에 허용해야 되는 URL에 앞부분을 입력해 놓은 것으로 해결 되었다.
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/poll/**")
.allowedMethods(
HttpMethod.HEAD.name(),
HttpMethod.OPTIONS.name(),
HttpMethod.GET.name(),
HttpMethod.POST.name()
)
.allowedHeaders("*")
.allowCredentials(true)
.allowedOrigins(getOrigins());
registry.addMapping("/key/**")
.allowedMethods(
HttpMethod.HEAD.name(),
HttpMethod.OPTIONS.name(),
HttpMethod.GET.name(),
HttpMethod.POST.name()
)
.allowedHeaders("*")
.allowCredentials(true)
.allowedOrigins(getOrigins());
}
정말 어처구니 없지만 이렇게 설정을 해주니 CORS가 허용되었다…
*가 안먹힐 줄은 생각도 못했는데 앞으로는 CORS는 잘게 나누어 세팅 해야겠다.
4. js 파일 CORS 허용 (crossorigin)
프론트 엔드 파트와 작업을 하면서 react나 node.js 같이 스크립트 파일의 CORS를 허용해 주어야 하는 일이 발생하였다.
위에 작업만 잘 수행해도 CORS가 해결되긴 하지만 스크립트에 CORS를 허용해주는 html 속성이 있어 정리
<script defer="defer" type="module" src="www.abc.com/test.js" crossorigin="anonymous"></script>
스크립트 테그안에 crossorigin 속성을 추가하면 된다. (속성을 아예 생략하면 CORS 요청을 하지 않으며, anonymous는 속성이 있되 값이 비었거나 잘못된 경우에 적용되는 기본값이다.)
5. allowCredentials(true) 와 와일드카드(*)는 같이 못 쓴다
쿠키나 인증 정보를 주고받으려고 allowCredentials(true)를 켰다면, origin 허용에 *(전체 허용)를 쓸 수 없다.
스프링이 아래 같은 에러를 내며 아예 뜨지 않거나, 떠도 브라우저가 요청을 거부한다.
When allowCredentials is true, allowedOrigins cannot contain the special value “*” …
자격증명을 아무 origin에나 열어주는 건 보안상 위험하기 때문에 막혀 있는 것이다.
그래도 서브도메인처럼 패턴으로 열어야 한다면 allowedOrigins 대신 allowedOriginPatterns(스프링 5.3 / 부트 2.4 이상)를 쓰면 된다. 이때 응답에는 *가 아니라 요청한 origin이 그대로 내려간다.
registry.addMapping("/poll/**")
.allowedOriginPatterns("https://*.my-service.co.kr")
.allowedMethods(
HttpMethod.HEAD.name(),
HttpMethod.OPTIONS.name(),
HttpMethod.GET.name(),
HttpMethod.POST.name()
)
.allowedHeaders("*")
.allowCredentials(true);
6. 웹서버(apache/nginx)에서 CORS 헤더가 중복되는 경우
이 글 제목에 apache/nginx가 들어간 이유이기도 한데, CORS 헤더를 애플리케이션과 웹서버 양쪽에서 동시에 붙이면 응답에 Access-Control-Allow-Origin이 두 번 들어간다.
이러면 브라우저는 아래처럼 거부한다.
The ‘Access-Control-Allow-Origin’ header contains multiple values ‘…, …’, but only one is allowed.
그런데 cURL은 CORS 정책을 강제하지 않기 때문에 헤더가 두 개든 말든 그냥 200으로 통과한다.
“cURL은 되는데 브라우저만 안 되는” 전형적인 원인 중 하나다.
해결은 단순하다. CORS 헤더는 한 군데에서만 내려주도록 정하면 된다.
스프링에서 처리한다면 nginx의 add_header Access-Control-Allow-Origin ... 같은 설정을 빼고, 반대로 웹서버에서 처리한다면 스프링 쪽 CORS 설정을 정리한다. 응답 헤더를 직접 까보고 access-control-allow-origin이 몇 개 찍히는지 확인하는 게 먼저다.
7. Spring Security를 쓴다면 http.cors()도 켜야 한다
스프링 시큐리티가 끼어 있으면 WebMvcConfigurer에 CORS를 아무리 잘 설정해놔도 동작하지 않을 수 있다.
시큐리티 필터가 컨트롤러보다 앞단에서 요청을 가로채는데, 인증 정보가 없는 preflight(OPTIONS) 요청을 막아버리기 때문이다.
시큐리티 설정에서 cors를 켜주면 MVC에 설정한 CORS 규칙(또는 CorsConfigurationSource 빈)을 그대로 사용한다.
http.cors(Customizer.withDefaults());
preflight 인 OPTIONS 요청은 인증 없이 통과되도록 열어두는 것도 잊지 말자.
8. 바뀐 설정이 반영이 안 될 땐 preflight 캐시를 의심하라
위 2번의 cURL 응답을 보면 access-control-max-age: 36000 이 있다. 이건 브라우저가 preflight 결과를 그 시간(초)만큼 캐시한다는 뜻이다.
서버 CORS 설정을 분명히 고쳤는데도 계속 같은 에러가 난다면, 브라우저가 예전 preflight 결과를 그대로 쓰고 있는 경우가 있다.
디버깅 중에는 max-age를 짧게 두거나, 브라우저 캐시를 비우고 다시 시도해 보자.
(참고로 크롬 등은 이 값을 무한정 믿지 않고 자체 상한, 보통 최대 2시간 정도로 제한한다.)
