CORS란 교차 출처 리소스 공유 Cross-Origin Resource Sharing의 줄임이다. 여기서 "교차 출처"라고 하는 것은 "다른 출처"를 의미하는 것이다. 먼저 다른 추러처간의 리소스 공유에 대해 알아보기 앞서 간단하게 출처(Origin)
라는 것이 정확히 뭘 의미하는지 짚고 넘어가보자.
서버의 위치를 의미하는 https://google.com
과 같은 URL들을 마치 하나의 문장열 같아도 사실은 여러개의 구성요소로 이루어져 있다.
이때 출처는 Protocol
과 Host
, 그리고 위 그림에는 나와있지 않지만 :80, :443과 같은 포트 번호까지 모두 합친것을 의미한다. 즉, 서버의 위치를 찾아가기 위해 필요한 가장 기본적인 것들을 합쳐놓은 것이다.
또한 출처 내의 포트 번호는 생략이 가능한데 이는 각 웹에서 사용하는 HTTP
, HTTPS
프로토콜의 기본 포트 번호가 정해져 있기 때문이다.
그러나 만약 https://google.com:443
과 같이 출처에 포트번호가 명시적으로 포함되어 있다면 이 포트 번호까지 모두 일치해야 같은 출처라고 인정된다. 하지만 이 케이스에 대한 명확한 정의가 표준으로 정해진 것은 아니기 때문에 더 정확히 이야기 하자면 어떤 경우에는 같은 출처, 또 어떤 경우에는 다른 출처로 판단될 수도 있다.
웹 생태계에는 다른 출처로의 리소스 요청을 제한하는 것과 관련된 두 가지 정책이 존재한다. CORS, 또한가지는 SOP(Same-Origin Policy)이다.
SOP는 지난 2011년 처음 등장한 보안 정챡으로 "같은 출처에서만 리소스를 공유할 수 있다."는 규칙을 가진 정책이다.
그러나 웹이라는 오픈스페이스에서 환경에 다른 출처에 있는 리소스를 가져와 사용하는 일은 굉장히 흔한 일이라 무작정 막을 수도 없는 노릇이니 몇 가지 예외 조항을 두고 이 조항에 해당하는 리소스 요청은 출처가 다르더라도 허용하기로 했는데, 그중하나가 "CORS 정책을 지킨 리소스 요청이다."
우리가 다른 출처로 리소스를 요청한다면 SOP 정책을 위반한 것이 되고, 거기에 SOP의 예외조항인 CORS 정책까지 지키지 않는다면 아예 다른 출처의 리소스를 사용할 수 없게 되는것이다.
즉, 이렇게 다른 출처의 리소스를 사용하는 것을 제한하는 행위는 하나의 정책만으로 결정된 사항이 아니라는 의미가 되며, SOP에서 정의된 예외조항과 CORS를 사용할 수 있는 케이스들이 맞물리지 않을 경우에는 아예 리소스 요청을 할 수 없는 케이스도 존재할 수 있다.
왜 이런 귀찮은 정책이 존재할까?
클라이언트 애플리케이션, 특히나 웹에서 돌아가는 클라이언트 애플리케이션은 사용자의 공격에 너무나도 취약하다. 당장 브라우저의 개발자 도구만 열어도 DOM이 어떻게 작성되어 있는지, 어떤 서버와 통신하는지, 리소스의 출처는 어디인지와 같은 각종 정보들을 아무런 제재 없이 열람할 수 있지 않은가?최근 JavaScript 소스 코드를 난독화해서 읽기 어렵다고 하지만, 난독화는 그저 읽기 어렵게만 만들어 둔것이지 암호화 된게 아니다. 그리고 아무리 난독화 되었다고 해도 사람이 바로 이해할 수 없는 정도도 아닌데다 소스코드를 직접 볼 수 있다는 것 자체가 보안적으로 상당히 취약한 부분이다. 심지어 아직까지도 소스 코드의 난독화가 안되어 개발자 도구만 열면
<script>
태그 안에 날것 그대로의 소스코드가 떡하니 노출되는 사이트도 많다.이러한 상황속에서 다른 출처의 애플리케이션이 서로 통신하는 것에 대하 아무런 제약도 존재하지 않는다면, 악의를 가진 사용자가 소스코드를 쓱 구경한 후 CSRF나 XSS같은 방법을 사용하여 사용자의 정보를 탈취하기가 너무나도 쉬워진다.
두개의 출처가 서로 같다고 판단하는 로직 자체는 굉장히 간단하다 두 URL의 구성요소중 Scheme
, Host
, Port
이 3가지만 동일하면 된다.
https://google.com:433
이라는 출처를 예로 들면 https://
이라는 스킴에 google.com
호스트를 가지고 :433
번 포트를 사용하고 있다는 것만 같다면 나머지는 전부 다르더라도 같은 출처로 인정이 된다는 것이다.
예시
https://google.com
를 예시로 살펴보자.
URL | 같은 출처 | 이유 |
---|---|---|
https://google.com/Mypage | O | Scheme , Host , Port 동일 |
https://google.com/Mypage?q=안냥 | O | Scheme , Host , Port 동일 |
https://user:password@google.com | O | Scheme , Host , Port 동일 |
http://google.com | X | Scheme 이 다름 |
https://goo-gle.com | X | Host 가 다름 |
https://google-no.naver-good.com | X | Host 가 다름 |
https://google.com:433 | ? | 브라우저의 구현에 따라 다름 |
맨 마지막에 있는 예시의 경우 만약 출처에 https://google.com:4332
처럼 포트 번호가 명시되어 있다면 명백하게 다른 출처로 인정되는 부분이지만 예시로 든 출처의 경우 포트번호가 포함되지 않았기 때문에 판단하기가 애매하다. 이러한 경우에는 각 브라우져들의 독자적인 출처 비교 로직을 따라가게 된다.
(출처 비교시 포트 번호를 완전히 무시하는 브라우저는 Internet Explorer밖에 없다.)
여기서 중요한 사실은 출처를 비교하는 로직이 서버에 구현된 스펙이 아니라 브라우저에 구현되어 있는 스펙이라는 것이다. 만약 CORS정책을 위반하는 리소스 요청을 해도 해당 서버가 같은 출처에서 보낸 요청만 받겠다는 로직을 가지고 있는 경우가 아니라면 서버는 정상적으로 응답을 하고 이후 브라우저가 이 응답을 분석해서 CORS 정책 위반이라고 판단되면 그 응답을 사용하지 않고 그냥 버리는 순서인 것이다.
즉, CORS는 브라우저의 구현 스펙에 포함되는 정책이기 때문에 브라우저를 통하지 않고 서버간 통신을 할 때는 이 정책이 적용되지 않는다. 또한 CORS 정책을 위반하는 리소스 요청 때문에 에러가 발생했다고 해도 서버쪽 로그에는 정상적으로 응답을 했다는 로그만 남기 때문에 CORS가 돌아가는 방식을 정확히 모르면 에러 트레이싱에 난항을 겪을 수도 있다.
기본적으로 웹 클라이언트 애플리케이션이 다른 출처의 리소스를 요청할 때는 HTTP프로토콜을 사용하여 요청을 보내게 되는데 이 때 브라우저는 요청헤더에 Origin
이라는 필드에 요청을 보내는 출처를 함께 담아 보낸다.
이후 서버가 이 요청에 대한 응답을 할 때 응답 헤더의 Access-Control-Allow-Origin
을 비교해본 후 이 응답이 유요한 응답인지 아닌지를 결정한다.
기본적인 흐름은 간단하지만 사실 CORS가 동작하는 방식은 한 가지가 아니라 세 가지의 시나리오에 따라 변경되기 때문에 여러분의 요청이 어떤 시나리오에 해당하는지 잘 파악한다면 CORS 정책 위반으로 인한 에러를 고치는 것이 한결 쉬울 것이다.
프리플라이트(prefilght)
방식은 일반적으로 우리가 웹 애플리케이션을 개발할 때 가장 마주치는 시나리오이다. 이 시나리오에 해당 하는 상황일 때 브라우저는 요청을 한번에 보내지 않고 예비 요청과 본 요청으로 나누어서 서버로 전송한다.
이때 브라우저가 본 요청을 보내기 전에 보내는 예비요청을 prefilght라고 하며 이 예비 요청에는 HTTP 메소드중 OPTION
메소드가 사용된다. 예 요청의 역할은 본 요청을 보내기 전에 브라우저 스스로 이 요청을 보내는 것이 안전한지 확인하는 것이다.
우리가 자바스크립트의 fetch
API를 사용하여 브라우저에게 리소스를 받아오라는 명령을 내리면 브라우저는 서버에게 예비요청을 먼저 보내고 서버는 이 예비요청에 대한 응답으로 현재 자신이 어떤것들을 허용하고 어떤 것들을 금지하고 있는지에 대한 정보를 응답헤더에 담아 브라우저에게 다시 보내주게 된다.
예시
const headers = new Headers({
'Content-Type': 'text/xml',
});
fetch('https://google.com/rss', { headers });
OPTIONS https://google.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: google.com
Origin: https://goo-gle.com
Referer: https://goo-gle.com/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://google.com/rss 200 OK
Access-Control-Allow-Origin: https://google.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=Edge
우리가 여기서 눈여겨 봐야할 것은 서버가 보내준 응답 헤더에 포함된 Access-Control-Allow-Origin: https://google.com
이라는 값이다.
예시 서버는 이 리소스에 접근이 가능한 출처는 오직 https://google.com
뿐이라고 브라우저에게 이야기해준 것이고 이 요청을 보낸 출처는 https://goo-gle.com
이므로 서버가 허용해준 출저와는 다른 출처이다.
결국 브라우저는 이 요청이 CORS정책을 위반했다고 판단하고 에러를 뱉게 된다.
이때 예비 요청에 대한 응답에서 에러가 발생하지 않고 정상적으로 200번대의 요청이 들어오게 되는데 콘솔창에는 빨갛게 에러표시가 뜨게 된다. CORS 정책 위반으로 인한 에러는 예비 요청의 성공 여부와 별 상관이 없다. 브라우저가 CORS 정책위반 여부를 판단하는 시점은 예비 요청에 대한 응답을 받은 이후이기 때문이다.
물론 예비 요청 자체가 실패해도 똑같이 CORS 정책위반으로 처리될 수도 있지만, 중요한 것은 예비 요청의 성공/실패 여부가 아니라 응답헤더에 유효한 Access-Control-Allow-Origin
값이 존재하는가이다. 만약 예비요청이 실패하여 200이 아닌 상태 코드가 내려오더라도 헤더 값이 제대로 들어가 있다면 CORS정책 위반이 아니라는 의미이다.
단순 요청은 예비 요청을 보내지 않고 바로 서버에게 본 요청부터 보낸 후 서버가 이에 대한 응답의 헤더에 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
만 허용된다.애초에 저 조건에 명시된 헤더들은 진짜 기본적인 헤더들이기 때문에, 복잡한 상용 웹 어플리케이션에서 이 헤더들 외에 추가적인 헤더를 사용하지 않는 경우는 드물다. 당장 사용자 인증에 사용되는 Authorization 헤더 조차 저 조건에는 포함되지 않는다.
게다가 대부분의 HTTP API는 text/xml이나 application/json 컨텐츠 타입을 가지도록 설계되기 때문에 사실 상 이 조건들을 모두 만족시키는 상황을 만들기는 그렇게 쉽지 않은 것이 현실이다.
인증된 요청을 사용하는 방법이다. 이 시나리오는 CORS의 기본적인 방식이라기 보다는 다른 출처 간 통신에서 좀 더 보안을 강화하고 싶을 때 사용하는 방법이다.
기본적으로 브라우저가 제공하는 비동기 리소스 요청 API인 XMLHttpRequest
객체나 fetch API는 별도의 옵션 없이 브라우저의 쿠키 정보나 인증과 관련된 헤더를 함부로 요청에 담지 않는다. 이때 요청에 인증과 관련된 정보를 담을 수 있게 해주는 옵션이 바로 credentials
옵션이다.
이 옵션에는 총 3가지의 값을 사용할 수 있으며 각 값들이 가지는 의미는 다음과 같다.
옵션 값 | 설명 |
---|---|
same-origin(기본 값) | 같은 출처 간 요청에만 인증 정보를 담을 수 있다. |
include | 모든 요청에 인증 정보를 담을 수 있다. |
omit | 모든 요청에 인증 정보를 담지 않는다. |
만약 same-origin이나 include와 같은 옵션을 사용하여 리소스 요청에 인증 정보가 포함된다면, 이제 브라우저는 다른 출처의 리소스를 요청할 때 단순히 Access-Control-Allow-Origin만 확인하는 것이 아니라 좀 더 빡빡한 검사 조건을 추가하게 된다.
브라우저는 인증 모드가 include일 경우, 모든 요청을 허용한다는 의미의 "*"를 Access-Control-Allow-Origin 헤더에 사용하면 안된다고 이야기하고 있다.
이처럼 요청에 인증 정보가 담겨있는 상태에서 다른 출처의 리소스를 요청하게 되면 브라우저는 CORS 정책 위반 여부를 검사하는 룰에 다음 두 가지를 추가하게 된다.
Access-Control-Allow-Origin
에는 *를 사용할 수 없으며, 명시적인 URL이어야한다.Access-Control-Allow-Credentials: true
가 존재해야한다.CORS 정책 위반으로 인한 문제를 해결하는 가장 대표적인 방법은, 그냥 정석대로 서버에서 Access-Control-Allow-Origin 헤더에 알맞은 값을 세팅해주는 것이다.
이때 와일드카드인 *을 사용하여 이 헤더를 세팅하게 되면 모든 출처에서 오는 요청을 받아먹겠다는 의미이므로 당장은 편할 수 있겠지만, 바꿔서 생각하면 정체도 모르는 이상한 출처에서 오는 요청까지 모두 받아먹겠다는 오픈 마인드와 다를 것 없으므로 보안적으로 심각한 이슈가 발생할 수도 있다.
그러니 가급적이면 귀찮더라도 Access-Control-Allow-Origin: https://google.com
와 같이 출처를 명시해주도록 하자.
사실 CORS를 가장 많이 마주치는 환경은 바로 로컬에서 프론트엔드 어플리케이션을 개발하는 경우라고 해도 과언이 아니다. 백엔드에는 이미 Access-Control-Allow-Origin
헤더가 세팅되어있겠지만, 이 중요한 헤더에다가 http://localhost:3000
같은 범용적인 출처를 넣어주는 경우는 드물기 때문이다.
프론트엔드 개발자는 대부분 웹팩과 webpack-dev-server
를 사용하여 자신의 머신에 개발 환경을 구축하게 되는데, 이 라이브러리가 제공하는 프록시 기능을 사용하면 아주 편하게 CORS 정책을 우회할 수 있다.
module.exports = {
devServer: {
proxy: {
'/api': {
target: 'https://api.google.com',
changeOrigin: true,
pathRewrite: { '^/api': '' },
},
}
}
}
이렇게 설정을 해놓으면 로컬 환경에서 /api
로 시작하는 URL로 보내는 요청에 대해 브라우저는 localhost:8000/api
로 요청을 보낸 것으로 알고 있지만, 사실 뒤에서 웹팩이 https://api.google.com
으로 요청을 프록싱해주기 때문에 마치 CORS 정책을 지킨 것처럼 브라우저를 속이면서도 우리는 원하는 서버와 자유롭게 통신을 할 수 있다. 즉, 프록싱을 통해 CORS 정책을 우회할 수 있는 것이다.
Reference
CORS는 왜 이렇게 우리를 힘들게 하는걸까?