CORS에 대한 나의 탐구

✍🏼 작성일 2016년 07월 07일   
❗️ 참고: 이 글이 작성된 지 이미 일이 지났습니다. 시의성에 유의하세요

글의 배경

온라인에서 CORS 관련 자료를 읽다가 한 문장에 혼란을 느꼈습니다: “'주의, withCredentials = true를 설정한 후, 전송되는 cookie는 목적 도메인의 cookie입니다’라는 내용이었는데, 이해가 되지 않았습니다: 현재 도메인이 a.com이고 xhrb.com으로 보낸다면, 당연히 원본 도메인 a.comcookieb.com으로 보내 처리해야 하는 것 아닌가? 어떻게 목적 도메인(여기서는 b.com)의 cookie가 전송된다는 말인가?” 그래서 조사를 시작했습니다(결론부터 말하자면, 제가 본 자료의 설명은 정확했습니다. 실제로 목적 도메인 b.comcookie가 전송됩니다).

첫 번째 목표: 단순 요청으로 a.com에서 b.com으로 ajax 보내기

단순 요청이 무엇인지는 직접 구글/StackOverflow에서 확인해 주세요. 여기서 중요한 기본 사실은 cookieIP 주소가 아닌 도메인에 따라 결정된다는 점입니다. 저는 로컬에서 cookie를 처리할 수 있는 간단한 express 서비스를 구동했고, 동시에 VPS에도 동일한 서비스를 설정했습니다. 그리고 hosts 파일을 수정하여 다른 도메인을 구현했습니다:

1
2
3
4
5
// 修改 hosts 如下
// 本机 ip
172.16.26.57 www.a.com
// VPS ip
45.78.41.32 www.b.com

먼저 VPS 서버에서 express 서비스를 시작했습니다. 별다른 설정 없이 단순히 header를 반환하도록 했으며, 포트는 9091로 설정했습니다:

1
2
3
4
5
6
7
8
var express = require('express');
var app = express();

app.get('/', function (req, res, next) {
res.send(req.headers);
});

app.listen(9091);

a.comServer 측도 기본적으로 동일하지만, ajax를 보내기 위해 정적 페이지를 추가했고, 포트는 9090으로 설정했습니다:

1
2
3
4
5
6
7
var express = require('express');
var app = express();
var path = require('path');
app.get('/', function (req, res) {
res.sendFile(path.join(__dirname, 'index.html'));
});
app.listen(9090);

index.html 내용:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
<!DOCTYPE html>
<html>
<head>
<title>a.com</title>
</head>
<body>
<button id="button">点我发请求, 打开控制台查看信息</button>
<script type="text/javascript">
var button = document.getElementById('button');

function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com:9091', true);
xhr.send();
}

button.addEventListener('click', xhrSend);
</script>
</body>
</html>

서버 측에서 Access-Control-Allow-Origin을 설정하지 않았기 때문에 오류가 발생했습니다:

VPSServerError

이제 b.com 서버 측에서 a.comajax를 허용하도록 추가했습니다(포트까지 정확히 지정해야 함):

1
2
3
4
5
6
app.get('/', function (req, res, next) {
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
});
res.send(req.headers);
});

다시 요청을 보냈습니다:

VPSServerError1

VPSServerError1.1

콘솔에 오류가 없고 상태 코드가 200이므로 b.coma.com:9090의 요청을 허용했다는 것을 확인했습니다.

두 번째 목표: 로컬 서버가 프론트엔드의 cookie를 받기

이제 로컬에서 a.com 백엔드가 프론트엔드의 cookie를 받을 수 있는지 테스트해 보겠습니다:

테스트 방법은 간단합니다. js에 임의의 cookie를 추가하면 됩니다:

1
2
3
document.cookie = 'domain=a.com;';
document.cookie = 'name=xheldon';
document.cookie = 'lover=xiaodan';

LocalServer1

로컬 콘솔의 Application 탭에서 cookie가 추가된 것을 확인할 수 있습니다. 백엔드 출력도 확인해 보겠습니다:

LocalServer2

문제 없습니다. www.a.com:9090에 접속할 때 cookie가 정상적으로 전송되었습니다. 예상대로입니다.

세 번째 목표: a.comcookieb.com으로 보내기

이제 a.com 페이지에는 cookie가 있습니다. 버튼을 다시 클릭하여 ajax 요청이 cookieb.com으로 전송할 수 있는지 확인해 보겠습니다:

VPSServerError1.2

cookie를 추가하지 않았을 때와 마찬가지로 a.comcookie를 받지 못했습니다. 이는 보안 제한 때문이며, 예상된 결과입니다.

추가 목표: 비단순 요청 테스트

여기서 단순 요청에 대한 테스트를 추가로 진행해 보겠습니다. xhr에 새로운 header를 추가한 후 요청을 보냅니다:

1
2
3
4
5
6
7
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com:9091', true);
xhr.setRequestHeader('xiaodan', 'xheldon');
xhr.send();
}

이번에는 Access-Control-Allow-Origin이 추가된 후의 작업이므로 브라우저에서 다른 오류가 발생했습니다:

VPSServerError2.1

문제를 인지한 것은 Access-Control-Allow-Origin 오류 때문이었지만, 이번에는 프론트엔드에서 사용자 정의 header를 설정했기 때문에 비단순 요청(non-simple request)이 발생했습니다. 비단순 요청의 경우 사전 요청(preflight)을 먼저 보내는데, 이 요청의 유형은 OPTIONS입니다. 자세한 내용은 이 글]을 참고하세요. 사전 요청의 목적은 b.com 서버가 xiaodan이라는 header를 허용하는지 확인하는 것입니다. 백엔드에서 반환한 headerAccess-Control-Allow-Headers에는 xiaodan이라는 값이 포함되어 있지 않아 오류가 발생했습니다.

이제 b.com이 반환하는 내용에 해당 header를 추가해 보겠습니다:

1
2
3
4
5
6
7
8
app.get('/', function (req, res, next) {
console.log(req.headers);
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
});
res.send(req.headers);
});

다시 요청을 보내보면:

VPSServerError2.1

똑같은 오류가 발생합니다. 서버는 200으로 응답했지만, Access-Control-Allow-Headers를 반환하지 않아 브라우저에서 결과를 거부했습니다(서버가 거부한 것이 아니라, 서버는 200을 반환했습니다).

문제를 조사해 보니, 이 비단순 요청에서 문제가 발생하고 있었습니다. b.com 함수를 약간 수정해 보겠습니다:

1
2
3
4
5
6
7
8
9
10
11
12
app.use(function (req, res, next) {
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
});
next();
});

app.get('/', function (req, res, next) {
console.log(req.headers);
res.send(req.headers);
});

서버 측:

VPSServerError2.2

클라이언트 측:

VPSServerError2.3

원인을 분석해 보면(확인 필요, 나중에 HTTP 권위 있는 가이드를 참고할 예정), 비단순 요청의 preflight 요청은 실제 요청을 보내지 않고, 먼저 서버가 특정 비단순 header 필드를 지원하는지 테스트하기 위한 사전 요청을 보냅니다. 즉, 비단순 헤더가 포함된 요청은 app.get('/') 내부로 들어가지 않습니다. 동시에 b.com 서버에서도 console.log(req.headers)app.get('/') 내부에 작성되어 있기 때문에, 방금의 요청에서 서버는 아무것도 출력하지 않았습니다. 이는 CORS 표준을 인지하지 못하는 레거시 서버를 보호하기 위한 설계입니다.

소목표-4: a.com 도메인의 cookieb.com으로 전송하기

OK, 잠시 중단하고 클라이언트 측, 즉 a.com에서 발신하는 xhr 요청의 페이지에서 withCredentials = true를 설정했을 때(여기서는 xhrSend 부분만 표시) a.comcookieb.com으로 전송할 수 있는지 테스트해 보겠습니다(단순 요청과 비단순 요청의 결과는 동일하며, 차이를 쉽게 확인하기 위해 xhr에서 설정한 header를 제거했습니다):

1
2
3
4
5
6
7
8
// 设置允许 cookie
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com/', true);
xhr.withCredentials = true;
xhr.send();
}

VPSServerError2.4

이번에는 오류 메시지가 변경되어 서버가 Access-Control-Allow-Credentialstrue로 설정하지 않았다고 표시됩니다. 이 header는 요청이 cookie를 포함할 수 있도록 허용하는 역할을 합니다. 따라서 설정을 추가하겠습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
app.use(function (req, res, next) {
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
});
next();
});

app.get('/', function (req, res, next) {
console.log(req.headers);
res.send(req.headers);
});

VPSServerError2.5

여전히 cookie가 전송되지 않습니다. Chromecookie를 확인해 보겠습니다:

VPSServerError2.6

분명히 cookie가 설정되어 있는데, 무슨 문제일까요? 이해가 되지 않아, req.headersexpress에서 포맷팅된 결과라고 생각하고 원시 headersrawHeaders를 확인해 보았습니다:

VPSServerError2.7

여전히 보이지 않습니다. cookie가 사라진 걸까요?

아무것도 없어, 그녀는 말했지 나중에 찾을 거라고, 시간, 시간이 나에게 답을 줄 거야 ------ 내 스케이트보드 신발

문제를 찾지 못할 때는 아이스크림을 먹어보세요. 아래로 내려가서 파슬리 맛 네슬레 아이스크림을 샀습니다(하겐다즈는 너무 비싸서요), 그리고 올라오는 순간 번뜩 아이디어가 떠올랐습니다. 아마도 우리의 경우 제3자 쿠키에 해당하는 것 같은데, 제가 브라우저 추적을 금지했기 때문일까요? 그래서 아이스크림을 다 먹고 크롬 설정에서 "추적 안 함" 요청을 브라우징 트래픽과 함께 보내기 옵션의 체크를 해제했습니다:

VPSServerError2.8

참고로, 이번에는 req.headersapp.use 안에 넣어두었습니다(사실 별 문제는 없을 겁니다):

1
2
3
4
5
6
7
8
9
10
11
12
13
app.use(function (req, res, next) {
console.log(req.headers);
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
});
next();
});

app.get('/', function (req, res, next) {
res.send(req.headers);
});

다시 버튼을 클릭하여 요청을 보내고, 크롬 콘솔과 b.com 서버 출력을 확인했습니다:

비단순 헤더가 있기 때문에 이전과 마찬가지로 두 개의 요청이 표시됩니다:

VPSServerError2.9

VPSServerError2.9.1

서버에서도 수신되지 않았으므로, 이 크롬 설정과는 무관하다는 것을 알 수 있습니다. 따라서 변수를 통제하기 위해 추적 안 함 옵션은 이전과 같이 다시 체크했습니다. 서버 측(GET 요청만 표시):

VPSServerError2.9.2

여전히 쿠키 필드가 없습니다. 왜일까요?

문서 시작 부분의 문구가 떠올랐습니다: ‘주의, withCredentials = true를 설정한 후 전송되는 쿠키는 대상 도메인의 쿠키입니다’. 혹시 a.com에서 버튼을 클릭해 b.com으로 요청을 보낼 때, b.com에 설정된 쿠키를 전송하는 걸까요?

그래서 먼저 b.com 서버에도 index.html을 새로 만들고, 거기에 임의의 쿠키를 추가했습니다:

b.com 서버 코드:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
var express = require('express');
var app = express();
var path = require('path');
app.use(function (req, res, next) {
console.log(req.headers);
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
});
next();
});

app.get('/', function (req, res, next) {
res.sendFile(path.join(__dirname, 'index.html'));
});

app.listen(9091);

b.com의 index.html 코드:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!DOCTYPE html>
<html>
<head>
<title>a.com</title>
</head>
<body>
<div>
open Application inspector to check whether the cookie is be setting
</div>
<script type="text/javascript">
document.cookie = 'yes=you_cant_believe_i_from_b.com';
document.cookie = 'from=this_is_from_b.com';
</script>
</body>
</html>

자, 먼저 b.com에 접속해 보겠습니다:

VPSServerError4

문제없이 웹 페이지가 정상적으로 반환되고, 쿠키도 정상적으로 설정되었습니다. 이제 a.com에서 버튼을 클릭해 요청을 보냅니다:

VPSServerError4.1

a.com에서 발송된 ajax 요청이 b.com쿠키를 함께 전송하는 것을 확인할 수 있습니다.

문서 시작 부분의 문구가 입증되었습니다.

목표-5: a.com자바스크립트b.com쿠키 가져오기:

a.com에서 b.com쿠키를 전송할 수 있다면, 프론트엔드에서 b.com쿠키를 가져올 수 있을까요?

문서를 확인해 보니, ajax에는 getAllResponseHeaders()getResponseHeader() 두 가지 인터페이스가 있고, 서버 측에는 Access-Control-Expose-Header가 있습니다. 그래서 테스트를 진행해 보았습니다(나는 분명히 따로 출력하고 싶습니다, 뭐 어쩌라고?).

먼저 간단한 것부터 시작해, xhrgetAllResponseHeaders() 인터페이스를 호출해 보았습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 设置允许 cookie
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com/', true);
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (this.status === 200) {
console.log('AllRes:', this.getAllResponseHeaders());
}
};
xhr.send();
}

LocalServer3

헤더쿠키 필드가 나타나지 않았습니다. 예상대로였지만, 혹시 getAllResponseHeaders()헤더를 순회할 때 쿠키enumerable: false로 설정되어 있어서일까 싶어 getResponseHeader()도 시도해 보았습니다:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 设置允许 cookie
function xhrSend(e) {
e.preventDefault();
var xhr = new XMLHttpRequest();
xhr.open('GET', 'http://www.b.com/', true);
xhr.withCredentials = true;
xhr.onreadystatechange = function () {
if (this.status === 200) {
console.log('Res:', this.getResponseHeader('Cookie'));
}
};
xhr.send();
}

LocalServer3.1

역시 예상대로였습니다. 서버에서 노출할 헤더 내용을 설정하지 않았기 때문입니다. 그래서 b.com에서 Access-Control-Expose-Header를 설정했습니다:

1
2
3
4
5
6
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
'Access-Control-Expose-Headers': 'Cookie',
});

그리고 다시 getResponseHeader('Cookie')getAllResponseHeaders()를 실행합니다.

LocalServer3.2

에러는 없지만, b.comcookie를 얻을 수 없습니다. b.com 서버가 모두 동의했는데도 말이죠.

자료를 찾아보니, 이 Access-Control-Expose-Header는 커스텀 header로만 설정되어 프론트엔드에서 획득할 수 있다고 합니다. 하지만 이건 별 의미가 없어요. 왜냐하면 이 커스텀 header는 내가 프론트엔드에서 설정한 것이기 때문이죠. 유일한 역할은 백엔드가 커스텀 header를 수정하거나 새로 생성한 후, 프론트엔드가 가져오는 것입니다. 아래에서 백엔드에 header를 설정하여 프론트엔드가 가져올 수 있도록 하겠습니다:

b.comserver:

1
2
3
4
5
6
7
res.set({
'Access-Control-Allow-Origin': 'http://www.a.com:9090',
'Access-Control-Allow-Headers': 'xiaodan',
'Access-Control-Allow-Credentials': true,
'Access-Control-Expose-Headers': 'Xheldon',
Xheldon: 'MyNameIsXheldon',
});

a.comindex.html:

1
2
3
4
5
6
xhr.onreadystatechange = function () {
if (this.status === 200) {
console.log('Res:', this.getResponseHeader('Xheldon'));
console.log('AllRes:', this.getAllResponseHeaders());
}
};

LocalServer3.3

이렇게요.

그래서 이 작은 목표는 달성할 수 없지만, SO 커뮤니티에서는 몇 가지 해결책을 제시했습니다. 예를 들어, 서드파티 서비스/백엔드를 사용하여 전달하는 방법 등이 있죠. 규칙은 고정되어 있지만 사람은 유연하니까요. jsonp와 비슷한 거죠, 그렇죠?

연상

어떤 사람들은 몇 달 동안 기초부터 훈련받으면 어떤 언어를 마스터할 수 있다고 말합니다. 저는 이건 터무니없는 이야기라고 생각합니다. 컴퓨터 기초 지식이 없고, 이진법/컴파일 원리/컴퓨터 원리/운영체제 원리/네트워크 기초/통신 프로토콜이 무엇인지 모르는 상태에서 코드를 작성할 수 있다는 건, 단지 따라하는 학습 능력이 뛰어나다는 걸 보여줄 뿐입니다. 이렇게 작성하면 이런 효과가 나온다는 걸 알지만, 왜 이렇게 작성하면 이런 효과가 나오는지는 모르는 거죠.

따라서 컴퓨터와 소통할 때는 지식의 폭이 넓을수록 좋고, 깊이가 깊을수록 좋습니다. 이번에 CORS를 이해하면서, 예전에 접했던 AUTH가 떠올랐습니다.

AUTH2.0이라는 게 있는데, 이게 CORS와 무슨 관계가 있을까요? 별 관계는 없지만, 제가 비교해보겠습니다:

CORSB 사이트를 방문한 사용자가 A 사이트에서 요청을 보낼 때 B 사이트의 cookie를 함께 보낼 수 있게 합니다. 단계는 다음과 같습니다:

  1. 사용자가 B 사이트를 방문합니다.
  2. 사용자가 A 사이트를 방문하고, A 사이트에서 B 사이트로 요청을 보냅니다.
  3. B 사이트는 A 사이트에서 온 요청에 포함된 B 사이트의 cookie를 검증하고, 문제가 없으면 B 사이트의 데이터를 반환합니다.

AUTH는 사용자가 A 웹사이트에서 B 웹사이트의 리소스에 접근할 수 있게 합니다. 단, 사용자 승인이 필요합니다. 단계는 다음과 같습니다:

  1. A 웹사이트에서 요청을 보냅니다.
  2. 1단계에서 B 사이트로 리디렉션되어 승인을 확인한 후, 다시 A 웹사이트로 리디렉션됩니다.
  3. 이제 A 웹사이트는 tooken(토큰)을 얻어 B 웹사이트의 사용자 리소스에 접근할 수 있습니다.

비슷하지 않나요?

CORS의 첫 번째 단계는 AUTH의 두 번째 단계에 대응됩니다. AUTH의 두 번째 단계는 CORS의 첫 번째 단계에서 B 웹사이트의 서버에 Access-Control-Allow-Credentials를 추가한 것과 같다고 볼 수 있습니다. 이렇게 하면 승인이 완료된 거죠.

CORScookie를 함께 보내는 건 AUTH의 간소화된 버전이라고 볼 수 있습니다.

아래는 RFC 6749에서 발췌한 내용입니다.

AUTH

후기

왜 제3자 광고 cookie가 개인정보를 유출할 수 있다고 하는 걸까요? 이는 광고가 A 웹사이트에 게재되면, 광고주는 이 광고가 A 웹사이트에 노출되었다는 사실을 알게 되기 때문입니다(광고 게재를 위한 id/key 같은 identity로 식별하고 결제합니다). 따라서 광고주는 이 광고에 cookie를 설정하는데, 이 광고는 광고 B의 도메인에서 제공되므로 설정되는 cookie는 당연히 Bcookie가 됩니다. A 웹사이트가 이 광고를 로드할 때마다 반드시 일부 js를 실행하게 되는데, 이 js에서 A 사이트가 cookie를 전송할 수 있도록 허용하고, 동시에 B 사이트도 A 사이트에서 B 사이트의 cookie를 포함해 전송되는 것을 허용합니다. 따라서 모든 정보를 알 수 있게 되는 것입니다.

`Google AD 구현:

googleWithCredientials

googleWithCredientials2

주의 사항

  1. 위에서 설명한 수정 사항 중 서버 측 수정이 포함된 경우 모두 서비스 재시작이 필요합니다. VPS에서 서비스를 재시작하는 것은 불편할 뿐만 아니라 시간이 지나면 연결이 끊어질 수 있으므로, 가장 좋은 방법은 VPS에 글에서 설명한 a.com(주로 index.html 수정용)을 배치하고 로컬에는 b.com 내용(주로 index.js 수정용)을 배치하는 것입니다.

  2. 원격 연결을 유지하는 가장 간단한 방법은 백그라운드에서 실행하는 것입니다(연결이 유지되지 않으면 연결이 끊어진 후 요청이 들어와 I/O 작업을 수행할 때 여전히 연결이 끊어질 수 있습니다). node index.js &를 실행하거나, 이미 node index.js가 실행 중일 때 ctrl+z를 눌러 백그라운드로 중단시킨 후 bg 명령어로 가장 최근의 백그라운드 작업을 활성화할 수 있습니다. jobs 명령어로 현재 작업 목록을 확인할 수 있습니다. 현재 session에서退出한 후 다시 연결하면 작업은 여전히 실행 중이지만 jobs로는 해당 작업을 볼 수 없으므로 ps -A로 모든 프로세스를 나열한 후 kill IDnode 프로세스를 종료하고 다시 실행하면 됩니다.

  3. 작은 목표를 달성하는 동안 오류가 발생했을 때 사용자 정의 필드를 X-로 시작하지 않아서 발생한 것으로 의심했지만, 표준을 확인해 보니 X-로 시작해야 한다는 규정은 없었습니다. 위키피디아와 Stack Overflow에서는 단지 사용자 정의 HeaderX-로 시작하는 것을 권장할 뿐입니다.

  4. withCredentials = true로 설정한 후에는 서버 측의 Access-Control-Allow-Originwildcard *로 설정할 수 없습니다.

- EOF -
이 글의 최초 게시: CORS에 대한 나의 탐구 - Xheldon Blog