CORS가 무엇일까
우선 CORS가 왜 필요한지를 알아야 한다
웹 공간에서는 다른 출처로의 리소스 요청에 대한 제한과 관련된 두가지 정책 CORS,SOP(Same-Origin Policy)이 존재하는데 서로 다른 출처를 가진 리소스를 안전하게 사용하기 위함이다.
우선 SOP를 알아보자.
SOP(Same Origin Policy)
SOP(동일 출처 정책)는 다른 출처의 리소스를 사용하는 것을 제한하는 보안 방식이다.
여기서 말하는 출처는 URL의 Protocol, Host, Port로 구분한다
http://github.com:80
위 주소를 예시로 들면 http:프로토콜 / github.com : 호스트 / 80 :포트로 본다
sop는 동일한 프로토콜,호스트,포트의 리소스만 허용한다.예시를 하나 들자면
1. 클라이언트가 네이버에 로그인(토큰 발급)을 한다
2. 해커가 클라이언트에게 `http://hack.ck`라는 URL을 보낸다
3. 클라이언트가 해커가 보낸 링크로 들어간다
4. 해커가 보낸 페이지에는 클라이언트의 토큰을 통해 개인정보를 탈취하는 스크립트가 포함되어 있다.
예시에서 본것처럼, 네이버의 서버가 토큰만을 통해 사용자를 판단한다면, 이 토큰이 클라이언트인지 해커인지 구분하기 힘들 것이다. 하지만 요청 URL을 확인하면 클라이언트의 출처는 http://www.naver.com /~이고 고 해커는 http://hack.ck이므로 구분할 수 있다. 이처럼 요청의 출처가 다르다면 Cross Origin, 즉 SOP에 위반이라고 한다.
하지만 외부 라이브러리도 사용해야 하는데 요청과 리소스를 매번 같은 출처로만 받을 수는 없는데, 이때 필요한 것이 CORS이다.
CORS(Cross Origin Resource Sharing)
CORS(교차 출처 리소스 공유)는 추가 HTTP헤더를 통해,한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 선택한 자원에 접근할 수 있는 권한을 브라우저에 알려주는 체제이다.
같은 출처와 다른 출처를 구분하는 방법은 두 URL의 구성 요소 중 Scheme, Host, Port만 동일하면 된다
=> 중요한 건 출처를 비교하는 로직이 서버가 아닌 브라우저에 구현된 스펙이라는 점이다
CORS는 브라우저의 구현 스펙에 포함되는 정책이기에,브라우저를 거치지 않고 서버 간 통신에는 CORS가 적용되지 않는다. 또한 CORS 정책을 위반하는 리소스 요청 때문에 에러가 발생했다해도 서버 쪽 로그에는 정상적으로 응답했다는 로그만 남아,CORS가 돌아가는 방식을 모르면 에러를 따라가서 해결함에 있어 난항을 겪을 수 있다.
CORS동작방식
그럼 본격적으로 어떤 방법을 통해 서로 다른 출처를 가진 리소스를 안전하게 사용할 수 있는지 알아보도록 하자.
기본적으로 웹 클라이언트 어플리케이션이 다른 출처의 리소스를 요청할 때는 HTTP 프로토콜을 사용하여 요청을 보내게 되는데, 이때 브라우저는 요청 헤더에 Origin이라는 필드에 요청을 보내는 출처를 함께 담아보낸다.
Origin : https://evan-moon.github.io
이후 서버가 이 요청에 대한 응답을 할 때 응답 헤더의 Access-Control-Allow-Origin이라는 값에 “이 리소스를 접근하는 것이 허용된 출처”를 내려주고, 이후 응답을 받은 브라우저는 자신이 보냈던 요청의 Origin과 서버가 보내준 응답의 Access-Control-Allow-Origin을 비교해본 후 이 응답이 유효한 응답인지 아닌지를 결정한다.
기본적인 흐름은 이렇게 간단하지만, 사실 CORS가 동작하는 방식은 한 가지가 아니라 세 가지의 시나리오에 따라 변경되기 때문에 여러분의 요청이 어떤 시나리오에 해당되는지 잘 파악한다면 CORS 정책 위반으로 인한 에러를 고치는 것이 한결 쉬울 것이다.
Simple Request
이 시나리오에 대한 정식 명칭은 없지만 MDN의 CORS 문서에서는 이 시나리오를 Simple Request라고 부르고 있으니, 필자도 그냥 단순 요청(Simple Request)이라고 부르도록 하겠다.
단순 요청은 예비 요청을 보내지 않고 바로 서버에게 본 요청부터 때려박은 후, 서버가 이에 대한 응답의 헤더에 Access-Control-Allow-Origin과 같은 값을 보내주면 그때 브라우저가 CORS 정책 위반 여부를 검사하는 방식이다. 즉, 프리플라이트와 단순 요청 시나리오는 전반적인 로직 자체는 같되, 예비 요청의 존재 유무만 다르다.
하지만 아무 때나 단순 요청을 사용할 수 있는 것은 아니고, 특정 조건을 만족하는 경우에만 예비 요청을 생략할 수 있다. 게다가 이 조건이 조금 까다롭기 때문에 일반적인 방법으로 웹 어플리케이션 아키텍처를 설계하게 되면 거의 충족시키기 어려운 조건들이라 이런 경우를 거의 경험하지는 못한다고 한다.
- 요청의 메소드는 GET, HEAD, POST 중 하나여야 한다.
- Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width를 제외한 헤더를 사용하면 안된다.
- 만약 Content-Type를 사용하는 경우에는 application/x-www-form-urlencoded, multipart/form-data, text/plain만 허용된다.
사실 1번 조건의 경우는 그냥 PUT이나 DELETE 같은 메소드를 사용하지 않으면 되는 것 뿐이니 그렇게 보기 드문 상황은 아니지만, 2번이나 3번 조건 같은 경우는 조금 까다롭다.
애초에 저 조건에 명시된 헤더들은 진짜 기본적인 헤더들이기 때문에, 복잡한 상용 웹 어플리케이션에서 이 헤더들 외에 추가적인 헤더를 사용하지 않는 경우는 드물다. 당장 사용자 인증에 사용되는 Authorization 헤더 조차 저 조건에는 포함되지 않는다.
게다가 대부분의 HTTP API는 text/xml이나 application/json 컨텐츠 타입을 가지도록 설계되기 때문에 사실 상 이 조건들을 모두 만족시키는 상황을 만들기는 그렇게 쉽지 않은 것이 현실이다.
Preflight Request
프리플라이트(Preflight) 방식은 일반적으로 우리가 웹 어플리케이션을 개발할 때 가장 마주치는 시나리오이다. 이 시나리오에 해당하는 상황일 때 브라우저는 요청을 한번에 보내지 않고 예비 요청과 본 요청으로 나누어서 서버로 전송한다.
이때 브라우저가 본 요청을 보내기 전에 보내는 예비 요청을 Preflight라고 부르는 것이며, 이 예비 요청에는 HTTP 메소드 중 OPTIONS 메소드가 사용된다. 예비 요청의 역할은 본 요청을 보내기 전에 브라우저 스스로 이 요청을 보내는 것이 안전한지 확인하는 것이다.
이 과정을 간단한 플로우 차트로 나타내보면 대략 이런 느낌이다.
우리가 자바스크립트의 fetch API를 사용하여 브라우저에게 리소스를 받아오라는 명령을 내리면 브라우저는 서버에게 예비 요청을 먼저 보내고, 서버는 이 예비 요청에 대한 응답으로 현재 자신이 어떤 것들을 허용하고, 어떤 것들을 금지하고 있는지에 대한 정보를 응답 헤더에 담아서 브라우저에게 다시 보내주게 된다.
이후 브라우저는 자신이 보낸 예비 요청과 서버가 응답에 담아준 허용 정책을 비교한 후, 이 요청을 보내는 것이 안전하다고 판단되면 같은 엔드포인트로 다시 본 요청을 보내게 된다. 이후 서버가 이 본 요청에 대한 응답을 하면 브라우저는 최종적으로 이 응답 데이터를 자바스크립트에게 넘겨준다.
이 플로우는 브라우저의 개발자 도구 콘솔에서도 간단하게 재현해볼 수 있는데, 필자의 블로그 환경에서 필자의 티스토리 블로그의 RSS 파일 리소스에 요청을 보내면 브라우저가 본 요청을 보내기 전에OPTIONS메소드를 사용하여 예비 요청을 보내는 것을 확인할 수 있다.
const headers = new Headers({
'Content-Type': 'text/xml',
});
fetch('https://evanmoon.tistory.com/rss', { headers });
OPTIONS https://evanmoon.tistory.com/rss
Accept: */*
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9,ko;q=0.8,ja;q=0.7,la;q=0.6
Access-Control-Request-Headers: content-type
Access-Control-Request-Method: GET
Connection: keep-alive
Host: evanmoon.tistory.com
Origin: https://evan-moon.github.io
Referer: https://evan-moon.github.io/2020/05/21/about-cors/
Sec-Fetch-Dest: empty
Sec-Fetch-Mode: cors
Sec-Fetch-Site: cross-site
실제로 브라우저가 보낸 요청을 보면, 단순히 Origin에 대한 정보 뿐만 아니라 자신이 예비 요청 이후에 보낼 본 요청에 대한 다른 정보들도 함께 포함되어 있는 것을 볼 수 있다.
이 예비 요청에서 브라우저는 Access-Control-Request-Headers를 사용하여 자신이 본 요청에서 Content-Type 헤더를 사용할 것을 알려주거나, Access-Control-Request-Method를 사용하여 이후 GET 메소드를 사용할 것을 서버에게 미리 알려주고 있는 것이다.
이렇게 티스토리 서버에 예비 요청을 보내면, 이제 티스토리 서버가 이 예비 요청에 대한 응답을 보내준다.
OPTIONS https://evanmoon.tistory.com/rss 200 OK
Access-Control-Allow-Origin: https://evanmoon.tistory.com
Content-Encoding: gzip
Content-Length: 699
Content-Type: text/xml; charset=utf-8
Date: Sun, 24 May 2020 11:52:33 GMT
P3P: CP='ALL DSP COR MON LAW OUR LEG DEL'
Server: Apache
Vary: Accept-Encoding
X-UA-Compatible: IE=Edg
여기서 우리가 눈여겨 봐야할 것은 서버가 보내준 응답 헤더에 포함된Access-Control-Allow-Origin: https://evanmoon.tistory.com라는 값이다.
티스토리 서버는 이 리소스에 접근이 가능한 출처는 오직https://evanmoon.tistory.com뿐이라고 브라우저에게 이야기해준 것이고, 필자가 이 요청을 보낸 출처는https://evan-moon.github.io이므로 서버가 허용해준 출처와는 다른 출처이다.
결국 브라우저는 이 요청이 CORS 정책을 위반했다고 판단하고 다음과 같은 에러를 뱉게 되는 것이다.
❗에러메시지
❗Access to fetch at ‘https://evanmoon.tistory.com/rss’ from origin ‘https://evan-moon.github.io’ has been blocked by CORS policy: Response to preflight request doesn’t pass access control check: The ‘Access-Control-Allow-Origin’ header has a value ‘http://evanmoon.tistory.com’ that is not equal to the supplied origin. Have the server send the header with a valid value, or, if an opaque response serves your needs, set the request’s mode to ‘no-cors’ to fetch the resource with CORS disabled.
이때 예비 요청에 대한 응답에서 에러가 발생하지 않고 정상적으로 200이 떨어졌는데, 콘솔 창에는 빨갛게 에러가 표시되기 때문에 많은 분들이 헷갈려하시는데, CORS 정책 위반으로 인한 에러는 예비 요청의 성공 여부와 별 상관이 없다. 브라우저가 CORS 정책 위반 여부를 판단하는 시점은 예비 요청에 대한 응답을 받은 이후이기 때문이다.
물론 예비 요청 자체가 실패해도 똑같이 CORS 정책 위반으로 처리될 수도 있지만, 중요한 것은 예비 요청의 성공/실패 여부가 아니라 “응답 헤더에 유효한 Access-Control-Allow-Origin 값이 존재하는가”이다. 만약 예비 요청이 실패해서 200이 아닌 상태 코드가 내려오더라도 헤더에 저 값이 제대로 들어가있다면 CORS 정책 위반이 아니라는 의미이다.
대부분의 경우 이렇게 예비 요청, 본 요청을 나누어 보내는 프리플라이트 방식을 사용하기는 하지만, 모든 상황에서 이렇게 두 번씩 요청을 보내는 것은 아니다. 조금 까다로운 조건이기는 하지만 어떤 경우에는 예비 요청없이 본 요청만으로 CORS 정책 위반 여부를 검사하기도 한다.
Credentialed Request
3번째 시나리오는 인증된 요청을 사용하는 방법이다. 이 시나리오는 CORS의 기본적인 방식이라기 보다는 다른 출처 간 통신에서 좀 더 보안을 강화하고 싶을 때 사용하는 방법이다.
기본적으로 브라우저가 제공하는 비동기 리소스 요청 API인 XMLHttpRequest 객체나 fetch API는 별도의 옵션 없이 브라우저의 쿠키 정보나 인증과 관련된 헤더를 함부로 요청에 담지 않는다. 이때 요청에 인증과 관련된 정보를 담을 수 있게 해주는 옵션이 바로 credentials 옵션이다.
이 옵션에는 총 3가지의 값을 사용할 수 있으며, 각 값들이 가지는 의미는 다음과 같다.
옵션 값 | 설명 |
same-origin (기본값) | 같은 출처 간 요청에만 인증 정보를 담을 수 있다 |
include | 모든 요청에 인증 정보를 담을 수 있다 |
omit | 모든 요청에 인증 정보를 담지 않는다 |
만약 여러분이 same-origin이나 include와 같은 옵션을 사용하여 리소스 요청에 인증 정보가 포함된다면, 이제 브라우저는 다른 출처의 리소스를 요청할 때 단순히 Access-Control-Allow-Origin만 확인하는 것이 아니라 좀 더 빡빡한 검사 조건을 추가하게 된다.
백문이불여일견이니 필자가 지금 이 포스팅을 작성하고 있는 로컬 환경과 필자의 블로그를 호스팅하고 있는 Github 서버와의 통신을 통해, 어떤 제약이 추가되었는지 직접 살펴보는 것이 좋을 것 같다.
필자의 블로그는 Access-Control-Allow-Origin 값으로 모든 출처를 허용한다는 의미인 *가 설정되어있기 때문에, 다른 출처에서 필자의 블로그로 리소스를 요청할 때 CORS 정책 위반으로 인한 제약을 받지 않는다.
그래서 http://localhost:8000과 같은 로컬의 개발 환경에서도 fetch API를 사용하여 마음대로 리소스를 요청하고, 또 받아올 수 있다.
fetch('https://evan-moon.github.io/feed.xml');
Request
GET https://evan-moon.github.io/feed.xml
Origin: http://localhost:8000
Referer: http://localhost:8000/2020/05/21/about-cors/
Response
GET https://evan-moon.github.io/feed.xml 200 OK
Access-Control-Allow-Origin: *
Content-Encoding: gzip
Content-Length: 1132748
Content-Type: application/xml
Server: GitHub.com
Status: 200
또한 구글 크롬 브라우저의 credentials 기본 값은 같은 출처 내에서만 인증 정보를 사용하겠다는 same-origin이기 때문에, 필자의 로컬 환경에서 https://evan-moon.github.io로 보내는 리소스 요청에는 당연히 브라우저의 쿠키와 같은 인증 정보가 포함되어 있지 않다.
그렇기 때문에 브라우저는 단순히 Access-Control-Allow-Origin: *이라는 값만 보고 “이 요청은 안전하구만”이라는 결론을 내리는 것이다. 그러나 필자가 credentials 옵션을 모든 요청에 인증 정보를 포함하겠다는 의미를 가진 include로 변경하고 같은 요청을 보내면 이번에는 상황이 조금 달라진다.
fetch('https://evan-moon.github.io/feed.xml', {
credentials: 'include', // Credentials 옵션 변경!
});
직접 브라우저 콘솔에서 실행해보면 알겠지만, 이번에는 credentials: include 옵션을 사용하여 동일 출처 여부와 상관없이 무조건 요청에 인증 정보가 포함되도록 설정했으므로, 이번 요청에는 브라우저의 쿠키 정보가 함께 담겨있는 것을 확인해볼 수 있다.
필자의 블로그를 호스팅하고 있는 Github 서버는 이번에도 동일한 응답을 보내주었지만, 브라우저의 반응은 다르다
❗에러메시지
❗ Access to fetch at ’https://evan-moon.github.io/feed.xml’ from origin ’http://localhost:8000’ has been blocked by CORS policy: 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’.
브라우저는 인증 모드가 include일 경우, 모든 요청을 허용한다는 의미의 *를 Access-Control-Allow-Origin헤더에 사용하면 안된다고 이야기하고 있다.
이처럼 요청에 인증 정보가 담겨있는 상태에서 다른 출처의 리소스를 요청하게 되면 브라우저는 CORS 정책 위반 여부를 검사하는 룰에 다음 두 가지를 추가하게 된다.
- Access-Control-Allow-Origin에는 *를 사용할 수 없으며, 명시적인 URL이어야한다.
- 응답 헤더에는 반드시 Access-Control-Allow-Credentials: true가 존재해야한다.
인증까지 얽혀있는 이 시나리오는 다른 시나리오에 비해서 다소 복잡하게 느껴질 수는 있지만, 이렇게 CORS 정책에 대한 다양한 시나리오를 알아두면 실제 상황에서 CORS 정책 위반으로 인한 문제가 발생했을 경우 삽질해야하는 시간을 크게 단축시킬 수 있으니 숙지해놓는 것을 추천한다. (하라는 거 다 했는데 왜 안돼? 같은 상황을 조금은 예방할 수 있다)
간단요약
SOP(동일 출처 정책) : 같은 URL끼리만 API등의 데이터 접근이 가능하도록 막는것
CORS : 다른 출처간에 리소스를 공유하게 해주는 것/다른 출처끼리 정보요청과 반환이 가능하도록 하는것
동작 방식에는 단순 요청(Simple Request) / 사전 요청(Preflight Request) / 인증요청 (Credentialed Request)이 있다
참고문헌 :
https://evan-moon.github.io/2020/05/21/about-cors/
'책벌레와 벌레 그 사이 어딘가 > 개념쌓기' 카테고리의 다른 글
[개념쌓기] CORS-크로스 도메인_프로토콜 문제 (0) | 2022.08.19 |
---|---|
[개념쌓기]CASCADE?OrphanRemoval? (0) | 2022.08.18 |
[개념쌓기]JSON의 직렬화/역직렬화 (0) | 2022.08.13 |
[개념쌓기] @Transactional (0) | 2022.08.10 |
[개념쌓기] 연관관계 매핑 (0) | 2022.08.10 |
댓글