본문 바로가기

Infrastructure/Network

[Network] CORS 란?

매번 CORS에 대해서 정리해야겠다는 마음만 가지고 있고 정리를 미루고 있었다.
최근 진행한 W사의 기술 인터뷰에서 질문으로 나왔지만 명확한 답변을 하지 못하였고 현재 진행중인 사이드 프로젝트에서 발생하는 CORS관련 에러도 명확하게 해결하지 못하였다.

이 글을 읽은 사람들은 필자와 같은 실수를 하지 않기를 바라면서 개발자들을 힘들게 하는 CORS에 대해서 정리해보도록 한다.


SOP(동일 출처 정책) 란?

CORS를 학습하기 위해서는 SOP라는 개념에 대해서 먼저 학습해야 한다.

동일 출처 정책(same-origin policy)는 어떤 출처에서 불러온 문서나 스크립트가 다른 출처에서 가져온 리소스와 상호작용하는 것을 제한하는 중요한 보안 방식이다.
동일 출처 정책은 잠재적으로 해로울 수 있는 문서를 분리함으로써 공격받을 수 있는 경로를 줄여준다.
여기서 출처는 두 개의 URL의 프로토콜, 포트(명시하는 경우), 호스트가 모두 같아야 동일한 출처라고 말한다.

MDN

우리가 홈페이지에 접속하기 위해 http://www.example.com 이라는 값을 입력했다고 가정한 상황에서 허용되는 값과 허용되지 않는 값을 비교한 표다.

그림에서 알 수 있듯이 프로토콜, 호스트, 포트가 완벽하게 일치하는 경우에만 동일한 출처라고 인식하며 Origin이 같다고 브라우저는 인식한다.
웹 브라우저에서 동작하는 프로그램은 로딩된 위치에 있는 리소스에만 접근할 수 있다는 정책이다.
결국 개발자들을 힘들게하는 CORS오류를 발생시키는 범인은 동일 출처 정책이라는 점을 인지해야 하며 CORSOrigin이 다른 경우에도 요청할 수 있도록 하기위해 사용되는 개념이다.


CORS 란?

CORS는 Cross-origin resource sharing 의 약자로 교차 출처 리소스 공유를 의미한다.
교차 출처 자원 공유는 웹 페이지 상의 제한된 리소스를 최초 자원이 서비스된 도메인 밖의 다른 도메인으로부터 요청할 수 있게 허용하는 구조이다.

위키백과

위키백과에 의하면 자원이 최초 서비스된 도메인 이외의 다른 도메인으로 요청할 수 있게 허용한다고 적혀있다.
결국 개발자들을 힘들게 하는 것은 CORS 가 아니라 SOP(Same-origin policy)라고 하는 동일-출처 정책이다.

CORS는 추가 HTTP 헤더를 사용하여, 어떠한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우에게 알려주는 방법이다.
웹 애플리케이션은 리소스가 자신의 출처(프로토콜, 도메인, 포트)와 다를 때 교차 출처 HTTP 요청을 실행한다. 아래의 예시를 살펴본다.

웹 애플리케이션은 domain-a.com이라는 출처에서 실행 중이기 때문에 같은 출처인 domain-a.com서버에게 전달하는 요청은 같은 Origin이기 때문에 항상 허용되어 있다.
하지만 다른 출처인 domain-b.com으로의 요청은 Origin이 다른 Cross-origin요청이기 때문에 CORS 체제를 통해 요청을 해야 한다.

동일 출처 정책에 대한 처리

웹 브라우저에서 http://sitea.com으로 접속하여 자바스크립트 코드를 로딩하고 http://api.my.com을 호출한다면 SOP를 위반하게 되어 호출 에러가 발생한다.
SOP를 위반하지 않기 위해서는 아래와 같은 세가지 방법이 사용된다.

  1. 프록시를 이용하는 방법

일반적으로 SOP는 웹 페이지를 호출하기 위한 요청의 주소와 호출된 페이지에서 호출하는 API 서버의 주소가 다르기 때문에 발생한다.
웹 페이지를 제공하는 서버와 API를 제공하는 서버의 앞단에 Nginx와 같은 Reverse Proxy를 구축하여 주소를 동일하게 하여 SOP를 위반하지 않도록 구현할 수 있다.
하지만 프록시를 이용한 방식은 자사의 웹 사이트를 서비스하는 경우에는 사용이 가능하지만 일반적으로 백엔드 개발자가 프론트엔드 개발자에게 REST API를 제공하는 경우에는 적절하지 않은 방식이다.


  1. 특정 사이트에 대한 접근 허용 방식

브라우저에서 실행되는 자바스크립트의 요청을 받는 API 서버에서 들어오는 모든 요청을 허용하는 방식이다.
예를 들어 클라이언트에서 HTTPAPI를 호출하였을 때 요청에 응답을 하면서 HTTP 헤더에 요청을 처리해 줄 수 있는 출처를 의미하는 Request Origin을 추가한다.
위에 있는 그림으로 예를 들어 API 서버에서 sitea.com의 요청을 전부 허용하고 싶다면 아래와 같이 헤더에 Access-Control-Allow-Origin을 추가하면 된다.

Access-Control-Allow-Origin: sitea.com

특정 사이트를 지정하지 않고 모든 사이트의 요청을 허용하고 싶다면 아래와 같이 직접적인 주소 대신 *을 사용하면 된다.

Access-Control-Allow-Origin: *

실제 요청을 가지고 예를 들어 본다.
아래와 같이 브라우저가 서버로 요청을 보냈다고 가정해본다.

GET /resources/public-data/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: https://foo.example

서버는 요청 헤더의 Origin값을 보고 https://foo.example라는 Origin으로 부터 서버는 요청을 받은 사실을 알게 된다.
서버는 요청을 처리하면서 응답 헤더에 Access-Control-Allow-Origin* 또는 클라이언트의 요청 값으로 전달된 Origin값을 입력해야 한다.

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: * or Access-Control-Allow-Origin: https://foo.example
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

  1. Preflight를 이용한 CORS 통제

프리플라이트 방식은 실제로 요청을 서버로 보내기 전에 HTTP 메서드 중 OPTIONS 메서드를 사용하여 다른 도메인의 리소스로 HTTP 요청을 보내서 실제 요청을 전송해도 안전한지 확인하는 방식이다.
Cross-origin 요청 방식은 유저 데이터에 영항을 줄 수 있기 때문에 미리 미리 전송해서 확인하는 방법이다.

클라이언트의 요청을 살펴본다.

const xhr = new XMLHttpRequest();
xhr.open('POST', 'https://bar.other/resources/post-here/');
xhr.setRequestHeader('Ping-Other', 'pingpong');
xhr.setRequestHeader('Content-Type', 'application/xml');
xhr.onreadystatechange = handler;
xhr.send('<person><name>Arun</name></person>');

POST 메서드와 함께 XML body 데이터를 전달하는 클라이언트의 요청이다.
표준 헤더가 아닌 Ping-Other가 사용되었고 Content-Type 헤더에는 application/xml가 사용되었다.
프리플라이트가 트리거되는 조건에 충족(조건은 하단 주의 글 참고)되기 때문에 이 요청은 사전 요청인 preflighted 처리가 된다.

preflight request를 살펴보면 아래와 같다.
(예시에서 POST 요청에 Access-Control-Request-* 헤더가 포함되는 것으로 나오지만 실제로는 OPTIONS 메서드 요청에만 필요하다.)

OPTIONS /resources/post-here/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Origin: http://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

요청에서 Access-Control-Request-Method: POST는 실제 요청에서는 POST 메서드가 사용될 것을 서버에게 알려준다.
Access-Control-Request-Headers: X-PINGOTHER, Content-Type은 실제 요청에서 헤더에 X-PINGOTHERContent-Type의 헤더가 사용될 것이므로 서버에서 요청을 수락할지 결정한다.

preflight 요청을 받은 서버는 아래와 같은 preflight response를 전달한다.

HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01:15:39 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Methods: POST, GET, OPTIONS
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive

클라이언트가 서버에게 POST 메서드가 사용될 것이라고 알렸기 때문에 서버는 POST, GET, OPTIONS 메서드가 허용되어 있다고 알려준다.
요청시에 전달된 X-PINGOTHERContent-Type헤더 또한 허용되어 있다고 클라이언트에게 전달하게 되면서 클라이언트와 서버는 실제 요청을 주고받을 준비가 된다.
Access-Control-Max-Age: 86400은 서버의 응답을 캐싱할 수 있는 최대 캐싱 시간이다.

실제 요청은 CORS를 처리하는 것과 크게 관련이 없으므로 다루지 않는다.

주의

아래의 조건을 충족하지 않는 경우 CORS 프리플라이트 요청이 트리거되지 않는다.
프리플라이트 요청이 자동으로 트리거되지 않는 경우 브라우저는 SOP를 위반했다고 판단하고 CORS 에러를 발생시키며 사용자에게 응답 데이터를 제공하지 않는다.

  • GET, HEAD, POST 메서드 중 하나를 사용해야 preflighted 처리된다.
  • Connection, User-Agent(en-US), Fetch 명세에서 "forbidden header name"으로 정의한 헤더, Fetch 명세에서 "CORS-safelisted request-header"로 정의된 헤더 이외의 헤더를 사용해야 한다.
    단, Content-Type 헤더의 경우 application/x-www-form-urlencoded, multipart/form-data, text/plain이외의 헤더를 사용해야 preflighted 처리된다. (Fetch 명세 문서는 글의 가장 하단부에 첨부한다.)

기타. 인증정보를 포함한 요청

기본적으로 Cross-origin 요청에서 브라우저는 자격 증명을 서버로 전달하지 않는다.
따라서 클라이언트에서 아래와 같이 withCredentials 값을 true로 변경하여 자격 증명을 서버로 전달할 수 있도록 설정해야 한다.

const invocation = new XMLHttpRequest();
const url = 'http://bar.other/resources/credentialed-content/';

function callOtherDomain() {
  if (invocation) {
    invocation.open('GET', url, true);
    invocation.withCredentials = true;
    invocation.onreadystatechange = handler;
    invocation.send();
  }
}

아래는 클라이언트 요청이다.

GET /resources/credentialed-content/ HTTP/1.1
Host: bar.other
User-Agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10.14; rv:71.0) Gecko/20100101 Firefox/71.0
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Connection: keep-alive
Referer: http://foo.example/examples/credential.html
Origin: http://foo.example
Cookie: pageAccess=2

쿠키 값에 컨텐츠를 대상으로 하는 쿠키가 포함되어 있는 것을 확인할 수 있다.
쿠키 값이 서버로 전달되는 경우 서버는 정상적으로 클라이언트에게 응답 값을 표시하기 위해 아래와 같이 Access-Control-Allow-Credentials값을 true로 하여 헤더에 추가해야 한다.

HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain

단, CORS 실행 전 요청에는 자격 증명이 포함되지 않아야 한다.
서버에서 자격 증명으로 실제 요청을 처리할 수 있음을 의미하는 Access-Control-Allow-Credentials값을 true로 응답한 경우에만 요청에 자격 증명을 포함해야 한다.

또한 자격 증명 요청에 응답하는 경우 Access-Control-Allow-Origin 헤더 값에는 *와 같은 와일드카드를 사용할 수 없다.
반드시 정확한 Originhttp://foo.example와 같은 값을 헤더에 전달해야 정상적으로 컨텐츠가 브라우저에 표시된다.


지금까지 CORS에 대해서 알아보았다.
이번 장에서는 CORS의 이론과 처리 방식에 대해서 알아보았고 다음 장에서는 실제로 우리가 접할 수 있는 환경에서 어떻게 처리해야 하는지에 대해서 알아본다.


참고한 문서

'Infrastructure > Network' 카테고리의 다른 글

[Network] CORS 해결 예제  (0) 2022.06.21
[HTTP] 헤더 - 4 (쿠키)  (0) 2021.06.28
[HTTP] 헤더 - 3 (일반 정보)  (0) 2021.06.28
[HTTP] 헤더 - 2 (전송 방식)  (0) 2021.06.28
[HTTP] 헤더 - 6 (조건부 요청)  (0) 2021.06.28