교차 출처 리소스 공유, CORS(Cross-Origin Resource Sharing)에 대하여
CORS란?
교차 출처 리소스 공유(Cross-Origin Resource Sharing, CORS)는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제입니다.
출처(Origin): https://developer.mozilla.org/ko/docs/Web/HTTP/CORS
무언가 말이 어려우니 천천히 뜯어보자.
우선, CORS의 개념을 알기 위해선 Origin이 정확히 무엇인지부터 파악할 필요가 있다.
Origin은 우리 말로 번역해보면 출처라고 할 수 있다.
우리가 글을 쓸 때 다른 사람이 쓴 구절이 너무 마음에 들어서 해당 구절을 그대로 인용하게 된다면, 우리는 해당 구절을 우리가 직접 쓴 게 아닌 다른 누군가에게서 가져왔다고 표시해주어야하며, 일반적으로 글의 맨 마지막 혹은 각주에 해당 구절의 원본이 위치한 곳을 적어둔다.
아마 대학교에서 논문 비스무리한 걸 한 번이라도 써본 적 있는 사람이라면 무엇을 말하는지 알 것이다.
그리고, 웹 애플리케이션의 모든 리소스(자원) 역시 출처(Origin)을 가지고 있다.
예컨대, 내가 이 글의 맨 처음에 적은 CORS의 정의는 모질라라는 웹 사이트에서 가져온 것이므로, 출처(Origin)은 모질라 웹사이트가 되겠다.
조금 더 정확히 말해서, 웹에서의 Origin은 프로토콜 + 도메인 + 포트로 이루어져 있다.
출처가 같다 or 출처가 다르다
위 세 가지의 구성요소가 모두 같아야 Origin(출처)이 같다고 할 수 있으며, 셋 중 하나라도 다르다면 Origin이 일치하지 않는 것이다.
예컨대, 아래의 두 주소는 프로토콜과 도메인이 같지만 포트 번호가 서로 다르기 때문에 Origin이 일치하지 않는다고 간주한다.
즉, 출처가 다르다고 표현할 수 있는 것이다.
아마 Origin에 대한 개념 정리가 됐다면 Cross-Origin Resource Sharing이라는 말 자체를 이해하는 것은 어렵지 않을 것이다.
Cross-Origin, Origin을 교차한다는 의미로, 서로 다른 웹 애플리케이션끼리 자원(Resource)를 공유하겠다는 것이다.
이 말은 즉, Origin이 다른 https://seongonion.tistory.com과 https://naver.com 사이의 자원 공유, 다시 말해 request를 가능하게 해준다.
사실, 우리는 이미 너무나도 다양하게 이런 작업들을 해오고 있다.
대표적으로 카카오의 지도 API, 정부의 주소 API 등 다른 출처에 있는 자원들을 우리의 서비스로 가져와 사용함으로써 CORS를 하고 있는 것이다.
SOP(Same-Origin Policy)란?
CORS를 제대로 알기 위해선 SOP라는 개념이 우선시되어야 한다.
SOP는 Same-Origin Policy의 약자로 Cross와 Same이 대비되듯, CORS와는 반대되는 개념이다.
즉, SOP는 Origin(출처)이 동일한 웹 애플리케이션끼리만 자원을 공유하겠다는 정책이다.
다른 출처에 있는 자원들을 가져다 쓰는 것이 아주아주 흔한 일임을 고려해볼 때, 전적으로 SOP를 따르는 것이 말이되나 싶을수도 있다.
사실, 모든 웹 사이트는 SOP 정책을 기본적으로 따르고 있으며, 이를 우회하기 위해서 CORS를 사용하는 것으로 이해하는 것이 맞다.
즉, SOP의 Relaxation이 CORS인 것이다.
다시 말해, 모든 웹사이트는 기본적으로 Origin이 다른 웹 애플리케이션과 리소스를 공유하지 않도록 설정해놓고, 자신이 따로 정해놓은 신뢰할 수 있는 몇 개의 사이트들에만 예외(CORS)를 둬서 Origin이 다르더라도 리소스를 공유할 수 있도록 한 것이다.
Origin에 대한 검증은 브라우저의 몫
한 가지 눈여겨 볼만한 점은 Origin(출처)를 비교하는 작업은 서버가 아닌 브라우저가 해준다는 점이다.
따라서 서버 개발자는 Origin을 비교하는 로직은 구현할 필요 없이, 어떤 리소스들에 대하여 CORS를 허용할지만 명시해주면 된다.
이러한 논리에 따라서, 서버에서 요청한 리소스가 CORS 정책에 위반되더라도 요청에 대한 응답 자체는 200 OK로 넘어오고(요청 및 응답 자체에는 브라우저가 관여하지 않기 때문에), 이를 브라우저가 검수해 CORS 정책에 위반된다고 판단되면 해당 응답을 버리게 된다.
물론 브라우저를 통해서 통신을 테스트해보면 CORS 정책 위반에 대한 에러 로그를 받을 수 있지만, 서버 쪽의 로그만 살펴보게 되면 에러는 안찍히는데 응답을 못 받는 상황이 일어나 에러를 찾는데 애를 먹을 수 있다.
참고로, 브라우저 중 인터넷 익스플로러는 유일하게 출처 비교 시 포트 번호를 무시한다.
http://seongonion.tistory.com:8080과 http://seongonion.tistory.com:3000의 Origin을 동일하다고 판단하는 것이다.
오늘도 연전연패하는 인터넷 익스플로러이다.
CORS와 SOP, 왜 중요한가?
항간에는 CORS를 사용함으로써 XSS나 CSRF 공격을 막을 수 있다고 한다.
하지만, 이건 CORS의 존재 의미를 생각해봤을 때 조금은 갸우뚱한 이야기이다.
앞서 언급했듯, CORS는 SOP의 Relaxation을 위해 존재한다.
우리가 www.bank.com이라는 은행 페이지에 접속해 로그인하고 해당 인증 정보가 쿠키에 저장된다고 가정해보자.
이 때, 우리는 은행 페이지에 로그인이 유지된 상태로 악의적인 사람이 만든 다른 웹 페이지에 접속하게 되고, 해당 웹 페이지가 현재 쿠키에 저장되어 있는 은행 페이지의 인증 정보를 탈취해, 이를 이용하여 은행의 API를 통해 일정 금액을 타 계좌로 송금 요청하도록 설계되어 있다고 생각해보자.
만약, 은행의 송금 API가 SOP를 따르고 있다면, 악의적인 사람이 만든 웹 페이지와 송금 API의 Origin은 같지 않으므로 해당 요청은 무시될 것이다.
반대로, 이번엔 우리가 직접 온라인 송금을 하고 싶다고 가정해보자.
그리고 이 때, 온라인 송금을 위해선 api.bank.com이라는 엔드포인트로 POST 요청을 보내야한다고 가정해보자.
만약 www.bank.com 이 SOP를 따른다면, api.bank.com은 자신과 Origin이 같지 않기 때문에 해당 엔드포인트로의 POST 요청은 CORS 에러를 떨구게 될 것이다.
하지만 이 때, CORS 설정을 통해 api.bank.com에 대한 리소스 공유를 허용해주게 된다면 두 출처 간 통신이 가능해져 온라인 송금이 가능해질 것이다.
이 이야기를 정리해보면, 사실상 XSS나 CSRF 공격을 막는 것은 SOP이고 SOP만으로 개발을 하기는 너무 제약이 많으니, 그에 대한 제한을 CORS를 허용한 웹 어플리케이션에 한정해 풀어주는 것이라고 할 수 있다.
나름 잘 옮겨 적었다고 생각했는데, 이해가 됐을지 모르겠다.
해당 예시에 대한 원문은 아래 링크를 통해 확인할 수 있으니 원한다면 참고하시길 바란다.
https://nodeployfriday.com/posts/cors-cyber-attacks/
CORS의 시나리오
그렇다면, CORS가 어떠한 방식으로 작동되는지 알아보자.
1) Preflight Request
Preflight Request 방식은 본 요청을 보내기 전에 http의 OPTIONS 메서드를 사용하여 해당 요청이 안전한지 확인하는 방식이다.
위 그림은 Preflight Request의 예시이다. 하나씩 살펴보자.
1. 클라이언트가 특정 서버에게 Request를 보내면, 해당 Request를 보내기 이전에 브라우저에서 Origin과 Request-Method, Request-Headers 등의 정보를 담아 OPTIONS 메서드로 사전 요청을 보낸다.
2. 해당 Request를 받은 서버는 자신들이 CORS를 허용하는 Origin 리스트와 Method 리스트, Header 리스트를 담아 응답으로 보낸다.
3. 이 응답을 받은 브라우저는 응답에 담긴 정보와 자신의 CORS 정책을 비교한 후, 해당 서버로 요청을 보내는 것이 안전하다고 판단되면 동일한 엔드포인트로 기존에 보내려던 Request를 보낸다. (안전하지 않다고 판단하면 본 Request를 보내지 않는다)
4. 서버는 Request에 대한 응답을 주고, 브라우저는 이 응답을 받아 최종적으로 클라이언트에게 넘겨준다.
2) Simple Request
Simple Request는 앞서 본 Preflight Request와 로직 자체는 같지만, Preflight Request의 유무에서 차이를 보인다.
이게 무슨 말장난인가 싶겠지만 말 그대로이다.
Simple Request에서는 사전 요청없이 본 요청을 바로 보낸 후, 서버가 Response의 헤더에 Access-Control-Allow-Origin 값을 담아주면 그 값을 토대로 브라우저가 CORS 위반 여부를 검사하는 것이다.
이게 가능하다면 뭐하러 Preflight Request 같은 걸 사용하나 싶지만, Simple Request를 사용하기 위해선 아래 조건을 모두 만족해야한다.
일일히 다 적기는 번거로워서 캡처해왔다.
다른 것보다 바로 체감할 수 있었던 허용 헤더의 종류와 Content-Type 헤더를 살펴보자.
허용된 헤더들을 보면 알 수 있겠지만, 흔히 사용자 인증에 사용되는 Authorization 헤더는 포함되어 있지 않다.
또한, 대부분의 API들이 사용하는 application/json의 Content-Type 또한 Simple Request에선 사용할 수 없다.
다른 부분들은 직접 체감할 수 있던 부분이 없어서 잘 모르겠지만, 이 두 가지 조건만으로도 Simple Request를 쉽사리 사용할 수 없다는 걸 확인할 수 있었다.
실제로도, Simple Request 방식은 잘 사용되지 않는다고 한다.
3) Credentialed Request
세 번째로 Credentialed Request는 말 그대로 인증된(Credentialed) 요청을 사용하는 방식이다.
사실, 이 방식은 앞서 봤던 Preflight Request나 Simple Request와는 결이 다르다.
Credentialed Request는 특정 Request의 CORS 정책 위반여부를 판단하기보다는 CORS 사용시 Request에 쿠키나 인증 값을 함께 전송하거나 아예 전송을 막기 위한 옵션이다. (물론 same-origin일 때도 전송을 막을 수 있다)
기본적으로, 브라우저의 비동기 요청 방식인 XMLHttpRequest와 Fetch API 방식은 보안 상의 이유로 CORS 이용 시 별도의 요청 없이는 브라우저의 쿠키나 인증 정보를 담지 않는다.
하지만, credentials 옵션을 통해 요청에 인증과 관련된 정보를 담아줄 수 있도록 설정할 수 있는 것이다.
credentials 속성을 통해 설정할 수 있는 값을 다음 3개이다.
Options | Desc |
same-origin | URL이 호출 script 와 동일 출처(same origin)에 있다면, user credentials (cookies, basic http auth 등..)을 전송한다. 이것은 default 값이다 |
include | cross-origin 호출이라 할지라도 언제나 user credentials (cookies, basic http auth 등..)을 전송한다 |
omit | 절대로 cookie 들을 전송하거나 받지 않는다 |
출처: https://developer.mozilla.org/ko/docs/Web/API/Request/credentials
표에서 확인할 수 있듯, request.credentials의 기본값은 same-origin이다.
same-origin이 기본값이기 때문에, 우리는 네이버에 한 번 로그인하여 인증 정보를 받고 해당 값을 쿠키에 저장하면, 동일한 Origin의 다른 페이지에 접속하더라도 처음 로그인 시 얻은 인증정보를 계속해서 사용할 수 있게 된다.
이 말은 즉, Origin이 다른 웹 애플리케이션에 접속한다면 네이버 로그인 시 저장된 인증 정보는 넘어가지 않게 된다.
물론, 이것은 CORS 설정 자체와는 다른 이야기이다.
credentials 값을 same-origin으로 설정하더라도, 특정 웹 어플리케이션에 대해 CORS를 허용하도록 설정하면 Request시 인증 정보만 가지 않을 뿐이지 요청 자체는 성공적으로 진행된다.
same-origin 이외에도, CORS 시에도 credentials 값을 보내는 'include'와 same-origin이든 cross-origin이든 credentials를 보내지 않는 'omit'옵션도 존재한다.
하나 기억해야할 것은, 만약 credentials를 include로 설정하게 되면 CORS를 허용할 Origin의 리스트를 담는 Access-Control-Allow-Origin 헤더에 와일드카드 값인 '*'를 사용할 수 없다는 것이다. (와일드카드 값을 사용하면 모든 Origin에 대해 CORS를 허용하겠다는 의미가 된다)
The value of the ‘Access-Control-Allow-Origin’ header in the response must not be the wildcard ’*’ when the request’s credentials mode is ‘include’.
만약 credentials를 include로 설정해주었다면 두 가지 조건을 충족시켜주어야 한다.
1. Access-Control-Allow-Origin을 하나하나 명시해주어야 한다.
2. 응답헤더에 Access-Control-Allow-Credentials: true 가 존재해야 한다.
이유는 예측 가능하듯 보안상의 이유일 것이다.
요청 시 민감한 값인 인증 정보가 넘어가도록 설정하는 것인데, 이걸 뭣모르고 와일드카드 값으로 설정해줬다간 치명적인 보안 이슈를 겪을 수 있기 때문이다.
역시 개발의 세계에는 모르는게 죄가 되는 것을 방지해주는 많은 안전장치들이 존재한다.
CORS 이슈 해결
CORS에 의해 고통 받는 사람들이 많다보니 CORS 자체가 무슨 에러를 나타내는 단어인 것처럼 취급되곤 한다.
하지만, CORS 자체는 SOP가 가진 딱딱하고 제한적인 환경을 릴렉스 해주는 유연한 정책이다.
CORS는 우리의 아주 좋은 친구이므로, 앞으로는 CORS 자체를 에러 마냥 취급하는 일은 지양하도록 하자 ^_^
CORS 이슈를 해결하는데는 흔히 두 가지 방법이 쓰인다.
1) Access-Control-Allow-Origin 설정
가장 명쾌하면서도 정석적인 방법이다.
서버에서 자원 공유를 허용할 Origin을 Access-Control-Allow-Origin에 명시해주는 것이다.
개발 초기에는 편리함을 위해 와일드카드(*)로 적어줄 수 있지만, 가급적 리스트를 직접 적어주자.
Access-Control-Allow-Origin: https://seongonion.tistory.com
2) Proxy 이용
만약, 내가 프론트엔드를 맡고 있고 서버 쪽을 건드릴 수 없어서 Access-Control-Allow-Origin 값을 직접 설정해줄 수 없는 상황이라면 Proxy 서버를 이용해 CORS 이슈를 피할 수 있다.
Proxy 서버는 클라이언트가 서버에 요청을 보낼 때, 클라이언트와 서버 사이에 Proxy 서버를 위치시켜 해당 요청이 본 서버에 가기 전에 Proxy 서버를 거쳐가도록 하는 것이다.
따라서, Proxy 서버의 Response Header에 Access-Control-Allow-Origin: "*"을 포함해 클라이언트에게 보내주면, CORS를 검수하는 브라우저는 Preflight Request에서 해당 응답이 본 서버에서 온 것이라 생각하고 본 요청을 보내게 된다.
마무리하며
CORS 이슈를 해결하기 위한 자세한 구현은 개발환경에 따라 다르고, 방법도 여러가지이므로 서치 후 원하는 것을 택하면 되겠다.
이 글은 CORS 에러를 해결하는데 초점을 맞추기보다는 에러 해결 이전 CORS에 대한 이해를 돕기 위한 글이므로 거기에 초점을 맞춰 읽어주면 감사하겠다.
Reference
https://developer.mozilla.org/ko/docs/Web/API/Request/credentials
https://developer.mozilla.org/ko/docs/Web/HTTP/CORS