CORS 얼마나 알고 계신가요?
전 X도 모릅니다. (1도 모른다는 뜻^^) 그래서 CORS 에러가 발생하면 항상 구글에다 이렇게 검색했어요.
그리고 모든 요청에 대하여 CORS를 허용해버렸습니다. 근데 CORS가 뭘까요?
아직 잘 모르겠지만 보안을 위해서 존재하는 일종의 프로토콜이라고 합니다.
그러니까 명색이 백엔드 개발자라는 양반이 보안을 신경쓰지 않았다는 말이죠. 왜 그랬을까요?
상당히 아마추어 같은 이유입니다. 이젠 프로답게 CORS를 해결해보고 싶습니다.
Cross Origin Resource Sharing의 약자입니다. 반대되는 개념도 있습니다.
반대되는 개념은 Same Origin Policy라고 부릅니다.
각설하고 CORS를 단어 단위로 살펴보겠습니다.
Cross: 교차, Origin: 출처, Resource: 자원, Sharing: 공유
말 그대로 서로 다른 출처끼리 자원을 공유하겠다는 뜻입니다. 그림으로 살펴볼까요?
(출처: MDN 공식문서)
그림 왼쪽에 있는 웹 페이지는 domain-a.com
에 접속하면 볼 수 있는 웹 페이지입니다.
domain-a.com
에 접속하면 웹 페이지를 출력하기 위해서 총 5개의 API 요청이 발생하고 있는데요.
그 중 셋은 Same Origin
이고 둘은 Cross Origin
입니다.
상단의 파란색 네모 박스안에 있는 API들은 Same Origin
입니다.
domain-a.com
에서 웹 서버 domain-a.com
로 요청을 보냈기 때문에 Same Origin
이죠.
그러면 하단의 빨간색 네모 박스안의 API들은 왜 Cross Origin
일까요?
domain-a.com
에서 domain-b.com
으로 요청을 보냈기 때문입니다.
서버는 허용되지 않은 Origin
으로부터 요청을 받으면 요청을 거절합니다.
즉, 빨간 박스에 있는 컨텐츠는 서버로부터 제공받지 못한다는 뜻입니다.
위의 그림으로 한 가지 사실을 알게 되었습니다.
요청을 보내는 곳(Client)과 요청을 받는 곳(Server)의 도메인이 다르면
Cross Origin
이라고 부르는구나!
맞는 말이지만 Cross Origin
은 도메인이 다를 때만을 이야기하는건 아닙니다.
말 그대로 Cross(서로 다른) Origin 끼리 통신할 때 발생합니다.
그러면 Origin은 뭘까요? 아래 그림의 URL을 같이 볼까요?
URL의 구조 중 일부에만 코멘트를 달아놨습니다. (포트는 생략이 가능합니다.)
Origin은 프로토콜, 호스트, 포트 셋을 의미합니다.
셋 중 하나만 달라져도 CROS
가 발생합니다.
몇 가지 예시를 들어보겠습니다.
예시 | CORS 여부 |
---|---|
http://velog.io --> https://velog.io | O 프로토콜이 다름 |
https://api.velog.io --> https://velog.io | O 호스트가 다름 |
https://velog.io:8080 --> https://velog.io | O 포트가 다름 |
https://velog.io/write --> https://velog.io | X |
https://velog.io/write?id=plzprayme --> https://velog.io | X |
간단하죠?
그런데 서로 다른 Origin
간의 통신은 절대로 불가능한걸까요?
서로 다른 Origin
간의 통신이 필요할 수도 있지 않을까요? 그럴땐 어떻게 해야할까요?
결론부터 말하면 HTTP Header
를 조작해서 허용할 수 있습니다.
조금 더 구체적으로 말하자면, 요청을 보내는 Origin에 대한 정보를 추가하면 됩니다.
CORS를 허용하는 방법은 Preflight
, Simple Request
두 가지가 있는데요.
두 가지를 살펴보겠습니다.
Preflight
단어의 뜻을 살펴보겠습니다.
먼저, 미리의 뜻을 가지고 있는 접두사 pre 와 날다의 뜻을 가진 flight 가 합성된 것 같습니다.
그러면 미리 날려본다. 이런 뜻이겠네요.
실제로 Preflight
는 브라우저가 본(main) 요청을 날리기 전에 날리는 사전 요청입니다.
그렇다면 API 요청이 발생할 때마다 사전 요청을 날릴까요? 그렇지 않습니다.
사전 요청을 날리지 않는 조건이 있습니다. Simple Request
를 살펴보겠습니다.
main 요청이 Simple Request
의 조건에 딱 맞다면 preflight
가 필요 없습니다.
그렇다면 Simple Request
의 조건이 뭘까요?
Simple Request의 조건
Accept
, Accept-Language
, Content-Language
, Content-Type
) 외의 다른 HTTP Header가 조작되지 않은 요청Content-Type
의 값이 application/x-www-form-urlencoded
, multipart/form-data
, text/plain
중 하나일 때XHR
객체로 API를 날릴 때 xhr.upload.addEventListener()
를 호출하지 않은 요청ReadableStream
객체가 사용되지 않은 요청실제로 그런지 예제를 작성해서 확인해보겠습니다.
const request = new Request('http://localhost:8081')
fetch(request, { method: 'GET' })
.then(response => alert(response))
Fetch API
로 작성한 아주 간단한 API 요청인데요.
Request
객체의 헤더를 조작하지 않은 GET 요청입니다.
API를 주고 받는 Origin 들을 그림으로 살펴보겠습니다.
localhost:8081
-> localhost:8080
로 보내는 요청입니다. 즉, 호스트가 다르니 CORS죠.
이제 요청 결과를 확인해볼까요?
브라우저 -> 개발자도구 -> 네트워크 탭을 캡쳐해서 살펴보겠습니다.
Preflight
가 없죠. Simple Request
이기 때문에 브라우저는 Preflight
를 날리지 않았습니다.
그런데 CORS 오류 라고 적혀있는게 보이시나요? Preflight
는 없지만 통신에는 실패합니다.
허용되지 않은 Cross Origin
이기 때문이죠.
CORS 오류는 조금 후에 해결하기로 하고 헤더를 조작해서 Preflight
를 발생시켜보겠습니다.
Preflight
가 발생하면 API는 아래 그림처럼 되겠네요.
위에 그림처럼 만들기 위해서 아래 코드를 살펴보겠습니다.
const request = new Request('http://localhost:8081')
request.headers.set("something", "new") // 여기 추가했음
fetch(request, { method: 'GET' })
.then(response => alert(response))
간단한 헤더를 추가했습니다. Key는 something이고 Value는 new입니다.
즉, Simple Request 조건인 "헤더를 추가하지 않는다" 를 어겼습니다.
요청 결과를 확인해봅시다.
이전에는 없던 preflight
가 발생했습니다.
근데 preflight
는 서버로 날아가서 무슨 일을 해주는걸까요?
Preflight
는 실제 요청을 보내기 전, 실제 요청이 안전한지 판단하기 위해서 보내는 사전 요청입니다.
그러면 Preflight
는 서버에서 뭘 하길래 main 요청의 안전 여부를 판단할 수 있을까요?
Preflight
의 Request
와 Response
객체를 살펴보면 알 수 있습니다.
CORS는 HTTP Header의 값을 조작함으로써 발생되며, HTTP Header의 값을 조작함으로써 허용 됩니다.
또, HTTP Header 값을 조작함으로써 거절할 수도 있습니다.
Preflight
의 Request
객체에서는 Access-Control-Request-
로 시작하는 헤더를 기억해야 합니다.
영어 단어 그대로 접근 제어(Access Control)를 요청(Request)하는 헤더인데요.
Preflight Request
는 main 요청의 정보들을 Access-Control-Request-
헤더들에 저장합니다.
앞서 살펴본 예제에서 우리는 분명히 main 요청의 헤더를 조작했습니다.
main 요청의 HTTP Method는 GET
이고 something
이라는 헤더를 추가했었죠.
그러면 Preflight Request
헤더에 아래와 같이 두개의 헤더가 추가됩니다.
Acess-Control-Request-Method: GET
Access-Control-Request-Headers: something
개발자 도구의 네트워크를 켜서 확인해봅시다.
Preflight Request
의 HTTP METHOD는 OPTIONS
입니다.
그리고 실제 요청에 추가했었던 something
이라는 헤더가 추가 되어 있는 것은 확인할 수 있습니다.
통신하며 자동으로 추가되는 즉, new Request()
를 통해서 자동으로 추가되는 헤더들을 제외하고, 새로 추가된 헤더들도 허용 해달라는 의미입니다.
그러면 이 Preflight Request
를 받은 후에 서버는 어떻게 할까요?
서버에서는 Preflight Request
의 헤더를 기반으로 Preflight Response
를 응답합니다.
구체적으로는, Access-Control-Allow-
로 시작하는 헤더를 추가해서 응답합니다.
그런데 서버가 알아서 해주지는 않고 프로그래밍이 필요합니다.
그러니까 사전에 프로그래밍 된 Origin
만 허용됩니다.
그럼 위 예제의 Preflight Request
를 날렸다면 어떤 Response
를 받아야 할까요?
정답은
가 추가되야 합니다.
서버에 로직을 추가하고 다시 볼까요? 저는 SpringBoot 서버에 추가하겠습니다.
@Configuration
public class CorsConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/") // 해당 URL에 오는 요청에
.allowedOrigins("http://localhost:8080") // 허용하는 Origin 추가
.allowedMethods("GET") // 허용하는 Method 추가
.allowedHeaders("Something") // 허용하는 Header 추가
.maxAge(3600L);
}
}
이렇게 추가하고 다시 Request
를 날리면 오류 없이 잘 동작합니다.
개발자 도구에서 확인해봅시다.
허용이 되었으니 아래처럼 요청들이 성공한 것을 볼 수 있습니다.
그리고 빨간 박스 중 위에 위치한 preflight API 명세를 살펴보면 아래와 같습니다.
서버에서 추가한 헤더들이 Response
헤더에 잘 추가되어 있습니다.
이렇게 CORS에 대해서 알아봤습니다.
이번엔 제가 개발에 참여한 프로젝트의 CORS 설정을 고쳐봅시다.
이 글을 작성한 이유이기도 합니다.
제가 개발에 참여한 사이드 프로젝트가 한동안 CORS 때문에 고생을 했습니다.
우리 프로젝트는 왜 CORS가 발생했을까요?
먼저, 우리 프로젝트의 Client와 Server의 Origin
을 그림으로 살펴보겠습니다.
앗, 클라이언트의 Origin
과 서버의 Origin
이 달랐군요. 그리고 클라이언트의 Origin
은 여럿이네요.
http:localhost:3000
는 개발환경이고, https://sgsg.space
는 배포 환경입니다.
반면에 서버는 https://api.sgsg.space
에서 요청을 기다리고 있었죠.
우리 프로젝트의 모든 요청에는 Authorization
헤더가 추가됩니다.
즉, 헤더를 추가했기 때문에(Simple Request가 아니기 때문에) Preflght
도 발생했습니다.
다시 말해서 Preflight
에 대한 Response
, 본 요청에 대한 Response
를 조작할 필요가 있었습니다.
어떻게 해결했을까요?
모든 Origin
을 허용해버렸습니다. 이렇게요.
아래 네모 박스를 보면 Access-Control-Request-Headers
에는 authorization
하나 뿐이지만,
위의 네모 박스처럼 Access-Control-Allow-Origin: *
을 사용해서 모두 열어놨구요.
Access-Control-Allow-Headers
에는 사용하지 않는 헤더들도 모두 넣어버렸습니다.
왜냐구요? StackOverFlow를 복붙 했으니까요.
지금이라도 수정하겠습니다. 어떻게 수정해야 할까요? 정답은 아래 네모 박스에 나와 있었습니다.
Access-Control-Request-Headers
에는 authorization
하나 뿐이거든요.
우리 프로젝트의 아키텍처를 아주 간단하게 살펴보겠습니다.
Preflight
는 NginX 선에서 처리됩니다. 본 요청은 SpringBoot까지 보내집니다.
따라서 NginX에서는 Preflight
요청을 처리했고, 본 요청은 SpringBoot 에서 처리했습니다.
이전에는 어떻게 처리했는지 살펴볼까요?
이전에 적용되어 있던 NginX 설정과 SpringBoot 코드입니다.
# nginx.conf
http {
# TODO
server {
# TODO
location {
if ($request_method = OPTIONS) {
# 모든 Origin 허용
add_header "Access-Control-Allow-Origin" "*";
# PUT DELETE 허용하지 않음
add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD";
# 사용하지 않는 헤더도 허용
add_header "Access-Control-Allow-Headers" "api_key, Authorization, Origin, X-Requested-With, Content-Type, Accept";
return 204;
}
}
}
}
@Configuration
public class WebConfig implements WebMvcConfigurer { // Confing
private static final long MAX_AGE_SECS = 3600L;
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("*") // 모든 Origin 추가
.allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") // 사용하지 않는 METHOD (PATCH) 추가
.allowedHeaders("*") // 모든 헤더 추가
.maxAge(MAX_AGE_SECS);
}
}
사용하지 녀석들을 이것저것 허용했고, 정작 사용하는 메서드의 일부는 허용하지 않았습니다.
이전의 악취나는 설정들을 어떻게 개선했는지 살펴보겠습니다.
# nginx.conf
http {
# TODO
server {
# TODO
location {
# 올바른 Preflight Request 인지 확인하기 위한 변수
set $FLAG "";
# Preflight Reuquest인지 확인하기
if ($request_method = OPTIONS) {
set $FLAG "A";
}
# 허용된 Origin인지 확인하기
if ($http_origin ~* (https://sgsg.space|http://localhost:3000)) {
set $FLAG "${FLAG}B"
}
# 위의 조건들이 TRUE면 요청을 허용한다.
if ($FLAG = "AB") {
add_header "Access-Control-Allow-Origin" $http_origin; // 일부 origin만 허용
add_header "Access-Control-Allow-Methods" "GET, POST, DELETE, PUT, OPTIONS"; // 사용하는 METHOD만 허용
add_header "Access-Control-Allow-Headers" "Authorization"; // 사용하는 헤더만 허용
add_header "Access-Control-Max-Age" "3600"; // 캐싱 타임 설정
return 204;
}
}
}
}
코드는 길어졌지만 안전하고 뭔가 깔끔해졌습니다.
이제 스프링부트는 어떻게 변경했는지 알아보겠습니다.
public class CorsFilter extends OncePerRequestFilter { // Filter
// 허용하는 Origin 목록
private static final List<String> ALLOWED_ORIGINS = new ArrayList<>(
Arrays.asList(
"https://sgsg.space",
"http://localhost:3000"
)
);
@Override
protected void doFilterInternal(
HttpServletRequest request,
HttpServletResponse response,
FilterChain filterChain
) throws ServletException, IOException {
try {
String origin = request.getHeader("Origin");
// Origin 검증
validate(origin);
// 검증 후 헤더 추가
response.addHeader("Access-Control-Allow-Origin", origin);
response.addHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS");
response.addHeader("Access-Control-Allow-Headers", "Authorization");
response.setIntHeader("Access-Control-Max-Age", 3600);
} catch (BadRequestException e) {
// 검증 실패 시 공격 Origin 로깅
logger.info(e.getMessage());
}
filterChain.doFilter(request, response);
}
}
Before는 WebMvcConfigurer.addCorsMappings()
을 오버라이딩 해서 사용했다. 반면에 After는 Filter
를 사용합니다.
허용된 Origin
인지 검사하기 위해서 반드시 Request
객체의 Origin
을 확인해야 했습니다.
그렇지만 WebMvcConfigurer.addCorsMappings()
에서는 Request
객체를 까볼 수 없었습니다.
어쩔 수 없이 Filter
를 하나 정의해서 Response
객체를 수정하게 되었습니다.
지금은 Preflight
는 NginX에서 관리합니다. 또, 본 요청은 SpringBoot에서 관리합니다.
이러면 허용되는 Origin
들을 관리하기가 어려워집니다.
예를들어, SpringBoot에서는 허용 Origin
을 추가했는데, NginX에는 까먹고 추가를 안할 수도 있습니다.
이런 문제를 해결하고 싶어서 한쪽에서만 관리를 하고 싶었는데 실패했습니다.
아직 원인을 정확히 파악하지 못했습니다. NginX와 SpringBoot에 대한 깊은 이해가 필요해 보입니다.
사실 허용하는 METHOD가 지나치게 많습니다.
Access-Control-Request-Method
에 담겨있는 Method들만 추가하고 싶은게 그게 안됩니다.
Request 객체의 HTTP Header들을 까봐도 Access-Control-Allow-Methods
가 없었거든요.
당연합니다. Access-Control-Allow-*
시리즈들은 Preflight
에만 추가되기 때문이죠.
짜잔~~ 잘 동작합니다.
왼쪽에 컨텐츠들도 잘 불러오고, 오른쪽에 API 들도 통신이 원활하게 된 모습입니다.
감사합니당