Please enable JavaScript to view the comments powered by Disqus.CORS Cross-origin Resource Sharing (CORS)에 대하여 자세히 알아보자
Search
📠

CORS Cross-origin Resource Sharing (CORS)에 대하여 자세히 알아보자

태그
HTTP
Security
공개여부
작성일자
2024/10/06
이전 인증팀에 있을때 나를 가장 힘들게 했던 것은 CORS이다. 지나고보면 개발하면서 그리 어려운 주제도 아닌데 너무 고생했던것 같다.
이 글은 복사 붙여넣기 하여 문제 해결을 해주는 글이 아니다.
CORS가 무엇인지 이해하여 그래서 어떻게 코드를 작성할지 스스로 생각하게 만드는 것이 목표이다.
만약 빠르게 문제 해결만을 원해서 복붙할 코드를 찾는다면 잘못 들어왔으니, 충분히 시간이 될 때 여유롭게 읽어보면 좋을것 같다.

용어 정리

영어가 모국어가 아니다보니 용어 때문에 처음에 좀 햇갈렸던것 같다.
그래서 간단히 용어만 먼저 정리하고 지나가려고 한다.
Origin: 출처, 요청을 보내는 곳 정도로 해석하면 가장 좋을것 같다.
Cross-origin: 교차 출처, 요청을 보내는 곳이 다른 경우를 의미한다. 출처는 다음과 같다.
프로토콜 (http, https 등등)
URL
Port
Preflight: 사전요청으로 side effect를 일으키는 요청은 preflight를 먼저 보낸다.
Resource: 이미지, json, html 등등의 데이터

Cross-Origin Resource Sharing

요청을 보내는 client application과 요청을 수신하여 정보를 반환하는 서버의 origin이 다를때 side effect를 통해 공격이 가능하다.
서버 입장에선 허용된 요청인지 아닌지 구분하여 CSRF 공격을 방어하기 위한 수단이다.
현재 Chromium, firefox, safari 등 주요한 브라우저들은 정해진 CORS 스펙을 구현해야 하며, resource(이미지, json 응답 등등)를 차단하도록 구현되었다.

Flow

VueJs, React 등으로 서버에 요청을 보내는경우, OPTION 요청이 함께가는 경우를 본적이 있을 것이다. 그 이후에 필요로 하는 요청을 전송하는데 보통 POST, PUT, PATCH, DELETE 등의 HTTP method를 사용하는 경우 OPTION 이 발견 되었을 것이다.
OPTION 을 preflight 라 하며 다음의 요청을 보내려고 하는데 가능한가? 라는 질문을 브라우저가 서버로 전송하고
서버는 이 질의를 받으면 가능한 부분을 명시한다.
다음은 요청과 응답의 예시이다.
OPTIONS /doc 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: content-type,x-custom
YAML
복사
요청
HTTP/1.1 204 No Content Date: Mon, 01 Oct 2024 18:15:39 KST Server: Apache/2 Access-Control-Allow-Origin: https://foo.example Access-Control-Allow-Methods: POST, GET, OPTIONS Access-Control-Allow-Headers: X-CUSTOM, Content-Type Access-Control-Max-Age: 86400 Vary: Accept-Encoding, Origin Keep-Alive: timeout=2, max=100 Connection: Keep-Alive
YAML
복사
응답

예시 해석

요청

요청을 해석하면 다음과 같다.
OPTIONS /doc 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: content-type,x-custom
YAML
복사
1.
Method는 OPTION 으로 preflight에 해당한다.
2.
송부하는 측은 https://foo.example 이다.
3.
수신측의 Host를 bar.other 로 전송한다.
4.
POST 요청을 전송하려고 한다. 가능한가?
5.
헤더로 content-typex-custom 을 전송할 것이다. 가능한가?

응답

HTTP/1.1 204 No Content Date: Mon, 01 Oct 2024 20: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-CUSTOM, Content-Type Access-Control-Max-Age: 86400 Vary: Accept-Encoding, Origin Keep-Alive: timeout=2, max=100 Connection: Keep-Alive
YAML
복사
1.
오로지 https://foo.example 에 resource를 제공한다.
2.
HTTP method는 POST, GET, OPTION 이 가능하다.
3.
Header로는 X-CUSTOM, Content-Type 이 가능하다.
4.
이 preflight는 24시간 동안 캐시 한다. (더 안보내도 된다.)
간단하게 CORS의 매커니즘을 설명했다. 만약 트러블 슈팅중 이면 아래 글을 더 읽는것을 권장하고, 정의와 flow만 확인하고 싶다면 여기까지만 읽어도 된다.
이젠 더 deep dive를 해보려고 한다.

Simple Request

CORS와 preflight를 설명하려면 반드시 simple request를 설명해야 한다.
Simple request는 CORS의 preflight를 실행하지 않는 요청이다.
다음의 조건을 모두 충족한다면 simple request에 해당하고 그렇지 않으면 preflight로 CORS 매커니즘을 사용해야 한다.
다음중 하나에 해당하는 HTTP method
GET
HEAD
POST
User-Agent 에서 자동으로 설정한 헤더만 포함된 경우
Accept
Accept-Language
Content-Language
Content-Type
Range (단순한 헤더 값만 포함된 경우이다)
Content-Type 이 다음에 type/subtype 에 해당하는 경우
application/x-www-form-urlencoded
multipart/form-data
text/plain
요청이 XMLHttpRequest 객체를 사용하여 이루어졌고, XMLHttpRequest.upload 에 의한 이벤트 리스터가 없을 때
요청에 ReadbleStream 객체가 사용되지 않았을 때
다시한번 강조하지만 위 조건이 모두 만족할 때 Simple request가 된다.
만약 HTTP method가 GET이면 simple request 인가? 라고 질문한다면, No 라고 대답해야 올바르다.

Preflight

Simple request와 다르게 실제 요청을 보내는 것이 안전한지 질의하기 위해 브라우저OPTION method를 사용해 다른 출처에 HTTP 요청을 보내는 것이다.
이러한 요청을 보내는 이유는 Cross-Origin 으로 요청을 보내어 side effect를 일으켜 유저 데이터에 영향을 줄 수 있기 때문이다.
Preflight는 브라우저가 전송하기 때문에 개발자 입장에선 신경쓰지 않아도 되지만 다음과 같이 직접 호출해볼 수 있다.
curl -i -X OPTIONS localhost:3001/doc \ -H 'Access-Control-Request-Method: GET' \ -H 'Access-Control-Request-Headers: Content-Type, Accept' \ -H 'Origin: http://localhost:3000'
YAML
복사

Credential을 포함한 요청은 어떻게 될까?

아마 CORS를 검색했다면 대부분 인증과 관련된 부분을 개발하다가 문제에 부딪혔을 것이다.(나도 그렇다)
Preflight 에는 credential 이 절대로 포함되선 안된다.
왜냐하면 preflight 과정은 권한과 관련이 없다. 그건 application의 영역이고 CORS는 HTTP 의 영역이다.
TCP/IP 4 layer 수준에서 둘이 같은것이 아니냐고 할 수 있지만, 개발자가 개발한 영역와 HTTP spec을 분리하여 대응해야 한다.

Preflight 와 credential

CORS preflight 에 실제 요청이 자격 증명과 함께 수행될 것임을 명시하기 위해 다음의 헤더를 추가해야 한다.
Acess-Control-Allow-Credentials: true
YAML
복사
만약 fetch() 에 credential 을 포함한다면 credential 옵션을 "include" 로 설정해야 한다.
const url = "https://bar.other/resources/credentialed-content/"; const request = new Request(url, { credentials: "include" }); const fetchPromise = fetch(request); fetchPromise.then((response) => console.log(response));
JavaScript
복사

Credential 이 포함되면 wildcard는 사용할 수 없다.

필자는 이 부분에 대한 인지가 없어서 CORS 문제를 겪었다. 아마 대부분 이 부분을 인지하고 있다면 문제를 쉽게 해결할 수 있지 않을까 싶다.
Access-Control-Allow-Origin 의 응답으로 서버가 * 를 포함할 수 없다.
명확한 origin 값을 반환해야 한다.
예시) https://foo.example.com
Access-Control-Allow-Methods 의 응답으로 서버가 * 를 포함할 수 없다.
명확한 method를 반환해야 한다.
POST, GET, OPTION
Access-Control-Allow-Headers 의 응답으로 서버가 * 를 포함할 수 없다.
명확한 헤더를 반환해야 한다.
X-CUSTOM, Content-Type
Security 관련된 기능을 사용하지 않고 직접 인증을 구현 했을 때 필자는 편리한 개발을 위해 * 를 남발했다. (일단 돌아가게 만들어 놓고 리팩토링 하는 타입)
이때 CORS 에러를 맞이했으며 그때 이 스펙을 인지하여 문제를 해결했다.
종종 스프링으로 트러블슈팅 하는 경우 * 를 응답 헤더에 할당 하라는 글을 발견하는데 모두 잘못 설명한 것이다.
브라우저는 위 헤더들에 대해 * 를 응답 헤더로 받게 된다면 응답의 Set-Cookie 헤더를 설정하지 않도록 정해졌다.

Third-party cookie

CORS 응답은 third-party 쿠키 정책의 적용을 받는다.
페이지는 foo.example 에서 로드되고, 응답의 Cookie 헤더는 bar.other 에서 반환된다.
따라서 사용자의 클라이언트가 third-party 쿠키를 거부하도록 설정 되었다면, 쿠키가 저장되지 않는다.
만약 매우 strict 정책에 적용받는다면 credential 이 포함된 모든 요청을 전혀 수행할 수 없게 만든다.

Headers: Access-Control-*

Preflight 의 샘플을 보면 body는 없고 Access-Control- 가 prefix인 헤더가 다양하게 있음을 확인할 수 있다.
CORS 에서 사용하는 모든 헤더를 하나씩 살펴보고자 한다.

Access-Control-Allow-Origin

Access-Control-Allow-Origin: <origin> | *
YAML
복사
Access-Control-Allow-Origin 은 단일 출처를 지정하거나, credential 이 없는 경우 * 를 사용해 origin에 관계없이 모든 리소스에 접근하도록 한다.
만약 https://blog.yevgnenll.me 의 코드가 리소스에 접근하도록 하려면 아래와 같이 지정할 수 있다.
Access-Control-Allow-Origin: https://blog.yevgnenll.me Vary: Origin
JavaScript
복사
이 경우 하나의 출처를 지정할 때 서버는 origin 요청 헤더에 따라 다르다는 것을 알려주기 위해 Vary 응답 헤더에 origin 을 포함해야 한다.
Origin 에 반환되는 주소는 딱 이름만 반환하는 것이다.
특정한 path를 추가하는 것은 허용되지 않는다.

Access-Control-Allow-Methods

Access-Control-Allow-Methods는 어떠한 HTTP methods를 사용할 수 있는지 명시하기 위해 사용한다.
Access-Control-Allow-Methods: <method>[, <method>]*
JavaScript
복사
예시와 같이 , 쉼표를 사용하여 여러 method를 나열할 수 있고 credential이 없는 경우 * 모든 methods가 사용 가능함을 명시할 수 있다.

Access-Control-Allow-Headers

Preflight 의 결과로 어떠한 HTTP header를 사용할 수 있는지 명시한다.
Access-Control-Allow-Headers: <header-name>[, <header-name>]*
JavaScript
복사
쉼표로 구분되어 하나 이상의 header를 반환하거나, credential 이 없는 경우 * 를 사용하여 반환할 수 있다.

Access-Control-Expose-Headers

Access-Control-Expose-Headers 는 preflight를 통해 브라우저에서 접근하여 노출할 수 있는 헤더를 의미한다. (자바스크립트로 치면 Response.headers 로 노출)
Access-Control-Allow-Headers 와 Access-Control-Expose-Headers 의 차이는 다음과 같다.
요청에 첨부할 헤더는 Access-Control-Allow-Headers 에 담아 전송하고
서버에서 클라이언트에 응답에 첨부할 때는 Access-Control-Expose-Headers 를 사용한다.
모두 커스텀(개발자가 정의한) 헤더를 사용할 수 있다.

Access-Control-Max-Age

처음 preflight를 알았을 때 그럼 모든 요청을 전송하기 전에 OPTION 요청을 보내는 것 인가?
그렇다면 너무 서버에 과부하가 오지 않을까? 였다.
이러한 걱정을 대응하는 것이 Access-Control-Max-Age 헤더이다.
Access-Control-Max-Age: <delta-seconds>
JavaScript
복사
Default: 5s
Firefox: 86,400s (24hours)
Chromium prior v76: 600s(10 minutes)
Chromium starting v76: 7,200s(2hours)
최대 값은 86,400s 이다.
이 헤더를 알고나선 한 명의 사용자가 우리 서비스를 사용하는 시간의 통계를 내어 평균시간을 반영했던 적이 있다. 만약 개인화가 가능했다면, 사용자에 따라 다르게 했을 것 같다.

Access-Control-Allow-Credentials

이 헤더는 없거나 true 로 값을 할당하여 전송한다.
Credentials 가 true 일 때 요청에 대한 응답을 표시할 수 있는지 여부를 명시한다.
주의할 점은 simple GET 에 preflight 가 없고, 자격증명 과정이 꼭 필요한 경우 이 헤더가 없으면 브라우저는 응답을 무시한다.
아마 이 글을 찾는 대부분의 개발자는 이 이슈 때문에 여기까지 오지 않았을까 싶다.

결론

현대 web application은 개인화에 초점을 맞추기 때문에 결국 인증, 인가로 부터 자유롭지 않을 것이다.
대기업은 인증팀에서 치밀하게 관리하기 때문에 마치 인증, 인가 과정이 완료된 것 처럼 혹은 없는 것 처럼 개발할 수 있게 만들어두지만 스타트업에 있을때만 하더라도 CORS는 직접 해결해야하는 문제였다.
다행히 개발자 콘솔에서 CORS는 매우 명확하게 로깅해주고, 원인이 특정 되었으니 해결은 이 글로 충분히 어떻게 해야할지 이 글을 읽는 독자가 스스로 판단할 수 있으리라 생각한다.