CORS 정복

정민·2023년 4월 17일
1

여러 프로젝트를 진행하면서 정말 수많은 CORS 오류를 경험해보았다.
그럴때마다 시간이 급하다보니 간단한 해결방법만을 서치해서 적용한 뒤 바로 넘어갈 때가 많았는데, 이번에 CORS에 대해 제대로 알아보고 싶어 이렇게 정리를 해보고자 한다.

이를 제대로 이해하기 위해서는 출처의 개념과 동일 출처 정책에 대해 인지하고 있어야한다.

📙출처?

URL의 스킴(프로토콜), 호스트(도메인), 포트로 정의되며, 보통 두 객체의 스킴, 호스트, 포트가 모두 일치하는 경우 같은 출처를 가졌다고 말한다.

예를 들어보자!


http://example.com/app1/index.htmlhttp://example.com/app2/index.html
스킴(http)과 호스트(example.com) 일치하기 때문에 동일한 출처(origin)이다.

http://Example.com:80http://example.com
HTTP의 기본 포트는 80이므로 동일한 출처(origin)이다.

http://example.com/app1https://example.com/app2
다른 스킴(http, https)을 가지고 있기 때문에 다른 출처(origin)이다.

http://example.comhttp://www.example.comhttp://myapp.example.com
다른 호스트를 가지고 있기 때문에 다른 출처(origin)이다.

http://example.comhttp://example.com:8080
다른 포트(80, 8080)를 가지고 있기 때문에 다른 출처(origin)이다.


📍동일 출처 정책(same-origin policy)

어떤 출처에서 불러온 문서나 스크립트가 다른 출처에서 가져온 리소스와 상호작용하는 것을 제한하는 중요한 보안 방식이다.

동일 출처 정책은, 서로 다른 출처 사이에서의 상호작용을 통제한다. 여기서 발생할 수 있는 상호작용은 크게 세가지로 나뉜다.

1. Cross-origin writes (교차 출처 쓰기)

일반적으로 허용하며, Cross-origin 으로의 링크, 리다이렉트, form submit이 있다.

2. Cross-origin embedding (교차 출처 삽입)

일반적으로 허용하며, 아래의 예시들이 있다.

  • <script src="..."></script>로 추가하는 JavaScript.
  • <link rel="stylesheet" href="...">로 적용하는 CSS.
  • <img>로 표시하는 이미지.
  • <video><audio>로 재생하는 미디어.
  • <object><embed>로 삽입하는 외부 리소스.
  • @font-face로 적용하는 글씨체.
  • <iframe>으로 삽입하는 모든 것.

3. Cross-origin reads (교차 출처 읽기)

일반적으로 불허한다. 그렇지만 종종 교차 출처 삽입 과정에서 읽기 권한이 누출된다.

나는 이 부분이 조금 이해하기 어려웠는데,

1. 단순 페이지 이동을 왜 "교차 출처 쓰기"라고 말하는 거지?
2. "교차 출처 읽기"가 왜 안전하지 않은거지? 일반적으로 쓰는게 더 위험한거 아닐까?

라는 생각 때문이었다.

1번은 결국 Cross-origin의 페이지를 받아와서 브라우저에 쓰는거니까, "쓰기"라고 표현하지 않았을까? 라고 어림짐작해보고 (만약 틀렸다면 알려주세요...)

2번은 해당 블로그에 정말 잘 설명되어 있다... Same-origin policy, CORS

요약하자면 교차 출처 읽기는 스크립트 내에서, Cross-origin의 사이트가, 페이지 이동 없이, 다른 사이트의 정보를 읽어오는 것을 금지한다는 이야기.

이러한 시나리오를 예상하면 더 이해하기 쉽다.

  1. 유저 A가 은행 업무를 보기위해 은행 웹 페이지에 로그인을 했다.
  2. 이후 로그아웃을 하지 않은 상태로, 유저 A의 은행 정보를 노리는 악성코드가 있는 페이지에 접속하였다.
    2-1. 악성코드를 가진 페이지가 은행 페이지로의 form submit을 통해 교차 출처 쓰기를 시도한다.
    -> 어차피 은행 페이지로 리다이렉트되기 때문에 위험한 상황이 발생하지 않음 (=악성 페이지가 가져가는 정보 없음)
    2-2. 악성코드를 가진 페이지가 교차 출처 읽기를 시도한다.
    -> 원래는 은행 사이트에만 전달되어야 하는 쿠키나 세션이 악성코드를 가진 페이지에게 전달된다. 이를 통해 유저 A 의 은행 정보를 가져올 수 있다.

이러한 보안상의 이유로, 브라우저에서는 스크립트에서 시작한 교차출처 HTTP 요청(=교차 출처 읽기 요청)을 제한한다.

즉, http://example1.com 라는 origin에서, 스크립트http://example2.com/api 로 요청을 보낼때, 아예 http://example2.com/api 에 닿기도 전에, 브라우저 측에서 제한한다는 의미다.

즉, 다른 출처로 요청을 보내게 되면 동일 출처 정책 때문에 브라우저에서 제한을 걸게 되고, 이를 해결하기 위해 CORS를 이용해 권한을 부여하는 것이다.

그리고 XMLHttpRequestFetch API는 이러한 동일 출처 정책(same-origin policy)를 따른다. (그러면 axios는 CORS를 신경쓰지 않아도 되는걸까...? 궁금하다.)

🔗CORS란?

이제 CORS가 무엇인지 대충 감이 올수 있을거라 생각한다.

Cross-Origin Resource Sharing (CORS). 직역하면 교차 출처 리소스 공유.
출처(origin) 에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제

그렇다면 어떠한 방식으로 다른 출처에 접근할 수 있게 만들어 주는걸까?

✉️프리플라이트(preflight)

직역하자면 사전 요청.
본격적인 교차 출처 HTTP 요청 전에 서버 측에서 그 요청의 메서드와 헤더에 대해 인식하고 있는지를 체크하는 것이다.

만약 https://foo.example (클라이언트) 가 https://bar.other/doc (서버) 에 요청을 날린다고 가정해보자!

이렇게 클라이언트가 OPTIONS 메서드를 통해 서버로 HTTP 요청을 보내 실제 요청을 전송하기에 안전한지 확인한다.

// https://foo.example 쪽 (클라이언트)
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

// https://bar.other/doc 쪽 (서버)
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

클라이언트 측의
Access-Control-Request-Method는, '이따가 이 메소드 요청할거야' 라는 의미고
Access-Control-Request-Headers는, '이따가 이 헤더도 같이 전송할거야' 라는 의미다.

이러한 사전 요청은 일반적인 상황에서는 브라우저에서 자동으로 발생된다. 그러므로 프론트엔드 개발자가 이 요청을 직접 작성할 필요는 없다.

단, 서버는 위의 요청을 수락하기 위해서 Access-Control-Allow-Origin: https://foo.example 를 통해 클라이언트의 origin을 미리 등록해두어야 한다.


이제 이러한 에러가 이해가 된다!
XMLHttpRequest 요청을 하기 위해 클라이언트 측에서 프리플라이트를 날렸지만, 서버에서 Access-Control-Origin 에 해당 클라이언트의 origin을 등록해두지 않았기 때문에, 요청이 거절된 것이다.

📃단순 요청 (Simple Request)

일부 요청은 이러한 preflight를 요청하지 않는다.

이는(=단순 요청) 웹 컨텐츠가 이미 발행할 수 있는 것과 동일한 종류의 cross-site 요청입니다. 서버가 적절한 헤더를 전송하지 않으면 요청자에게 응답 데이터가 공개되지 않습니다. 따라서 cross-site 요청 위조를 방지하는 사이트는 HTTP 접근 제어를 두려워 할 만한 부분이 없습니다

즉, 서버가 무언가를 건들이지 않는 이상 요청자에게 정보가 갈 일이 없기 때문에, 보안상의 위험성이 없다고 생각하여 preflight 없이 요청할 수 있게 한 것이다.

이러한 요청은 아래와 같다.

  • 다음 중 하나의 메서드
    • GET
    • HEAD
    • POST
  • 유저 에이전트가 자동으로 설정 한 헤더
    • Accept
    • Accept-Language
    • Content-Language
    • Content-Type (아래의 추가 요구 사항에 유의)
  • Content-Type 헤더는 다음의 값들만 허용.
    • application/x-www-form-urlencoded
    • multipart/form-data
    • text/plain
  • 요청에 사용된 XMLHttpRequestUpload (en-US) 객체에는 이벤트 리스너가 등록되어 있지 않는다.
  • 요청에 ReadableStream 객체가 사용되지 않는다.

🔐인증 정보를 포함한 요청(Requests with credentials)

해당 요청은 HTTP cookiesHTTP Authentication 정보를 인식한다.
그러나 기본적으로 cross-site XMLHttpRequest 나 Fetch 호출에서 브라우저는 자격 증명을 보내지 않는다.
그렇기 때문에 XMLHttpRequest 객체나 Request 생성자가 호출될 때 특정 플래그를 설정해야 한다.

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

특정 플래그는 프론트엔드 쪽에서 이러한 방식으로 설정할 수 있다.

주요하게 봐야할 것은 withCredentials = true

그렇지만 이런다고 끝이 아니다.

서버측에서 Access-Control-Allow-Credentials 헤더를 추가해줌으로써 credentials 플래그가 true일 때 요청에 대한 응답을 표시할 수 있는지를 설정해주어야한다!

// 클라이언트
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

// 서버
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

클라이언트는 Cookie 를 통해 쿠키를 담아 보냈고
서버는 쿠키를 받아오기 위해 Access-Control-Allow-Credentialstrue로 주었다.

브라우저는 Access-Control-Allow-Credentials: true 헤더가 없는 응답을 거부하기 때문에, 만약 서버에서 위의 속성을 설정하지 않는다면 서버의 응답을 받아올 수 없다!!

다음은 내가 겪은 다양한 CORS 에러 사례와, 해결 방법을 써보고 싶다...

참고

https://developer.mozilla.org/ko/docs/Web/HTTP/CORS
https://developer.mozilla.org/ko/docs/Web/HTTP/Methods/OPTIONS
https://developer.mozilla.org/ko/docs/Glossary/Preflight_request
https://fetch.spec.whatwg.org/#http-cors-protocol
https://developer.mozilla.org/ko/docs/Web/Security/Same-origin_policy
https://developer.mozilla.org/ko/docs/Glossary/Origin
https://en.wikipedia.org/wiki/Same-origin_policy

profile
괴발개발~

0개의 댓글