Learn business/Network

CORS 정책


어드민의 GNB(Global Navigation Bar) 영역을 개발하면서 script 태그 내에서 Cross-Origin 으로 요청을 보내야하는 상황이 있었는데, 구글링을 통해서 해결책을 찾았지만 원하는 대로 되지 않아서 고생한 적이 있다. 앞으로 같은 상황일 때 삽질하는 시간을 줄일 수 있도록 포스팅을 한다.

 

CORS은 Cross-Origin Resource Sharing 의 약자로 W3C에서 내놓은 정책이다. 의미를 해석을 해보면 Cross-Origin의 Resrouce를 공유하는 정책이라고 볼 수 있을 것 같다. 모질라에 있는 CORS의 정의를 빌려오자면 CORS는 특정 헤더를 통해서 브라우저에게 한 출처(origin) 에서 실행되고 있는 웹 애플리케이션이 다른 출처(cross-origin)에 원하는 리소스에 접근할 수 있는 권한이 있는지 없는지를 알려주는 매커니즘이다. 여기서 origin이란 특정 페이지에 접근할 때 사용되는 URL의 Scheme(프로토콜), host(도메인), 포트를 말한다. 그래서 same-origin이란 scheme(프로토콜), host(도메인), 포트가 같다는 말이며, 이 3가지 중 하나라도 다르면 cross-origin이다.

 

HTTP 요청에 대해서 HTML은 기본적으로 Cross-Origin 요청이 가능하다. 왜냐하면 HTML은 Cross-Origin 정책을 따르기 때문이다. 예컨데 HTML에서 link 태그에서 다른 origin의 css 등의 리소스에 접근하는 것이 가능하며, img 태그에서 다른 jpg, png 등의 리소스에 접근하는 것이 가능하다. 그러나 script 태그 내에 있는 HTTP 요청(XmlHttpRequest, Fetch Api)에 대해서는 기본적으로 Same-Origin 정책을 따르고 있기 때문에 Cross-Origin 요청이 불가능하다. 그 이유는 보안상의 이슈라고 하는데, 아무리 검색해봐도 안나와서 일단 넘어가도록 하자.

 

script 내부에서 cross-origin 정책을 허용하지 않는 것이 초기에는 보안을 위해서 좋은 방법이라 생각되었지만 요즘은 대규모 웹 서비스가 늘어나며, 외부 호출에 대한 니즈가 점차적으로 많아지게 되었다. 그래서 많은 개발자들이 JSONP와 같은 우회적인 방법으로 cross-origin 정책을 회피하였다. 이에 W3C는 조금 더 안전하게 브라우저와 서버간에 교차 통신을 할 수 있도록 CORS라는 정책을 내놓았다.

 

CORS를 사용하는 요청은 어떤 것이 있을까?

아직 아래 요청에 대해서 1번, 2번을 제외하고 실제로 격은 적은 없지만 mozilla에서는 아래 요청에 대해서 기본적으로 CORS를 사용하고 있다고 말한다.

1. XmlHttpRequest와 Fetch Apis 호출하는 경우

2. CSS 내에서 @font-face 속성에서 Cross-Origin의 폰트 리소스를 호출하는 경우

3. WebGL에서 texture를 사용하는 경우

4. CanvasRenderingContext2D의 drawImage() 메소드를 사용하여 Image/video 프레임을 그리는 경우

5. CSS Shapes from images

 

Cross-Origin을 회피하는 방법은?

1. 웹 브라우저 실행 시 외부 요청을 허용하는 옵션을 사용해서 회피하는 방법

크롬이나 사파리 같은 웹 브라우저를 실해할 때 Command-Line 옵션을 통해서 Cross-Origin 서버로부터 받아온 리소스에 접근이 가능하도록 설정할 수 있다. 예컨데 크롬의 경우는 --disable-web-security 라는 명령어를 사용해서 실행하면 Cross-Origin 서버로부터 받아온 리소스에 접근이 가능하게 된다. 번외로 --disable-web-security 라는 명령어는 보안을 완전 무시하라는 명령어 같다. 사실 많은 사람들이 CORS를 보안적 매커니즘에 의해서 탄생했다고 생각하는데, 사실은 CORS는 보안에 반하는 정책이다.

 

2. 웹 브라우저에 외부 요청을 허용하도록 하는 플러그인을 설치하는 방법

서버로부터 응답받은 헤더에 "Access-Controller-Allow-Origin: *" 만 추가해주면 웹 브라우저는 Cross-Origin 서버로부터 받아온 리소스에 접근이 가능한 것이라고 판단한다. 이러한 작업을 플러그인을 통해서 서버 응답 헤더에 강제적으로 넣어줄 수 있는데, 대표적으로 구글 웹스토어에 Allow-Controll-Allow-Origin 플러그인이 있다. (일종의 속임수)

 

3. JSONP로 요청하는 방법

js나 css 같은 리소스들은 Cross-Origin 정책을 따르기 때문에 외부 요청이 가능하다. 이 점을 이용해서 Cross-Origin 정책을 우회한 방식이 있는데 바로 Jsonp 방식이다. 해당 포스팅에서는 범위에 벗어나는 내용이므로 생략하겠다. Jsonp 설명 잘해 놓은 블로그 링크

 

4. CORS로 요청하는 방법

아래에서 설명할 W3C에서 내놓은 표준 정책 방식이다.

 

CORS 동작 방식은?

CORS의 기본적인 동작은 서버가 한 origin으로부터 요청을 받게되면 응답할 때 HTTP 헤더에 특정 헤더를 추가함으로써 브라우저는 이 origin이 특정 리소스를 읽을 수 있는 권한이 있는지 없는지를 알게 된다. 추가적으로 HTTP 메소드들 중 GET 이 외의 메소드나, POST 메소드에서 특정 MIME Type은 서버 데이터에 사이드 이펙트를 발생시킬 수 있기 때문에 기본적으로 브라우저는 Preflight Request 방식으로 요청할 수 있도록 규제한다. 아래에서 CORS가 어떻게 동작하는지 세 가지의 시나리오로 설명하고자 한다. 

 

CORS 동작 방식: Simple Requests

위에서 CORS는 Preflight 방식을 규제한다고 하였는데 몇몇 요청들은 Prefight Request 방식으로 요청하지 않는다. Prefight Request 이 외의 방식으로 요청하는 것을 Simple Request 방식이라고 하는데, 브라우저는 요청을 분석하여 아래 와 같은 조건을 충족할 때 Simple Request 방식으로 요청한다.

 

1. HTTP Method가 GET, POST, HEAD 이셋 중에 하나로 요청한 경우에 Simple Request 방식으로 요청한다.

2. Fetch 표준 정책에서 정의한 Forbidden Header Name 이라는 헤더 목록(클라에서 자동으로 넣음)과 CORS-safelisted request header 라는 헤더 목록(클라에서 수동으로 넣을 수 있음) 이외에 다른 커스텀 헤더, 권한과 관련된 헤더가 없는 경우 Simple Request 방식으로 요청한다.

3. HTTP Method가 POST인 경우 Content-Type 헤더를 수동으로 지정할 수 있는데 application/x-www-form-urlencoded, multipart/form-data, text/plain 이 세 가지 값에 포함되는 경우 Simple Request 방식으로 요청한다. (application/json은 포함되지 않는 것을 주의하자. 이것 때문에 엄청 삽질했다.)

 

아래와 같이 클라이언트에서 외부 서버로 요청을 보냈다면 어떠한 일이 발생하는지 Step by Step으로 알아보자.

const xhr = new XMLHttpRequest();
const url = 'https://bar.other/resources/public-data/';
   
xhr.open('GET', url);
xhr.onreadystatechange = someHandler;
xhr.send(); 

이 경우 클라이언트가 서버에게 전송하는 내용을 살펴보고, 서버가 클라이언트에게 어떠한 내용을 전달하는지 살펴보도록 하자.

 1. 브라우저는 위 XMLHttpRequest가 Cross-Origin 요청인 것을 판단하여 아래와 같이 요청에 "Origin: https://foo.example" 헤더를 추가하여 외부 서버로 보낸다.

# 요청 헤더
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

 2. Cross-Origin으로부터 온 요청인 것을 안 서버는 Origin 헤더를 확인하며, 그 값이 허용이 되었는지 안되었는지를 확인한다. 그리고 "Access-Control-Allow-Origin: [서버에서 설정한 값]" 을 응답 헤더에 추가하여 클라이언트로 보낸다. 여기서 "Access-Control-Allow-Origin: *" 이란 것은 어떠한 Origin 이든 허용한다는 뜻이며, 특정 Origin만 허용하고 싶다면, 서버에서 응답헤더에 "Access-Control-Allow-Origin: https://foo.example" 로 값을 설정해 주면 된다. 

# 응답 헤더
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml

[...Payload...]

 3. 브라우저가 응답을 받으면, "Access-Control-Allow-Origin"헤더의 값을 찾아 해당 Origin의 값이 허용이 됐는지를 판단하고, 만약 허용이 되었다면 리소스에 접근할 수 있도록 하며, 그렇지 않다면 리소스에 접근할 수 없는 에러를 던진다. 

 

CORS 동작 방식: Preflighted Requests

Prefight Request 가장 먼저 HTTP Request 메소드 중 하나인 OPTION 메소드를 Cross-Origin으로 보내고(Preflight), 응답 받은 헤더 정보를 통해서 메인 요청(실제로 보내야하는 요청)을 보낼 수 있는지 판단하는 방식이다. Authorization와 같은 유저 정보와 관련된 헤더는 브라우저로 하여금 Preflight Request 방식으로 요청하도록 유도한다. 아래와 같이 Preflight Request 방식을 Step by Step으로 알아보도록 하자. 

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

 1. 브라우저는 위 XMLHttpRequest가 Cross-Origin 요청인 것을 판단하여 아래와 같이 요청에 "Origin: https://foo.example" 헤더를 추가한다. 또한 브라우저는 POST 방식이며, Content-Type이 application/x-www-form-urlencoded, multipart/form-data, text/plain에 포함되지 않기 때문에 Prefight Request 방식으로 보내야 한다는 것을 알고 있다. 그래서 브라우저는 요청에 아래와 같이 헤더 정보를 추가하여 외부 서버로 보낸다.

  - Origin: https://foo.example

  - Access-Control-Request-Method: POST

  - Access-Control-Requset-Headers: X-PINGOTHER, Content-Type

# Prefight 요청 헤더
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: https://foo.example
Access-Control-Request-Method: POST
Access-Control-Request-Headers: X-PINGOTHER, Content-Type

 2. 서버는 Preflight Request 방식으로 날아온 요청에 대한 API에 아래와 같은 정보를 헤더에 추가해서 응답한다.

  - 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

# Preflight 요청에 대한 응답 헤더
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

 3. 응답받은 헤더 정보를 통해서 브라우저는 실제 요청을 외부 서버로 보낼지 말지를 판단하게 된다. 위 예시에서 받은 응답은 해당 API는 Cross-Origin에 대해서 POST방식과 커스텀 헤더인 X-PINGOTHER 그리고 Content-Type을 허용한다고 하였으므로, 브라우저는 실제 요청을 외부 서버로 보낼 수 있다.

# 실제 요청 헤더
POST /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
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache

<person><name>Arun</name></person>

# 실제 응답 헤더
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain

[Some GZIP'd payload]

 추가적으로 Preflight Request 방식은 많은 리소스를 잡아 먹는다. 그렇기 때문에 서버에서 "Access-Control-Max-Age" 헤더 정보를 통해서 Preflight Request 를 캐싱함으로써 그 효율을 높힐 수 있다.

 

CORS 동작 방식: Requests with credentials

 기본적으로 CORS 정책상 XmlHttpRequest 혹은 Fetch Api를 사용하여 외부 서버에 요청을 보낼 때 쿠키 정보나 HTTP Authentication 정보는 보내지 않는 것이 원칙이다. 그러나 특정 플래그 값을 이용해서 외부 서버에 쿠키 혹은 HTTP Authenticaion 정보를 보낼 수 있다.

 

만약 foo.example 서버에서 렌더링된 javascript에 아래와 같이 코드를 작성했다고 해보자.

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(); 
  }
}

바로 7번째 라인을 통해서 간단하게 외부 서버로 쿠키 정보나 HTTP Authentication 정보를 보낼 수 있게 되었다. 위 요청을 보면 HTTP 메소드 중 GET 방식으로 외부 서버로 요청을 보내기 때문에 Prefight Request 방식을 사용하지 않지만, 만약 응답 받은 서버에서 "Access-Control-Allow-Credentials: true" 라는 헤더 값이 없다면, 브라우저는 응답 받은 데이터에 대해 무시할 뿐만 아니라 리소스에 접근할 수 없다.

# 요청 헤더
GET /resources/access-control-with-credentials/ 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

# 응답 헤더
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


[text/plain payload]

참고로, 외부 서버로 요청할 때 withCredentials를 true로 세팅하여 보냈다면, 외부 서버로 부터의 응답 헤더에 Access-Control-Allow-Origin의 값에 WildCard가 오면 안 된다. 무조건 명시적으로 Origin의 URL정보가 있어야 한다.

 

CORS에 대한 오해

 CORS는 서버가 클라이언트로 요청을 허용할 수도 있으며, 반대로 차단할 수 있다는 면에선 ACL(Access-Control-List)와 비슷한 것 같다. 그렇다면 정말 브라우저가 외부 서버의 리소스에 접근할 수 있는 방법은 외부 서버가 내려주는 화이트 리스트 뿐일까? 그렇지 않다. HTTP 요청은 브라우저 뿐만 아니라 포스트맨, CURL과 같은 다양한 툴을 사용하여 요청을 보낼 수 있다. 이러한 툴을 사용하여 언제든 Origin의 값을 요청 헤더에 끼어넣어서 외부 서버로 요청할 수 있다. 또한 프록시 서버를 두어 요청할 때 Origin의 값을 변경할 수도 있다. 이를 통해 외부 서버로부터 받아온 리소스를 언제든 접근할 수 있다. 왜 이런게 가능한 걸까? 이유는 CORS는 언제까지나 웹 브라우저에서 엄격한 보안 정책에 반하여 내놓은 정책이다. 그러므로 서버 개발자들은 CORS 허용에 주의를 기울어야할 것이다.

 


[참고자료]

https://homoefficio.github.io/2015/07/21/Cross-Origin-Resource-Sharing/

https://kingbbode.tistory.com/26

https://brunch.co.kr/@adrenalinee31/1

https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS

https://dzone.com/articles/do-you-really-know-cors