CORS에 대하여.
이전에 CORS Error를 접하며 관련 정보를 찾아보고 해결 했었는데,
'CORS' 개념 자체에 대해서는 부족하게 이해한거 같아 다시 공부해 보게 되었다.
1. Cors의 개념
CORS(Cross-Origin Resource Sharing)는 HTTP 헤더 안에서 브라우저가 자신의 출처(도메인, 프로토콜, 포트)가 아닌 다른 출처에 접근할 수 있는 권한을 부여할 수 있게 한다. 일반적으로 브라우저는 서버가 교차 출처 요청에 대해 허용할 것인지 확인하기 위해 실제 요청 전 사전 요청('preflight')을 보내게 된다. 이 사전 요청의 header에는 실제 요청에 사용될 HTTP method와, headers의 정보가 포함된다.
보안 상의 이유로, 브라우저는 스크립트에서 시작된 교차 출처 HTTP요청을 제한 한다. 예를 들어 XMLHttpRequest, Fetch API 는 동일 출처 정책(동일한 출처로의 요청만 허용하는)을 따른다. 즉, 서버에서 주는 응답에 적절한 CORS 관련 헤더가 포함되지 않았다면 유저는 다른 출처에서의 리소스를 받을 수 없게 된다는 것.
서버는 CORS 관련 HTTP 헤더를 응답에 추가해서, 어떤 출처의 요청이 허락 되는지 브라우저에게 알릴 수 있다. 이 때 서버 데이터에 부작용을 일으킬 수 있는 HTTP 요청은 (특히 GET method를 제외한 다른 method나 특정 MIME 유형을 사용하는 POST) 브라우저가 사전에 'preflight'을 보내도록 규정한다.
브라우저는 HTTP OPTION method를 통해 사전 요청을 보내는데, 서버가 어떤 method가 지원이 되고 어떤 헤더가 지원이 된다고 응답을 보내 "승인" 이 되면 그 때 실제 요청을 보낼 수 있게 된다. 또한 서버는 "승인"시에 클라이언트가 실제 요청을 보낼 때 "쿠키", HTTP 인증과 같은 "자격 증명"을 보내는 것이 적합한 지에 대해서도 알릴 수 있다.
어떤 요청들이 CORS를 사용하는지는, 일반적으로 XMLHttpRequest, Fetch API 요청에 가장 많이 사용되고, 이 외에도 웹 폰트(교차 도메인 폰트 사용 시), WebGL 텍스쳐, drawImage()를 사용해 캔버스에 그린 그림, 이미지로부터 추출하는 CSS shapes 요청에 사용된다.
2. 동작 예시
1) 단순 요청
일부 요청은 preflight, 사전요청을 필요로 하지 않는다. 이것은 HTML 4.0에서 폼데이터가 어떠한 출처로도 전송될 수 있게 되었기 때문에, 모든 서버가 이미 크로스 사이트 요청 위조 (CSRF)에 대한 보호를 하고 있다는 전제에서 비롯되었다.
preflight가 발생하지 않는 단순 요청은 다음 조건을 모두 충족해야 한다.
- GET, HEAD, POST 메서드 중 하나여야 한다.
- user-agent에 의해 자동으로 설정 된 헤더 외, 수동으로 설정한 헤더는 Fetch spec에서 CORS-safelisted 요청 헤더로 정의된 헤더여야 한다. (ex) Accept, Accept-Language, Content-Language, Content-Type, Range)
- 설정된 Content-Type은 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나여야 한다.
예시)
요청 헤더의 Origin, 응답 헤더의 Access-Control-Allow-Origin을 이용 하는 것은 접근 제어 프로토콜의 가장 기본적인 사용 구조이다.
- 요청 HTTP
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
위의 예시를 보면 요청 헤더의 Origin을 통해 메시지의 출처가 'https://foo.example'라는 것을 알 수 있고,
- 응답 HTTP
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 00:23:53 GMT
Server: Apache/2
Access-Control-Allow-Origin: *
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Transfer-Encoding: chunked
Content-Type: application/xml
[…XML Data…]
서버의 응답 헤더의 경우 'Access-Control-Allow-Origin: * '로 답하고 있다. '*' 의 의미는 모든 출처에서 접근이 가능하다는 의미이다.
특정 출처에서만 접근이 가능하도록 만들기 위해서는 'Access-Control-Allow-Origin: https://foo.example' 이런식으로 헤더를 지정해 주면 된다. 이렇게 지정을 해주면 http://foo.example 이외의 도메인은 교차 출처 방식으로 리소스에 접근할 수 없게 된다. 요청을 한 곳에 대한 접근을 허용 하려면 요청에서 보내준 Origin 값을 넣어주면 된다.
예시) in javascript
교차 출처 요청을 하고 있는 요청의 Origin 은 https://chuckchoiboi.github.io/cors-tutorial/ 으로 가정한다.
//server
app.get('/api/simple/no-origin', (req, res) => {
res.status(200).json({ title: 'Hello World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/simple/no-origin')
=> 실패 : Origin이 Host와 다르기 때문에 교차출처 요청이나, CORS에 관련한 핸들링이 서버측 코드에 없다.
//Server
app.get('/api/simple/bad-origin', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://www.website.notcool")
res.status(200).json({ title: 'Hello World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/simple/bad-origin')
=> 실패 : 요청을 보내는 Origin이 서버에서 header를 통해 허용한 도메인과 다르다.
//Server
app.get('/api/simple/good-origin', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://chuckchoiboi.github.io")
or
res.header("Access-Control-Allow-Origin", "*")
res.status(200).json({ title: 'Hello World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/simple/good-origin')
=> 잘 동작함 : 요청을 보내는 Origin 혹은 * 와일드 카드를 이용해서 교차 출처 요청을 전부 허용했기 때문에 잘 동작한다.
2) preflight 요청
단순 요청의 조건에 해당하지 않는 다면 preflight, 사전 요청을 진행하게 된다. preflight 요청에서는 브라우저가 실제 요청 전에 HTTP OPTION method를 이용해서 사전 요청을 보낸다. 실제로 할 요청이 적합한지 확인하기 위함이다.
예시)
- preflight 요청 / 응답 HTTP
// 요청
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: X-PINGOTHER, Content-Type
해당 실제 요청은 POST 요청이고, Headers에는 X-PINGOTHER이라는 단순 요청 조건에 포함되지 않는 헤더가 있다.
그러므로 위와 같이 preflight 요청이 진행되게 된다. 주의 깊게 봐야할 것은 아래 2줄이다.
Access-Control-Request-Method : 서버에게 실제 요청의 method가 POST라는 것을 알려준다.
Access-Control-Request-Headers : 실제 요청이 X-PINGOTHER, Content-Type 사용자 지정 헤더를 포함하여 요청될 것임을 알려주고 있다.
서버는 실제 요청 정보를 담은 preflight 요청을 받았으니, 실제 교차 출처 요청이 허락되는 조건에 대한 정보를 보내줄 수 있다.
// 응답
HTTP/1.1 204 No Content
Date: Mon, 01 Dec 2008 01: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-PINGOTHER, Content-Type
Access-Control-Max-Age: 86400
Vary: Accept-Encoding, Origin
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
중요하게 봐야될 것은 4 ~ 8줄이다.
Access-Control-Allow-Origin: https://foo.example : 지정된 도메인에서만 접근이 가능하다고 알려주고 있다.
Access-Control-Allow-Methods: POST, GET, OPTIONS : 허용되는 methods들에 대해 알려주고 있다.
Access-Control-Allow-Headers: X-PINGOTHER, Content-Type : 허용되는 headers에 대한 정보이다.
Access-Control-Max-Age: 86400 : 다른 preflight 요청을 보내지 않고 응답을 캐시할 수 있는 최대 시간이다. 응답을 보낼 때마다 계속 사전요청을 해야 한다면 리소스의 낭비로 이어진다. 기본값은 5초이고, 지정한 시간이 각 브라우저별 최대 값을 초과하면 브라우저별 최대 값이 우선시 된다.
위의 preflight 요청 / 응답 과정이 끝나면 실제 요청 / 응답이 이루어진다.
- 실제 요청 / 응답 HTTP
//요청
POST /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
X-PINGOTHER: pingpong
Content-Type: text/xml; charset=UTF-8
Referer: https://foo.example/examples/preflightInvocation.html
Content-Length: 55
Origin: https://foo.example
Pragma: no-cache
Cache-Control: no-cache
<person><name>Arun</name></person>
//응답
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:15:40 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 235
Keep-Alive: timeout=2, max=99
Connection: Keep-Alive
Content-Type: text/plain
[Some XML payload]
예시) in javascript
교차 출처 요청을 하고 있는 요청의 Origin 은 https://chuckchoiboi.github.io/cors-tutorial/ 으로 가정한다.
//Server
app.options('/api/preflight/bad-method', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://chuckchoiboi.github.io")
res.status(204).end()
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/preflight/bad-method', '{"method":"DELETE"}')
=> 실패 : 브라우저가 DELETE method를 통해 요청을 보냈다. 이것은 '단순 요청'을 위한 조건에 해당하지 않기 때문에, preflight에 대한 핸들링이 서버코드에 필요하게 된다. 이에 서버에서는 app.options를 통해 응답하고 있다. 요청의 Origin은 'Access-Control-Allow-Origin' header에 적합하지만, 응답에는 적합한 method에 대한 지정이 빠져있다. header는 어떤 method가 적합한지에 대해 알려주어야 한다. 만약 요청에 사용자가 추가적으로 기입한 임의의 header가 있다면 그것이 적합한지도 확인되어야 한다.
//Server
app.options('/api/preflight/req-bad-origin', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://chuckchoiboi.github.io")
res.header("Access-Control-Allow-Methods", "DELETE")
res.status(204).end()
})
app.delete('/api/simple/req-bad-origin', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://www.website.notcool")
res.status(200).json({ title: 'Goodbye World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/preflight/req-bad-origin', '{"method":"DELETE"}')
=> 실패 : 위의 사례와 다르게 서버는 preflight 요청에 대한 응답으로 적합한 origin과 method에 대해 알려주고 있다. (이 부분은 통과) 하지만 delete method으로온 실제 요청에 대한 핸들링에서 대해서는 "https://www.website.notcool" 출처로 온 교차 출처 요청만 가능하도록 명시하고 있기 때문에 동작하지 않게 된다.
//Server
app.options('/api/preflight/good-request', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://chuckchoiboi.github.io")
res.header("Access-Control-Allow-Methods", "DELETE")
res.status(204).end()
})
app.delete('/api/simple/good-request', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://chuckchoiboi.github.io")
res.status(200).json({ title: 'Goodbye World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/preflight/good-request', '{"method":"DELETE"}')
잘 동작 !
3. 인증을 포함하는 요청
Fetch, XMLHttpRequest와 CORS에서 발생되는 흥미로운 기능 중 하나는 'credentialed request' 이다. 브라우저는 기본적으로 교차 출처 요청에 대해서 쿠키나 credential 정보를 포함하지 않는다. XMLHttpRequest 오브젝트나 Request 생성자에 특정 flag를 설정해야 한다. 예를 들어 인증과 관련된 정보를 담을 수 있게 해주는 credential 옵션이 있다. axios를 예로 들면 아래와 같다.
axios.post('https://example.com:1234/users/login', {
profile: { username: username, password: password }
}, {
withCredentials: true // 클라이언트와 서버가 통신할때 쿠키와 같은 인증 정보 값을 공유하겠다는 설정
})
이러한 요청에 대해서 서버의 응답에는 'Access-Control-Allow-Credentials: true' 헤더가 포함되어야 한다. 포함되어 있지 않으면 브라우저에 의해 거부된다.
+ 자격 증명 요청에 응답시에 서버는 반드시 Access-Control-Allow-Origin에 "*" (와일드카드)가 아닌 출처를 지정 해주어야 한다.
위의 예를 든다면 요청 헤더에 Cookie가 포함되어 있기 때문에 응답의 Access-Control-Allow-Origin가 "*" 로 지정되어 있다면 요청이 실패하게 된다.
예시)
1) 단순 요청 with Credential
만약 쿠키와 함께 단순한 GET 요청을 보낸다고 할 때, 자바스크립트 코드는 대략 아래와 같을 것이다.
const invocation = new XMLHttpRequest();
const url = "https://bar.other/resources/credentialed-content/";
function callOtherDomain() {
if (invocation) {
invocation.open("GET", url, true);
invocation.withCredentials = true;
invocation.onreadystatechange = handler;
invocation.send();
}
}
7번째줄의 invocation.withCredentials = true; 이 부분이 인증 정보를 담는, 쿠키와 함께 호출하기 위한 flag 역할을 한다. 기본적으로 호출은 쿠키 없이 이루어진다. 이제 브라우저는 'Access-Control-Allow-Credentials: true' 헤더가 포함되지 않은 모든 응답을 거부하게 될 것이다.
//요청
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
위의 코드에 대한 요청 HTTP문이다. 요청에 쿠키가 포함되어 있음으로 만약 아래 응답 헤더에 'Access-Control-Allow-Credentials: true' 가 없다면 브라우저는 응답을 거부할 것.
//응답
HTTP/1.1 200 OK
Date: Mon, 01 Dec 2008 01:34:52 GMT
Server: Apache/2
Access-Control-Allow-Origin: https://foo.example
Access-Control-Allow-Credentials: true
Cache-Control: no-cache
Pragma: no-cache
Set-Cookie: pageAccess=3; expires=Wed, 31-Dec-2008 01:34:53 GMT
Vary: Accept-Encoding, Origin
Content-Encoding: gzip
Content-Length: 106
Keep-Alive: timeout=2, max=100
Connection: Keep-Alive
Content-Type: text/plain
[text/plain payload]
예시) in javascript
교차 출처 요청을 하고 있는 요청의 Origin 은 https://chuckchoiboi.github.io/cors-tutorial/ 으로 가정한다.
//Server
app.get('/api/credentialed/wildcard-origin', (req, res) => {
res.header("Access-Control-Allow-Origin", "*")
res.status(200).json({ title: 'Hello World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/credentialed/wildcard-origin', '{"credentials":"include"}')
=> 실패 : 요청에 credentials 속성이 포함되므로 인증이 포함된 요청이 된다. 서버는 인증이 포함된 요청에 대한 응답으로 교차 출처 허용 대상을 "*", 모든 대상으로 지정할 수 없다.
//Server
app.get('/api/credentialed/good-origin', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://chuckchoiboi.github.io")
res.status(200).json({ title: 'Hello World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/credentialed/good-origin', '{"credentials":"include"}')
=> 실패 : 요청하는 브라우저의 Origin이 서버에서 교차 출처 요청을 허용한 Origin에 적합하지만, credential이 포함된 요청에 대해서 서버는"Access-Control-Allow-Credentials", "true" 헤더를 포함해서 응답해야 한다.
//Server
app.get('/api/credentialed/good-request', (req, res) => {
res.header("Access-Control-Allow-Origin", "https://chuckchoiboi.github.io")
res.header("Access-Control-Allow-Credentials", "true")
res.status(200).json({ title: 'Hello World!' })
})
//Request
fetch('https://cors-tutorial-server.up.railway.app/api/credentialed/good-request', '{"credentials":"include"}')
잘 동작 !
2) Preflight 요청 with Credential
Preflight 요청에는 Credential이 포함되면 안된다. Preflight 요청에 대한 응답은 Access-Control-Allow-Credentials: true를 지정하여 실제 요청이 자격 증명과 함께 요청될 수 있음을 나타내야 한다.
참고사이트