서론
우리는 프로젝트를 진행하다가 "...CORS disabled"와 같은 CORS에 대한 오류를 만날 수 있다.
도대체 CORS가 뭐길래 우리를 이렇게 귀찮게 하는가?!!
이번에는 우테코의 "나봄님의 CORS"를 통해 CORS에 대해서 알아보고, 정리하는 시간을 가져보았다.
SOP(Same Origin Policy)
SOP란 다른 출처에 대한 리소스를 사용하는 것을 제한하는 보안방식을 말한다.
SOP에 대해 잘 알면, 보안에 큰 도움에 될 수 있다.
SOP에서 O에 해당하는 출처(Origin)이란, 위처럼 생긴, URL의 구성요소 중에 Porotocol, Host, Port를 통해 같은 출처인지, 다른 출처인지 판단할 수 있다. 세가지중 하나만 틀려도 다른 출처, 세가지가 모두 같아야 같은 출처라고 이야기 한다.
번외로 인터넷 익스플로러의 경우는 출처를 판단하지 않는다고 한다. port가 달라도 같은 출처로 판단한다.
https://localhost // 1번
http://localhost:80 // 2번
http://127.0.0.1 // 3번
http://localhost/api/cors // 4번
그러면 위의 4가지의 출처는 같은 것일까??
여기서 같은 출처를 가진 주소는 2번과 4번이다.
1번의 경우 https로 프로토콜이 다르다. 그러므로 다른 출저로 판단한다.
3번의 경우 127.0.0.1 은 localhost가 맞지만, 브라우저 입장에서는 String value로 비교를 하므로, localhost와 127.0.0.1은 다르므로 다른 출처로 판단한다.
4번의 경우 /api/cors는 추가적으로 붙는 로케이션이므로, /api 앞까지 비교했을 때는 동일하므로 동일출처로 판단한다.
그러면 SOP는 언제 이용이 될까? 위의 예시를 같이 보면서 확인한다.
1. 사용자가 SNS에 로그인해서 해당 서비스에서 로그인한 사용자에 대해 인증토큰을 발급 해준다.
2. 해커가 사용자에게 악성 링크 이메일을 보내고, 선량한 사용자가 해당 링크를 클릭한다.
3. 해커는 링크로 들어온 해당 사용자의 토큰을 이용해서 SNS에 악성 게시물을 올리도록 한다.
4. 여기서 SNS에서는 Origin을 확인해서 어디서 요청이 온 지를 확인한다. 자신의 출처와 다르므로 Cross Orgin이라고 판단해, SOP를 위반했으므로 이 요청을 받아들이지 않는다.
SOP는 이렇게 보안적으로 이용이 된다.
하지만 여기서 문제가 생긴다.
우리는 프로젝트를 진행하면서 frontend와 backend를 따로 분리해서 개발을 진행하고 있다.
그럼 각각 다른 포트를 부여해서 사용하게 되는데, 다른 출처의 리소스가 필요하다는 뜻, 이럴 때는 어떻게 해야할까??
답은 CORS이다.
CORS?
CORS는 Cross-Origin Resource Sharing의 약자로 다른 출처의 자원을 공유한다는 의미를 가지고 있다.
MOZLLA에서 CORS를 설명하는 내용으로는, CORS는 추가 HTTP헤더를 사용하여, 한 출처에서 실행중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에게 알려주는 체제라고 한다.
CORS의 접근제어 시나리오에는 3가지가 있다.
Simple Request, Preflight Request, Credentialed Request가 존재한다.
단순 요청 (Simple Request)
Preflight 요청 없이 바로 본 요청을 보내면서 Cross Origin인지 확인한다.(하단의 prefilght request 참고)
다음 조건을 모두 만족해야 한다.
- GET, POST, HEAD 메서드 중 하나여야만 한다.
- Content-Type가 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야만 한다.
- 헤더는 Accept, Accept-Language, Content-Language, Content-Type만 허용 된다.
흐름을 보면,
1. Client는 Origin(이 요청이 어디서 오는 것인지)을 밝힌다. 어떤 요청(GET,POST 등등)을 보낸다는 것을 알려준다.
2. 서버는 모든 오리진을 허가(Access-Control-Allow-Origin)을 해준다. (여기서 *은 와일드 카드형식으로 "모든"을 의미한)다.
프리플라이트 요청 (Preflight Request)
1. OPTIONS 메서드를 통해서 다른 도메인의 리소스에 요청이 가능한 지 확인 작업을 한다.
2. 요청이 가능하다면 실제 요청(Actual Request)을 보낸다.
본 요청을 보내기전에, 서버에게 요청을 보내도 되는지 물어보고(OPTIONS), 가능하다면 응답한다.
Preflight에는 사전요청시 물어볼 내용과 그에대한 포멧(PREFILGHT REQUEST)이 있다.
- Header에는 Origin이 있어야한다. 이 요청이 어디서 날아오는 것인지.
- Access-Control-Request-Method 라는 실제 요청하는 메서드이다.
- Access-Control-Request-Headers 라는 실제 요청의 추가 헤더를 어떤걸 보내는지 물어보는 메서드이다.
서버측에서는 Preflight에 대한 답변을(PREFILGHT REPONSE) 내준다.
- Access-Control-Allow-Origin : 서버 측 허가 출처, 이 Origin은 허가 되었다.
- Access-Control-Allow-Methods : 서버 측 허가 메서드, 이런 메서드가 허가 되었다.
- Access-Control-Allow-Headers : 서버 측 허가 헤더, 이런 Header는 허가 되었다.
- Access-Control-Max-Age : Preflight 응답 캐시 기간, Preflight를 보낼 시 두번의 요청이 가는데, 사전요청, 실제요청이 간다. 매번 두번의 요청을 하는 것은 리소스적으로 낭비이므로, 이 응답에 대해서 캐싱을 하고, 같은 요청시 확인하고 이미 있는거면 바로 본요청을 보낸다.
- 특징적으로, RESPONSE는 응답코드는 200대여야하며, 응답 body는 비어있는 것이 좋다.
Preflight 왜하는거야? 그냥 Simple Request하면 간단하잖아
CORS Preflight는 CORS를 모르는 서버를 위해서 존재한다.
CORS에 대해서 모르는 서버가 있다고 가정해보자. 그러면 Client에서 Origin을 담아서 CORS 요청(request)을 보냈는데, 서버에서는 CORS에 대해 모르니까 일단 Response-allow-origin은 비운상태로 응답해준다. 이후 Origin이 맞지 않으니 CORS에러가 난다.
만약 요청이 GET,POST정도가 아니라 DELETE요청이라고 한다면, 서버는 요청에 의해 Delete를 일단 해당 요청에 의해 DB를 비우고 응답을 보내고, 이후 CORS에러가 나게 된다. 하지만 이미 요청에 의해 DB가 지워져버린 상태이다...
이런 상황을 방지하고자, Preflight 요청을 본 요청이전에 보냄으로서 Response-allow-origin을 받는다. 이게 비워져 있다면, 설정이 맞지 않으니 CORS에러가 생긴다. 이 Preflight요청은 사전요청이므로 이후에 어떠한 요청이 이루어지지 않는다. 그러므로 서버는 안전하게 지켜지게 된다. 한마디로 Preflight는 CORS를 모르는 서버를 위해서 필요한 작업이다.
인증정보 포함 요청 (Credentialed Request)
인증 관련 헤더를 포함할 때 사용하는 요청이다.
토큰을 클라이언트에서 자동으로 담아서 보내고 싶을 때, credentials : include 를 하게되면 서버측까지 전달한다.
서버측에서도 Access-Control-Allow-Credentials를 true로 응답해줘야한다. 그래야 Client에서 보내는 것을 받을 수 있다.
해당 Allow-credentials 옵션을 true로 하는 순간, Access-Control-Allow-Origin는 *로 모든 것을 허용하면 안된다. 에러가 생긴다. 정확한 Origin을 주어야한다. 만약 hello.com에서 보낸다고 하면, 이 Origin을 설정해서 허용해줘야 한다.
CORS 해결하기
1. 프론트 프록시 서버 설정
브라우저가 프론트 서버로 요청을 보낸다. 일단 브라우저 입장에서는 목표인 Target이 front 서버이고, origin 역시 front서버일 테니 Origin이 같으므로 문제가 없을 것이다.
프론트 서버에서는 /api에서의 요청을 백엔드로 보내주고 싶다. 여기서 origin은 front 서버 주소인데, Target은 백엔드 서버로 된다. CORS 오류는 브라우저에서 나타나는데, 브라우저 입장에서는 문제가 없으니 문제가 터지지 않는다.
module.exports = {
devServer: {
proxy: {
'/api': {
// origin : http://localhost:80801
target: 'http://localhost:8080',
changeOrigin: true
},
},
},
};
예를들어 frontend를 vue를 사용한다 했을 때, 위처럼 vue.config.js를 보면, 프론트는 8081포트를 사용하지만, '/api 로 요청이 들어오면, 8080으로 연결 되도록한다. 브라우저입장에서는 이 내용을 모르니 잘 동작한다.
2. 직접 헤더에 설정
매우 귀찮다..! 패스!
3. 스프링 부트 이용
@RequestMapping("/api/cors")
@RestController
@CrossOrigin(origins = "http://localhost:8081") // 추가
// @CrossOrigin(origins = "*", allowCredentials = "true") // 오류!!
public class CorsApiController {
...
스프링부트에서는 @CrossOrigin이라는 어노테이션을 컨트롤러에 붙여서 해결한다.
origin설정을 프론트에서 들어올 8081포트로 해준다. 만약 설정을 안해주면 default로 모든 포트를 받게된다. 그러므로 origin설정을 해야한다.
위에서 인증정보 포함 요청에서 했던 얘기로 위의 코드에서 주석처리된 내용처럼, allowCredentials를 true면서, 모든 origin을 허용해주면 에러가 발생할 것이다.
하지만 이 방법은 사용하는 곳에 모두 @CrossOrigin 어노테이션을 사용해야한다는 수고로움이 있다. 전역적으로 사용하기위해 configuraiton을 만들어준다.
@Configuration
public class CorsConfiguration implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/api")
.allowedOrigins("http://localhost:8081");
}
}
이렇게 CorsConfiguration이라는 Config클래스를 따로 만들어서 전역적으로 관리해준다.