CORS

se·2023년 1월 31일
46
post-thumbnail

브라우저 정책을 모른 채 서버와 클라이언트만 생각하는 초보 개발자에게 CORS 에러는 무시무시하다. 그래서 그 때의 충격을 간직하며 CORS에 막연한 벽을 느꼈다.

Express 서버를 여러개 만들어보며 몇 줄 안되는 미들웨어로 해결이 가능하다는걸 알게 됐지만, 발생한 이슈 외에 개념 자체에 대한 공부는 부족했기 때문에 여전히 좀 찝찝한 키워드였다. 이번에 차가운 스터디 주제로 고르면서 제대로 뜯어봤다.

TL;DR
공식문서 차분하게 정독하기를 1년만 빨리 했어도 CORS 이슈 해결하느라 N시간을 쓸 일은 없었을 것 같다.
=== 길어도 한번만 읽어보세요 ..

Cross-Origin Resource Sharing (CORS) - HTTP | MDN


CORS

CORS는 Cross-Origin Resource Sharing의 약자이다. 단어 그대로 다른 출처와 자원 공유가 필요한 경우 사용되는 체제로, 더 자세하게는 이렇게 설명할 수 있다.

한 웹 어플리케이션이 실행 중인 출처와 다른 출처의 선택한 자원에 접근할 수 있는 권한을 부여하도록 추가 HTTP 헤더를 사용해 브라우저에 알려주는 체제

Same-origin Policy

이걸 이해하기 위해 먼저 동일 출처 정책에 대해 알아야 한다. 원칙적으로 브라우저에서는 현재 어플리케이션과 다른 출처에 있는 리소스에 접근이 제한된다.

  • 이 정책을 따르는 API를 사용하는 웹 어플리케이션은

    • 자신과 동일한 출처(프로토콜 + 도메인 + 포트)의 리소스만 불러올 수 있으며,
    • 리소스가 자신의 출처와 다를 때 Cross-Origin HTTP 요청을 실행한다.
    • 해당 출처에서 올바른 CORS 헤더를 포함한 응답을 반환해야 리소스를 불러올 수 있다.
  • 이 때 응답에 대한 CORS 에러는 서버가 아닌 브라우저의 스펙이다.

    • 즉, 서버가 성공적으로 리소스를 응답해도 브라우저에서 무시하는 것이다.
    • 브라우저는 스크립트에서 시작한 cross-origin HTTP 요청을 제한한다.
    • 잠재적으로 해로운 문서를 분리해 공격받을 수 있는 경로를 줄이려는 보안 상의 이유가 있다.
  • XMLHttpRequest Fetch API 모두 이 정책을 따르고 있다.

CORS는 HTTP 헤더를 통해 이 접근을 허용하는 체제이다.

간단하게는 응답의 Access-Control-Allow-Origin 헤더와 자신의 출처가 같다면 유효하고 안전한 응답이라고 판단되어 리소스에 접근할 수 있다.


동작 방식

알아야 하는 개념은 딱 세 가지로 정리된다.

  1. Access-Control-Allow-Origin

    • 서버 측에서 해당 정보를 읽는 것이 허용된 출처이다.

    • 이 헤더에 담긴 출처와 브라우저 측 Origin이 같아야 한다.

    • Access-Control-Allow- prefix를 가진 HTTP 헤더들은 이 출처에 대한 정보를 담고 있다.

  2. Preflight 요청

    • Preflight 요청은 OPTIONS 메서드를 사용하는 사전 요청이다.

    • 서버 데이터에 side effect를 일으킬 수 있는, GET 이외의 HTTP 메서드를 사용하는 요청은 preflight 처리된다.

      • (사용 메서드 외에도 디테일한 조건이 있지만 아래쪽에서 설명한다.)

      • 이 요청에 대한 서버의 허가가 떨어지면 실제 요청을 보내도록 명세에 요구되어 있다.

    • 실제 요청에서 허용되는 메서드, 헤더에 관한 정보를 Access-Control-Allow-Methods/Headers 헤더에 담아 전달한다.

  3. Access-Control-Allow-Credentials

    • 클라이언트에게 인증 정보를 함께 보내야 한다고 알려주기도 한다.

      • cookies, authorization headers, or TLS client certificates 등이 있다.
    • 서버에 쿠키 등의 인증 정보가 필요한 경우 이 헤더를 true로 설정해주어야 한다.

      • npm cors에서는 { credentials: true } 옵션을 통해 가능하다.

위의 내용을 이해했다면 이제 CORS를 알고있다고 말할 수 있다.

다른 출처와의 통신에서 HTTP 헤더를 사용해 제한된 자원의 접근과 정보 제공이 안전하게 이루어지도록 하는 것이 CORS다.

세상에는 나쁜 사람들이 많다. 클라이언트라는 아기는 어쩌다 길을 잘못들어(XSS 공격) 나쁜 서버를 만날 수 있다. 그래서 브라우저라는 보호자는 아기가 모르는 사람의 응답을 무시하고 도망치게 한다. 다만 그 사람이 아기를 알아보고 엄마아빠 이름까지 알고 있다면 안전한 사람이라고 판단하고, 경찰 옷을 입었다면 집 주소나 전화번호까지 알려줄 수도 있다.

서버 아기에게도 나쁜 클라이언트의 요청이 들어올 수 있다. 이런 위험 요소가 있는 요청들을 브라우저 레벨에서 확인하는 과정을 거쳐 서버 데이터에 대한 보안 계층을 추가하고, 동시에 클라이언트 아기까지 한번 더 보호한다.


동작을 더 확실하게 살펴보기 위해서 Express 어플리케이션을 위해 만들었던 서버의 cors 미들웨어를 가져왔다. 최근 살펴본 npm cors 모듈은 옵션 지원해야해서 그런지 내부가 복잡하던데 정적인 설정으로는 간단하게 만들 수 있다.

() => (req: Request, res: Response, next: NextFunction) => {
  // 1.
  res.append('Access-Control-Allow-Origin', env.CLIENT_PATH);

  // 2.
  res.append('Access-Control-Allow-Credentials', 'true');

  // 3.
  // 본 요청에 사용될 수 있는 메서드와 헤더 정보
  res.append('Access-Control-Allow-Methods', 'GET, PUT, POST, DELETE, OPTIONS');
  res.append('Access-Control-Allow-Headers', 'Content-Type, Origin, Cookies');

  // OPTIONS 메서드 요청에 OK 응답 전송
  if (req.method === 'OPTIONS') {
    res.status(OK).send();
    return;
  }
	
  next();
};

짧은 코드지만 앞서 설명한 세 가지 경우를 모두 커버한다. 내가 진행한 프로젝트에서는 이 미들웨어를 사용했을 때 CORS 이슈가 전혀 발생하지 않았다.

  1. 허용된 클라이언트 출처를 Access-Control-Allow-Origin 헤더에 넣는다.

  2. 인증을 위해 쿠키가 필요해 Access-Control-Allow-Credentials 값을 true로 설정했다. 출처가 다른 클라이언트에게 인증 정보를 요청하는 헤더이다.

  3. 이 부분은 Preflight 요청을 처리하기 위한 로직이다.
    허용되는 메서드와 헤더를 설정하고, 요청이 사용한 메서드가 OPTIONS인 경우 위의 헤더들을 설정한 응답을 클라이언트에게 전송한다.


시나리오 예제

MDN에서 소개하는 시나리오 예시들로 더 자세하게 살펴보자. 가독성을 위해 응답에는 CORS 관련된 헤더들만 남겼다.

1️⃣ 단순 요청

CORS preflight를 트리거하지 않는 요청으로, 다음의 조건들을 모두 충족해야 한다. (뭔가 까다로운 조건이라는 것만 알면 된다.)

  • GET, HEAD, POST 중 하나의 메서드

  • User-Agent 자동 설정 헤더 외의 수동 설정 헤더는 CORS-safelisted request header만 사용

    • Accept, Accept-Language, Content-Language, Content-Type

    • Content-Type 헤더에 허용되는 값은 다음으로 제한

      • application/x-www-form-urlencoded
      • multipart/form-data
      • text/plain
  • 요청에 사용된 XMLHttpRequestUpload 객체에 이벤트 리스너가 등록되어 있지 않음

  • 요청에 ReadableStream 객체가 사용되지 않음

# request
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>

# response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: *
...

[…XML Data…]

응답에는 리소스에 접근할 수 있는 출처에 대한 정보를 제공하는 Access-Control-Allow-Origin 헤더가 포함되어야 한다. 예시의 * 값은 모든 도메인에서 접근 가능함을 나타낸다.

* 와일드 카드를 사용하지 않고 특정 클라이언트의 접근만 허용하려면 Access-Control-Allow-Origin: <https://foo.example> 처럼 설정한다. 이 값이 Origin 헤더에서 전송된 값과 일치하는 경우 접근이 허용된다.
이런 경우 아래와 같이 Vary 응답 헤더에 Origin 을 포함해야 한다는 주의 사항이 있다.

Access-Control-Allow-Origin: <https://foo.example>
Vary: Accept-Encoding, Origin

2️⃣ Preflight 요청

단순 요청과 다르게, cross-origin 요청이 유저 데이터에 영향을 줄 여지가 있는 요청은 OPTIONS 메서드를 통해 본 요청이 전송하기에 안전한지 확인하는 preflight 요청을 거친다.

어차피 CORS 정책에 어긋나는 응답이 오는거라면 바로 본 요청을 보내는거랑 뭐가 다른가 싶어서 ChatGPT에 물어봤다.

양 측의 보안을 위해 사용할 메서드와 헤더(민감한 정보가 포함될 수 있는)를 승인받는 목적에서, 또 서버가 실제 서버인지 확인하는 측면에서도 보안을 강화한다고 한다.

아래의 요청은 POST 요청의 body가 application/xml 컨텐트 타입으로 전송되고, 비표준 HTTP Ping-Other 헤더(사용자 정의 헤더)가 설정되기 때문에 preflighted 처리된다.

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

사진처럼 두 번의 요청이 오고가게 되고, 본 요청은 preflight 응답이 CORS 정책에 적합한 경우에만 전송된다. HTTP 헤더들은 이렇게 생겼다.

# preflight request
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

# preflight response
HTTP/1.1 204 No Content
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 request를 보내지 않고, 캐시된 응답을 사용할 수 있는 시간(초)
Vary: Accept-Encoding, Origin
...

3️⃣ 인증 정보를 포함한 요청

기본적으로 브라우저는 cross-site 요청에 인증 정보를 보내지 않는다.

HTTP Cookies, Authentication 등의 인증 정보를 요청에 포함하기 위해서는 아래처럼 XMLHttpRequest 또는 Fetch API의 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; // withCredentials 플래그
    invocation.onreadystatechange = handler;
    invocation.send();
  }
}

// axios 사용하는 경우
axios.post(
	'https://developer.mozilla.org/ko/docs/Web/HTTP/CORS', 
	body, 
	{ withCredentials: true }
)

또, 이렇게 보낸 요청에 대한 응답에는 Access-Control-Allow-Credentials: true 헤더가 포함되어 있어야 한다.
값이 존재하지 않거나 false인 경우, 브라우저는 해당 응답을 무시하고 웹 컨텐츠는 제공되지 않는다. 따라서 요청과 응답 측 전부에 인증 정보 관련 설정이 필요하다.

# credentialed request
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

# credentialed response
HTTP/1.1 200 OK
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
...
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT

인증 정보는 가장 민감한 요소인만큼 이를 포함하는 요청-응답에서는 주의할 점이 몇 가지 있다.

  • Access-Control-Allow-Origin: * 와일드카드를 사용하는 경우 응답은 거부된다.

  • 사용자 브라우저에서 모든 third-party cookies가 거부되어 있다면 Set-Cookie 헤더로 설정한 쿠키는 저장되지 않는다.

    • 예시에서 접속된 브라우저는 foo.example 페이지이지만 응답은 bar.other 가 전송

Wrap-up

CORS 관련 이슈를 해결할 수 있는 방법은 두 가지가 있다.

  1. 서버에서 필요한 헤더들을 설정하는 방법
  2. 프록시 서버를 사용하는 방법

근본적으로는 모두 HTTP 헤더를 설정하는 것이다.

서버에서든 프록시 서버에서든 단순 요청, Preflight 요청, 인증 정보를 포함한 요청 중 어떤 케이스가 필요한지 생각하고 헤더를 붙이면 된다. 모듈을 사용하는 경우는 옵션 몇 줄만 넣어주면 된다.

그냥 1.로 헤더만 붙이면 되는걸 왜 프록시 서버까지 두고 자원을 낭비.. 라고 생각해서 질문한 적이 있다. 그런 케이스를 잘 보지 못했다는 답변을 들었지만

다만 저희 사내 프로젝트 중에서, 인증 토큰을 관리할 수 있는 서버가 소수만 지정되어있고, "신규 서비스들은 토큰 관련된거 새로 만들지 말고, 여기서 알아서 분기해서 사용해라" 와 같은 기조가 있는 경우에 기존에 존재하는 서버에서 로그인 관련 역할 API만 뚫어둔 프록시 서버를 쓰는 형태가 있긴 했어요!

내가 서버를 컨트롤 할 수 없는 경우에 프록시 서버를 통해 CORS 우회가 가능한 듯 하다.


👀 Origin 비교 로직에 대한 신기하고 별로 쓸데는 없는 고민

  • 프로토콜 + 호스트 + 포트 튜플이 일치하는 경우 동일한 출처
  • MDN에는 프로토콜의 포트 기본값을 고려하는 것처럼 설명되어 있는데, 브라우저에서 프로토콜 기본 포트가 생략된 경우 다른 origin으로 판단해 CORS 에러가 발생한 경험이 있다.

    • cf) preflight 응답에 2xx 응답이 오더라도 Access-Control-Allow-Origin 헤더와 origin 값이 다르면 본 요청을 보내지 않는다.
  • 비교 로직이 궁금해 스펙을 찾아보려고 했지만 명시된 자료가 없었다.

  • 브라우저마다 구현 방식이 다르겠지만 Chrome, Safari 기준으로는 생략된 기본 포트를 대체하고 있지 않았다.

  • 대충 내부 구현을 생각해보면 :// : 기준으로 파싱해서 값을 추출할 듯 하다.

    • 이 과정에서 포트가 생략된 경우 프로토콜 기본 포트로 설정해주는 과정 없이 비교를 진행한다고 추측했다.

8개의 댓글

comment-user-thumbnail
2023년 2월 8일

너무 도움이 되었어요!!!!!!!

1개의 답글
comment-user-thumbnail
2023년 2월 8일

엄청 자세하네요 !! 🫵👍

1개의 답글
comment-user-thumbnail
2023년 2월 9일

비유가 너무 좋네요~ 이해가 쏙쏙됐어요!

1개의 답글
comment-user-thumbnail
2023년 2월 10일

자세한 설명 감사합니다!

1개의 답글