본문 바로가기

Hub Web/Web

[Web] CORS와 해결 방법

728x90

📜 개요


🔹사용자를 CSRF, XSS 등의 공격으로부터 보호하기 위해 1차적인 방어선으로 시행된 동일 출처 정책 (Same-Origin Policy, SOP)에 의해 제 3자가 제공하는 API를 직접 호출하기가 어려워졌다. 하지만, 보호를 위해 동일한 출처를 제외하고 모든 외부 리소스를 차단한다면 인터넷이 돌아가지 않을 테니 이런 배경에 의해 리소스 호출이 허용된 출처를 서버가 명시해 놓으면, 출처가 다르더라도 요청과 응답을 주고받을 수 있도록 만들어놓은 정책이 바로 교차 출처 리소스 공유 (Cross-Origin Resource Sharing, CORS) 이다.

 

결국 내가 마주했던 에러는 사실상 브라우저의 SOP 정책에 따라 다른 출처의 리소스를 차단하면서 발생된 에러이며, CORS는 다른 출처의 리소스를 얻기 위한 해결 방안이었던 것이다. 요약하자면 SOP 정책을 위반해도 CORS 정책에 따르면 다른 출처의 리소스라도 허용한다는 뜻이다.

⚙️ CORS 에러가 발생하는 경우


1. 출처가 다른 도메인 또는 포트로 리소스를 요청할 때 ( 가장 기본적인 문제 )
CORS 정책에 따라, 출처가 다른 도메인이나 포트에서 리소스를 요청하는 경우에는 브라우저에서 CORS 에러가 발생한다. 이 경우에는 서버 측에서 Access-Control-Allow-Origin 헤더를 설정하여 요청을 허용해야 한다.

 

2. HTTPS에서 HTTP로 리소스를 요청할 때
보안상의 이유로 HTTPS에서 HTTP로 리소스를 요청하는 경우에도 CORS 에러가 발생할 수 있다. 이 경우에는 HTTPS로 통신하는 서버에서 HTTP로 요청을 전달하는 것이 아니라, HTTPS로 전달해야 한다.

 

3. 인증 정보를 포함한 요청을 보낼 때
CORS 정책에 따라, 인증 정보를 포함한 요청을 보낼 때에는 서버에서 Access-Control-Allow-Credentials 헤더를 설정해야 한다. 이 헤더가 설정되어 있지 않으면 브라우저는 인증 정보가 포함된 요청을 거부한다.

 

4. 허용되지 않은 메서드, 헤더를 사용할 때
CORS 정책에 따라, 허용되지 않은 메서드와 헤더를 사용하는 요청을 보내는 경우에는 CORS 에러가 발생한다. 이 경우에는 서버에서 Access-Control-Allow-Methods 헤더와 Access-Control-Allow-Headers 헤더를 설정하여 요청을 허용해야 한다.

 

5. 프리플라이트 요청에 대한 응답이 없는 경우
브라우저는 CORS 정책을 준수하기 위해, 프리플라이트 요청을 보내고 서버에서 이에 대한 응답을 받아야 한다. 이 때, 서버에서 응답이 없는 경우에는 CORS 에러가 발생한다. 이 경우에는 서버에서 프리플라이트 요청에 대한 응답을 보내도록 설정해야 한다.

 

6. 브라우저가 CORS를 지원하지 않는 경우
일부 오래된 브라우저에서는 CORS를 지원하지 않는 경우가 있다. 이 경우에는 서버 측에서 다른 방법을 사용하여 요청을 처리해야 한다.

 

7. CDN 등에서 제공하는 리소스를 요청할 때
일부 CDN 등에서 제공하는 리소스를 요청할 때에도 CORS 에러가 발생할 수 있다. 이 경우에는 CDN 서비스 제공 업체에서 CORS 설정을 제공하고 있으므로 해당 설정을 확인해야 한다.

 

8. 브라우저 설정 문제
일부 브라우저에서는 CORS 에러가 발생하는 경우가 있는데, 이는 브라우저 설정에서 발생하는 문제일 수 있다. 이 경우에는 브라우저 설정을 확인하거나, 다른 브라우저를 사용하여 문제를 해결할 수 있다.

 

🔍 브라우저의 CORS 기본 동작 


1. 클라이언트에서 HTTP요청의 헤더에 Origin을 담아 전달

    ◈ 기본적으로 웹은 HTTP 프로토콜을 이용하여 서버에 요청을 보내게 되는데,

    ◈ 이때 브라우저는 요청 헤더에 Origin이라는 필드에 출처를 함께 담아 보내게 된다.

2. 서버는 응답헤더에 Access-Control-Allow-Origin을 담아 클라이언트로 전달한다.

    ◈ 이후 서버가 이 요청에 대한 응답을 할 때 응답 헤더에 Access-Control-Allow-Origin이라는 필드를 추가하고 값으로 '이 리소스를 접근하는 것이 허용된 출처 url'을 내려보낸다.

3. 클라이언트에서 Origin과 서버가 보내준 Access-Control-Allow-Origin을 비교한다.

    ◈ 이후 응답을 받은 브라우저는 자신이 보냈던 요청의 Origin과 서버가 보내준 응답의 Access-Control-Allow-Origin을 비교해 본 후 차단할지 말지를 결정한다.

    ◈ 만약 유효하지 않다면 그 응답을 사용하지 않고 버린다. (CORS 에러 !!)

    ◈ 위의 경우에는 둘 다 http://localhost:3000이기 때문에 유효하니 다른 출처의 리소스를 문제없이 가져오게 된다.

 

즉, 브라우저 측에서는 CORS 에러를 방지하기 위해 XMLHttpRequest나 fetch API 등을 사용할 때, withCredentials 속성을 설정하거나, crossorigin 속성을 사용하여 요청을 보내야 하고 서버에서는  Access-Control-Allow-Origin 헤더에 허용할 출처를 기재해서 클라이언트에 응답하면 되는 것 이다.

그렇다면 클라이언트에서 미리 자바스크립트로 origin 헤더값을 위조하면 되지 않을까 싶지만, 브라우저에서 이를 감지하여 차단하기 때문에 결론은 불가능하다.

 

🧪 CORS의 동작 시나리오

⛓ 1. 단순 요청 (Simple Request)


단순 요청은 말 그대로 예비 요청(Prefilght)을 생략하고 바로 서버에 직행으로 본 요청을 보낸 후, 서버가 이에 대한 응답의 헤더에 Access-Control-Allow-Origin 헤더를 보내주면 브라우저가 CORS정책 위반 여부를 검사하는 방식이다.

대표적으로 아래 3가지 경우를 만족할 때 만 예비요청을 생략할 수 있다.

  • GETPOSTHEAD 메서드만 허용된다.
  • AcceptAccept-LanguageContent-LanguageContent-Type 헤더만 허용된다.
  • Content-Type 는 application/x-www-form-urlencodedmultipart/form-datatext/plain 이 세가지 값만 허용된다.

하지만, 일반적으로 Content-Type 이 application/json 인 경우가 굉장히 많으며, 사용자 인증을 위해서 Cookie 혹은 Autorization와 같은 추가 헤더를 사용하기 때문에 3번째 Content-Type이 위반되기 때문이다. 단순 요청의 조건을 맞추기는 쉽지 않다.

 

따라서 대부분의 API 요청은 그냥 예비 요청(preflight)으로 이루어진다라고 이해하면 된다.

 

⛓ 2. 예비 요청 (Preflight Request)


사실 브라우저는 요청을 보낼 때 한 번에 바로 보내지 않고, 먼저 예비 요청을 보내 서버와 잘 통신되는지 확인한 후 본 요청을 보낸다. 즉, 예비 요청의 역할은 본 요청을 보내기 전에 브라우저 스스로 안전한 요청인지 미리 확인하는 것이다.

 

이때 브라우저가 예비요청을 보내는 것을 Preflight라고 부르며, 이 예비요청의 HTTP 메서드를 GET이나 POST가 아닌 OPTIONS라는 요청이 사용된다는 것이 특징이다.

 

예를 들어 다음 api 요청을 보내는 경우,

1) 자바스크립트의 fetch() 메서드를 통해 리소스를 받아오려고 한다.

 

2) 브라우저는 서버로 HTTP OPTIONS 메서드로 예비 요청(Preflight)을 먼저 보낸다.

    ◈ Origin 헤더에 자신의 출처를 넣는다.

    ◈ Access-Control-Request-Method 헤더에 실제 요청에 사용할 메서드를 설정한다.

    ◈ Access-Control-Request-Headers 헤더에 실제 요청에 사용할 헤더들을 설정한다.

 

3) 서버는 이 예비 요청에 대한 응답으로 어떤 것을 허용하고 어떤 것을 금지하고 있는지에 대한 헤더 정보를 담아서 브라우저로 보내준다.

    ◈ Access-Control-Allow-Origin 헤더에 허용되는 Origin들의 목록을 설정한다.

    ◈ Access-Control-Allow-Methods 헤더에 허용되는 메서드들의 목록을 설정한다.

    ◈ Access-Control-Allow-Headers 헤더에 허용되는 헤더들의 목록을 설정한다.

    ◈ Access-Control-Max-Age 헤더에 해당 예비 요청이 브라우저에 캐시 될 수 있는 시간을 초 단위로 설정한다.

 

4) 이후 브라우저는 보낸 요청과 서버가 응답해 준 정책을 비교하여, 해당 요청이 안전한지 확인하고 본 요청을 보내게 된다.

 

5) 서버가 본 요청에 대한 응답을 하면 최종적으로 이 응답 데이터를 자바스립트로 넘겨준다.

🖋  Preflight 요청이 필요한 이유는 무엇일까?


위에서 이야기한 것처럼 CORS는 서버가 아닌 브라우저 구현 스펙에 포함된 정책이다. 따라서 서버는 CORS 위반 여부와 상관없이 일단 요청이 들어오면 처리를 하고 응답을 보낸다. 그 응답을 받은 브라우저가 응답 헤더를 확인하고 응답의 파기 여부를 결정하게 된다.

 

GET과 HEAD와 같은 요청은 단순 조회를 하기 때문에 상관없지만, POSTPUTDELETE와 같은 메서드는 서버에 부작용 (Side Effect)을 야기할 수 있다. 이는 응답이 유효하지 않아 파기한 브라우저의 의사와 상관없이 발생한다.

 

Preflight는 실제 요청이 CORS를 위반하지 않았는지를 미리 확인하고, 부작용으로부터 서버를 보호하기 위해 전송한다. 하지만 POST와 같은 경우 조건만 만족하면 Preflight 요청 대신 단순 요청으로 전송될 수 있으므로 백엔드에서도 이에 대한 처리가 필요하다.

참고로 Preflight 요청은 브라우저에서 자동으로 전송되므로 프론트엔드 개발자가 직접 보낼 필요는 없다. 개발을 하면서 Postman을 사용하면 정상적으로 요청이 되는데, 웹브라우저에서 호출하기만 하면 CORS가 발생하는 경우를 겪어봤을 것이다. 브라우저와 다르게 Postman과 같은 API 테스팅 도구에서는 기본적으로 Preflight를 보내지 않기 때문이다.

 

✔️ 개발자 도구에서 예비 요청 확인하기


위의 플로우는 브라우저의 개발자 도구의 네트워크 탭을 통해 간단히 재현이 가능하다.

실제로 자바스크립트 코드로 api 요청을 보내면, 크롬 개발자 도구에서 클라이언트와 서버가 본 요청(xhr)을 보내기 전에 예비 요청(preflight) 통신을 하고 있는 것을 볼 수 있다.

await fetch("<http://localhost:4000/users/location-registration>", {"method":"DELETE"})

preflight요청이 먼저 서버에 전달됨을 알 수 있다.

위의 사진상에는 요청 헤더의 Origin과 응답 헤더의 Access-Control-Allow-Origin의 URL값이 서로 같아 다른 출처라도 CORS(다른 출처 리소스 공유)가 허용돼서 정상 응답을 받게 된다.

 

만일 이 둘이 다르게 되면 브라우저는 이 요청이 CORS 정책을 위반했다고 판단하고 악명 높은 에러를 내뱉게 되는 것이다.

 

⏰ 예비 요청의 문제점과 캐싱


요청을 보내기 전에 OPTIONS 메서드로 예비 요청을 보내 보안을 강화하는 목적의 취지는 좋다. 그러나 결국은 실제 요청에 걸리는 시간이 늘어나게 되어 애플리케이션 성능에 영향을 미치는 크나큰 단점이 있다.

 

특히 수행하는 API 호출 수가 많으면 많을수록 예비 요청으로 인해 서버 요청을 배로 보내게 되니 비용 적인 측면에서 폐가 될 수 있다. 따라서 브라우저 캐시(Cache) Visit Website를 이용해 Access-Control-Max-Age 헤더에 캐시 될 시간을 명시해 주면, 이 Preflight 요청을 캐싱시켜 최적화를 시켜줄 수 있다.

 

600초 동안 예비 요청을 캐싱

예비 요청 캐시는 다른 캐싱 메커니즘과 유사하게 작동한다.

  1. 브라우저는 예비(Preflight) 요청을 할 때마다, 먼저 Preflight 캐시를 확인하여 해당 요청에 대한 응답이 있는지 확인한다.
  2. 만일 응답이 캐싱 되어 있지 않다면, 서버에 예비 요청을 보내 인증 절차를 밟는다.
  3. 만일 서버로 부터 Access-Control-Max-Age 응답 헤더를 받는다면 그 기간 동안 브라우저 캐시에 결과를 저장한다.
  4. 다시 요청을 보내고 만일 응답이 캐싱 되어 있다면, 예비 요청을 서버로 보내지 않고 대신 캐시된 응답을 사용한다.

⛓ 3. 인증된 요청 (Credentialed Request)


쿠키, 토큰과 같이 사용자 식별 정보가 담긴 요청에 대해서는 조금 더 엄격하게 처리한다. 클라이언트는 요청을 보낼 때 credentials 옵션을 별도로 설정해줘야 한다. fetch API의 경우 아래와 같은 3가지 옵션이 존재한다.

  1. same-origin : 같은 출처 간 요청에만 인증 정보를 담을 수 있다.
  2. include : 모든 요청에 인증 정보를 담을 수 있다.
  3. omit : 모든 요청에 인증 정보를 담지 않는다.
fetch("<http://example.com/>", {
  method: "PUT",
  credentials: "include",
})

참고로 XMLHttpRequest 혹은 Axios를 사용할 경우 withCredentials 옵션을 true로 설정해 주면 된다.

 

서버는 응답할 때 Access-Control-Allow-Credentials라는 헤더를 true로 설정해줘야 한다. 이때, Access-Control-Allow-Origin 은 와일드카드가 될 수 없으며, 명확한 출처를 명시해줘야 한다.

 

1. 클라이언트에서 인증 정보를 보내도록 설정하기


기본적으로 브라우저가 제공하는 요청 API 들은 별도의 옵션 없이 브라우저의 쿠키와 같은 인증과 관련된 데이터를 함부로 요청 데이터에 담지 않도록 되어있다.

 

이때 요청에 인증과 관련된 정보를 담을 수 있게 해주는 옵션이 바로 credentials 옵션이다. 이 옵션에는 3가지의 값을 사용할 수 있으며, 각 값들이 가지는 의미는 아래와 같다.

 

same-origin(기본값) 같은 출처 간 요청에만 인증 정보를 담을 수 있다.
include 모든 요청에 인증 정보를 담을 수 있다.
omit 모든 요청에 인증 정보를 담지 않는다.

만일 이러한 별도의 설정을 해주지 않으면 쿠키 등의 인증 정보는 절대로 자동으로 서버에게 전송되지 않는다.

서버에 인증된 요청을 보내는 방법으로는 fetch 메서드를 사용하거나 axios, jQuery 라이브러리 등 다양하다. 어떤 메서드를 사용하느냐에 따라 약간 credentials 옵션을 지정하는 문법이 다르니 이들을 모두 소개해 본다.

// fetch 메서드
fetch("<https://example.com:1234/users/login>", {
	method: "POST",
	credentials: "include", // 클라이언트와 서버가 통신할때 쿠키와 같은 인증 정보 값을 공유하겠다는 설정
    body: JSON.stringify({
        userId: 1,
    }),
})
// axios 라이브러리
axios.post('<https://example.com:1234/users/login>', { 
    profile: { username: username, password: password } 
}, { 
	withCredentials: true // 클라이언트와 서버가 통신할때 쿠키와 같은 인증 정보 값을 공유하겠다는 설정
})
// jQuery 라이브러리
$.ajax({
	url: "<https://example.com:1234/users/login>",
	type: "POST",
	contentType: "application/json; charset=utf-8",
	dataType: "json",		
	xhrFields: { 
    	withCredentials: true // 클라이언트와 서버가 통신할때 쿠키와 같은 인증 정보 값을 공유하겠다는 설정
    },
	success: function (retval, textStatus) {
		console.log( JSON.stringify(retval));
	}
});

 

2. 서버에서 인증된 요청에 대한 헤더 설정하기


서버도 마찬가지로 이러한 인증된 요청에 대해 일반적인 CORS 요청과는 다르게 대응해줘야 한다.

  1. 응답 헤더의 Access-Control-Allow-Credentials 항목을 true로 설정해야 한다.
  2. 응답 헤더의 Access-Control-Allow-Origin 의 값에 와일드카드 문자("*")는 사용할 수 없다.
  3. 응답 헤더의 Access-Control-Allow-Methods 의 값에 와일드카드 문자("*")는 사용할 수 없다.
  4. 응답 헤더의 Access-Control-Allow-Headers 의 값에 와일드카드 문자("*")는 사용할 수 없다.

즉, 응답의 Access-Control-Allow-Origin 헤더가 와일드카드(*)가 아닌 분명한 Origin으로 설정되어야 하고, Access-Control-Allow-Credentials 헤더는 true로 설정되어야 한다는 뜻이다. 그렇지 않으면 브라우저의 CORS 정책에 의해 응답이 거부된다. (인증 정보는 민감한 정보이기 때문에 출처를 정확하게 설정해주어야 한다)

 

만일 이를 어길 경우 아래와 같은 또 다른 종류의 CORS 에러 메시지를 접하게 된다.

 

Access-Control-Allow-Credentials 설정 안 했을 경우

Access-Control-Allow-Origin가 * 로 설정되어 있을 경우

위의 과정을 그림으로 나타내면 다음과 같다.

참고로 인증된 요청 역시 역시 예비 요청처럼 preflight가 먼저 일어난다. 위의 그림에서는 단순 GET 요청이기 때문에 예비 요청은 생략되었다.

⚙️ CORS 해결 방법


🖋 서버에서 Access-Control-Allow-Origin 헤더 세팅하기


직접 서버에서 HTTP 헤더 설정을 통해 출처를 허용하게 설정하는 가장 정석적인 해결책이다.

서버의 종류도 노드 서버, 스프링 서버, 아파치 서버 등 여러 가지가 있으니, 이에 대한 각각 해결책을 나열해 본다.

각 서버의 문법에 맞게 위의 HTTP 헤더를 추가해 주면 된다.

 

참고로 CORS에 연관된 HTTP 헤더 값으로는 다음 종류가 있다. (이들을 모두 설정할 필요는 없다)

# 헤더에 작성된 출처만 브라우저가 리소스를 접근할 수 있도록 허용함.
Access-Control-Allow-Origin : <https://naver.com>

# 리소스 접근을 허용하는 HTTP 메서드를 지정해 주는 헤더
Access-Control-Request-Methods : GET, POST, PUT, DELETE

# 요청을 허용하는 해더.
Access-Control-Allow-Headers : Origin,Accept,X-Requested-With,Content-Type,Access-Control-Request-Method,Access-Control-Request-Headers,Authorization

# 클라이언트에서 preflight 의 요청 결과를 저장할 기간을 지정
# 60초 동안 preflight 요청을 캐시하는 설정으로, 첫 요청 이후 60초 동안은 OPTIONS 메소드를 사용하는 예비 요청을 보내지 않는다.
Access-Control-Max-Age : 60

# 클라이언트 요청이 쿠키를 통해서 자격 증명을 해야 하는 경우에 true. 
# 자바스크립트 요청에서 credentials가 include일 때 요청에 대한 응답을 할 수 있는지를 나타낸다.
Access-Control-Allow-Credentials : true

# 기본적으로 브라우저에게 노출이 되지 않지만, 브라우저 측에서 접근할 수 있게 허용해주는 헤더를 지정
Access-Control-Expose-Headers : Content-Length

Node.js 세팅


서버에 response 헤더(Header) 값으로 Access-Control 설정을 해준다.

var http = require('http');

const PORT = process.env.PORT || 3000;

var httpServer = http.createServer(function (request, response) {
    // Setting up Headers
    response.setHeader('Access-Control-Allow-origin', '*'); // 모든 출처(orogin)을 허용
    response.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS, PUT, PATCH, DELETE'); // 모든 HTTP 메서드 허용
    response.setHeader('Access-Control-Allow-Credentials', 'true'); // 클라이언트와 서버 간에 쿠키 주고받기 허용

    // ...

    response.writeHead(200, { 'Content-Type': 'text/plain' });
    response.end('ok');
});

httpServer.listen(PORT, () => {
    console.log('Server is running at port 3000...');
});

이때 Access-Control-Allow-origin 헤더 값으로 * 을 사용하면 모든 Origin에서 오는 요청을 허용한다는 의미이므로 당장은 편할 수 있겠지만, 바꿔서 생각하면 정체도 모르는 이상한 출처에서 오는 요청까지 모두 허용하기 때문에 보안은 더 허술해진다. 그러니 가급적이면 귀찮더라도 다음과 같이 출처를 직접 명시해 주도록 하자.

response.setHeader('Access-Control-Allow-origin', '<https://inpa.tistory.com>');

Express.js 세팅


> npm i corsCopy
const express = require('express')
const cors = require("cors"); // cors 설정을 편안하게 하는 패키지
const app = express();

// ...

app.use(cors({
    origin: "<https://naver.com>", // 접근 권한을 부여하는 도메인
    credentials: true, // 응답 헤더에 Access-Control-Allow-Credentials 추가
    optionsSuccessStatus: 200, // 응답 상태 200으로 설정
}));

// ...

Spring 세팅


// 스프링 서버 전역적으로 CORS 설정
@Configuration
public class WebConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
        	.allowedOrigins("<http://localhost:8080>", "<http://localhost:8081>") // 허용할 출처
            .allowedMethods("GET", "POST") // 허용할 HTTP method
            .allowCredentials(true) // 쿠키 인증 요청 허용
            .maxAge(3000) // 원하는 시간만큼 pre-flight 리퀘스트를 캐싱
    }
}
// 특정 컨트롤러에만 CORS 적용하고 싶을때.
@Controller
@CrossOrigin(origins = "*", methods = RequestMethod.GET) 
public class customController {

	// 특정 메소드에만 CORS 적용 가능
    @GetMapping("/url")  
    @CrossOrigin(origins = "*", methods = RequestMethod.GET) 
    @ResponseBody
    public List<Object> findAll(){
        return service.getAll();
    }
}

Nginx


nginx.conf 파일 안에 location / 부분에 add_header 값으로 헤더 설정을 추가

location / {
    root html;
    add_header 'Access-Control-Allow-Origin' '*';
    index  index.html index.htm;
}

AWS (S3 호스팅)


  1. S3 콘솔 메뉴에 들어가 버킷을 선택한다.
  2. 권한(Permissions) 탭을 선택한다.
  3. 교차 출처 리소스 공유 창에서 [편집] 선택한다.
  4. 텍스트 상자에 아래 JSON CORS 규칙을 입력한다.
[
  {
    "AllowedHeaders": [
      "Authorization"
    ],
    "AllowedMethods": [
      "GET",
      "HEAD"
    ],
    "AllowedOrigins": [
      "<http://www.example.com>"
    ],
    "ExposeHeaders": [
      "Access-Control-Allow-Origin"
    ]
  }
]
 

📸 참조


https://inpa.tistory.com/entry/WEB-%F0%9F%93%9A-CORS-%F0%9F%92%AF-%EC%A0%95%EB%A6%AC-%ED%95%B4%EA%B2%B0-%EB%B0%A9%EB%B2%95-%F0%9F%91%8F

https://developer.mozilla.org/ko/docs/Web/HTTP/CORS