이 포스트는 2024.05.28에 작성되었습니다.
이전에 프론트엔드와 api통신을 하면서 cors 에러를 많이 만났다.
분명 포스트맨, swagger로는 잘 동작하던 api가 프론트와 통신하면 헤더값이 이상하게 들어오기도 하고, 프론트엔드에서 이상한 Options 메서드의 preflight 요청을 보내기도 했었다. 너무 스트레스를 받아서 예전 블로그 글에 cors에 대해 정리한 적도 있었다.
사실 프론트엔드 지식이 없었던 시절에는 cors를 테스트할 방법이 생각나지 않아 여러 상황을 대비해 configuration도 만들고, controller 상단에도 @CrossOrigin을 주렁주렁 달았었다. 하지만 이제는 프론트지식이 생겼으니 상황을 직접 테스트할 수 있게 되었다!
이번 기회에 확실하게 정리하고 가려고 한다.
(짤이 재밌어서 주워왔다. 이미지 출처 블로그 )
CORS에 대해 알아보기 전, CORS가 나오게 된 이유를 먼저 알아보자!
CORS가 나오게된 건 SOP와 관련이 깊다.
SOP는 한 출처(Origin)에서 로드한 문서나 스크립트가 다른 출처의 리소스와 상호 작용할 수 있는 방식을 제한하는 중요한 보안 메커니즘 (MDN)
SOP는 RFC 6454에서 처음 등장한 보안 정책이다. 이 정책은 Cross-Origin, 즉 다른 출처에서 리소스를 요청하는 것을 제한한다.
origin은 출처라는 뜻이다. 여기서 말하는 origin은 URL에서 도메인만 뜻하는 게 아니라 프로토콜과 포트까지 모두 포함하는 개념이다. 즉, origin 구성요소는 3가지이다.
https
naver.com
80
https://naver.com
+) 인터넷 익스플로러는 port가 달라도 같은 출처로 인식됨
동일 출처 정책은 URI로 신뢰를 지정합니다. (RFC 6454)
cross-origin은 origin이 다른 것을 말한다. 예를 들어 http://localhost
라는 url이 있으면 Same-Origin과 Cross-Origin을 다음과 같다.
url | origin | 이유 |
---|---|---|
http://localhost:80 | Same-Origin | |
http://localhost/api/cors | Same-Origin | |
http://localhost:8080 | Cross-Origin | 포트가 다름 |
http://127.0.0.1 | Cross-Origin | 브라우저는 string value 자체를 비교하기 때문 |
https://localhost | Cross-Origin | https와 http는 다름 |
브라우저는 보안상의 이유로 scripts에서 시작된 cross-origin HTTP 요청을 제한한다.
다음은 SOP가 없을 때 발생할 수 있는 보안 취약점의 예시이다.
http://hacker.comm
주소로 접속이전에 JWT토큰을 보관하는 방법에 대해 정리하면서 CSRF
에 대해 언급한 적이 있다. 위 예시는 CSRF의 대표적인 예시라고 볼 수 있다. 사용자가 다른 사이트에 인증되어있는 상태라는 점을 이용해 원하는 요청을 보낸다.
이 때 SOP를 사용한다면 결과는 달라진다. facebook은 요청의 origin을 확인하여 facebook이 아닌 것을 알아낸다. 같은 출처에서 보낸 요청이 아니기 때문에 SOP를 위반하였므로, 이 요청을 받아들이지 않는다.
SOP를 통해 악의적인 사이트가 다른 사이트의 중요한 데이터를 읽을 수 없게하여 CSRF등 잠재적인 공격위험을 방지할 수 있다.
(SOP덕분에 리소스를 안전하게 주고받을 수 있는 것이기 때문에 CORS에러를 너무 미워하지 말자!)
무조건 같은 출처의 요청만 허용하는 건 한계가 있고, 다른 출처의 리소스가 필요한 상황도 존재한다! 가장 쉬운 예시는 프론트 서버에서 백엔드 서버에 api요청을 보낼 때, 둘이 port나 도메인이 다른 경우이다.
이 때는 CORS 설정
을 통해 출처가 다른 서버 간 리소스를 공유할 수 있다.
CORS(Cross-Origin Resource Sharing)는 브라우저가 리소스 로드를 허용해야 하는 자체 Origin이 아닌 모든 Origin(도메인, scheme or 포트)을 서버가 허용할 수 있도록 하는 HTTP 헤더 기반 메커니즘 (MDN)
CORS는 추가 HTTP 헤더를 사용하여, 한 출처에서 실행 중인 웹 애플리케이션이 다른 출처의 자원에 접근할 수 있는 권한을 부여하도록 브라우저에 알려주는 체제이다.
SOP에서는 같은 Origin이 아닌 요청을 모두 막아버리는데, 서버에서 CORS설정을 통해 다른 Origin에서 리소스 요청, 응답을 허용할 수 있도록 한다. 허용된 origin들이 보낸 요청만 성공적으로 서버에 전달된다.
여기서 포인트는 권한을 서버에서 => 브라우저로 알려준다는 것이다!
Cross-Origin의 API를 사용하는 웹 애플리케이션은 서버 응답에 올바른 CORS 헤더가 포함되지 않는 한 동일한 출처에서만 리소스를 요청할 수 있다.
CORS 정책은 브라우저가 생길 때 부터 있었던 게 아니다.
이전에는 프론트엔드와 백엔드를 따로 구성하지 않고, 한 번에 구성하여 모든 처리가 같은 도메인 안에서 가능하게 했었다. 그래서 다른 출처로 리소스 요청을 보내거나 응답받을 일이 없었고, 다른 출처로 요청을 보내는 게 의심스러운 행위로 보일 수밖에 없었다.
하지만 시간이 지나 프론트엔드와 백엔드가 API를 통해 통신하는 방식이 당연해지기 시작하였는데, 이 경우 다른 도메인에 있는 경우가 많다. 그래서 CORS 정책이 추가되었다고 한다!
(CORS 정책이 뒤늦게 등장하였기 때문에 아직 cors에 대한 설정이 없는 서버가 있을 수 있다고 함. 이런 서버들은 보안문제가 생길 수 있는데, 이는 preflight 설명에서 더 자세히 언급하겠다!)
CORS 실패로 인해 오류가 발생해도 보안상의 이유로 JavaScript에서는 오류에 대한 세부 사항을 사용할 수 없다. 이를 확인하려면 브라우저 콘솔에서 확인해야한다
다른 출처로의 요청은 브라우저가 요청 헤더에 Origin
을 함께 보내면서 시작된다.
Origin: http://localhost:8080
서버는 이 요청에 대한 응답을 할 때 Access-Control-Allow-Origin
헤더에 리소스 호출이 허용된 출처(Origin)임을 명시해준다.
이후 응답을 받은 브라우저는 자신이 보냈던 요청의 Origin과 서버가 보내준 응답의 Access-Control-Allow-Origin
을 비교해본 후 이 응답이 유효한 응답인지 아닌지를 결정한다. 유효하지 않을 경우 CORS 에러를 띄운다.
✨ 여기서 중요한 사실은 CORS에러를 띄우는 로직은 브라우저에 구현되어 있는 스펙이라는 것이다
만약 CORS정책을 위반하는 요청을 서버에 보내더라도, 서버는 정상적으로 로직을 처리 후 응답을 보낼 수 있다. 그리고 그 응답을 받은 브라우저는 서버의 응답을 분석하여 CORS 정책이 위반되었는지를 판단하고, 위반하였다면 응답을 사용하지 않고 그냥 버린다.
(200ok 인데 CORS에러가 난 상황)이 경우 CORS 정책을 위반하는 요청이 서버의 리소스를 변경할 수 있다는 위험을 가지고있다. 그래서 브라우저는 이런 위험을 방지하기 위해 preflight
를 사용한다.
preflight 요청은 CORS 프로토콜이 understood 되었는지를 체크하기 위한 CORS request이다.
서버 데이터에 side-effects을 일으킬 수 있는 HTTP 요청 방법(특히 GET 이외의 HTTP 요청 메서드 또는 특정 MIME 유형의 POST)의 경우에는 브라우저가 요청을 preflight
하여 서버에서 지원되는 방법을 요청한다.
preflight
요청은 HTTP OPTIONS
method을 사용하고, 응답을 통해 서버의 승인(200, 204)을 받은 뒤 실제 요청을 보낸다.
OPTIONS /test HTTP/1.1
Access-Control-Request-Headers: authorization
Access-Control-Request-Method: GET
Origin: http://localhost:8080
Access-Control-Request-Method
: 동일한 리소스에 대한 향후 실제 request가 사용할 수 있는 메서드Access-Control-Request-Headers
: 동일한 리소스에 대한 실제 request가 사용할 수 있는 헤더+) 그렇다고 브라우저가 모든 요청에 대해 preflight를 날리진 않는다. 일부 조건에 맞는 요청은 preflight 없이 바로 실제요청을 보낸다. (GET, POST, HEAD 메서드만 가능) 이렇게 preflight를 먼저 날리지 않는 요청을 Simple Request라고 부른다.
Simple Request 는 요청을 날리면 서버가 cross-origin인지 확인 후 바로 cors에러를 내보내는 동작을 한다.
CORS를 모르는 서버를 위해서라고 한다.
CORS spec이 생기기 이전에 만들어진 서버들은 브라우저의 SOP request만 가능하다는 가정하에 만들어졌는데, cross-site request가 CORS로 인해서 가능해졌기 떄문에 이런 서버들은 cross-site request에 대한 security mechanism이 없다.
이로 인해 보안적으로 문제가 생길수 있는데, 이런 서버들을 보호하기 위해 CORS spec에 preflight request를 사용한다. 서버가 CORS를 인식하고 핸들할수있는지 먼저 확인을 함으로써 CORS를 인식하지 못하는 서버들을 보호할 수 있다.
preflight를 모르는 서버에서 발생할 수 있는 보안적 문제는 다음과 같다.
이미 서버의 리소스는 변경되었다..
하지만 여기 preflight가 있다면 다음과 같이 문제를 해결할 수 있다.
서버는 요청과 함께 자격 증명(쿠키 및 HTTP 인증)을 보내야 하는지 여부를 클라이언트에게 알릴 수 있다.
Access-Control-Allow-Credentials
헤더의 경우 요청의 credentials mode가 "include"일 때 응답을 공유할 수 있는지 여부를 나타낸다. 서버에서 설정해줄 수 있다
기본적으로 브라우저가 제공하는 비동기 리소스 요청 API인 XMLHttpRequest
객체나 fetch API
는 별도의 옵션 없이 브라우저의 쿠키 정보나 인증과 관련된 헤더를 함부로 요청에 담지 않는다. 이때 요청에 인증과 관련된 정보를 담을 수 있게 해주는 옵션이 바로 credentials 옵션이다.
same-origin
, include
, omit
3가지 값을 credentials에 담을 수 있다. 기본은 same-origin이다.
same-origin
: 같은 출처 간 요청에만 인증 정보를 담을 수 있음include
: 모든 요청에 인증 정보를 담을 수 있음omit
: 모든 요청에 인증 정보를 담지 않음same-origin이나 include를 사용할 경우, 브라우저는 조금 더 빡빡한 검사조건이 추가된다.
Access-Control-Allow-Origin
헤더에 *
를 사용하면 안된다.Access-Control-Allow-Credentials: true
가 존재해야한다.+) preflight요청의 경우 요청의 credentials mode는 항상 same-origin
인데, 후속 요청의 경우 그렇지 않을 수 있다. 따라서 Access-Control-Allow-Credentials
헤더는 preflight 요청에 대한 HTTP 응답의 일부로 표시되어한다
위에서 설명했듯, 브라우저는 서버에게 실제 요청을 보내기 전 preflight 요청을 통해 확인 절차를 가진다. 즉, 실제 요청을 보내기 위해서는 2번의 요청이 나간다.
preflight 요청은 OPTIONS 메서드를 통해 요청이 가능한지 확인하고, 200번대 응답이 와야 실제 요청을 보낸다.
OPTIONS /test HTTP/1.1
Access-Control-Request-Headers: authorization
Access-Control-Request-Method: POST
Origin: http://localhost:8080
Access-Control-Request-Headers
Access-Control-Request-Method
Origin
(아래의 응답은 직접 만들어준 응답이 아니고, spring이 알아서 만들어준 응답이다)
🎉 성공 시
HTTP/1.1 200
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Access-Control-Allow-Origin: http://localhost:8080
Access-Control-Allow-Methods: GET,POST,PUT,DELETE,HEAD,OPTIONS
Access-Control-Allow-Headers: content-type
Access-Control-Allow-Credentials: true
Access-Control-Max-Age: 1800
Allow: GET, HEAD, POST, PUT, DELETE, OPTIONS, PATCH
Content-Length: 0
Keep-Alive: timeout=60
Connection: keep-alive
Access-Control-Allow-Methods
헤더에는 서버가 하용한 메서드 종류Access-Control-Allow-Headers
에는 실제요청이 포함하고 갈 헤더Access-Control-Allow-Credentials
값이 trueAccess-Control-Max-Age
는 Allow Method와 Header 정보를 캐시할 수 있는 시간 (기본 5초)🎃 실패 시
HTTP/1.1 403
Vary: Origin
Vary: Access-Control-Request-Method
Vary: Access-Control-Request-Headers
Transfer-Encoding: chunked
Keep-Alive: timeout=60
Connection: keep-alive
status가 403으로 날아온다.
비교)
여러 번 요청을 보내다보면 preflight를 보내지 않고 바로 요청을 보내는 걸 볼 수 있는데, 이는 preflight 요청이 캐싱되기 때문이다. preflight를 매번 보내면 리소스적으로 좋지 않기 때문에 일정 기간동안 캐싱해두는 거라고 보면된다.
캐싱 기간은 서버에서 Access-Control-Max-Age
헤더를 통해 지정해줄 수 있다.
인증 관련 헤더를 포함할 때 사용하는 요청이다. 쿠키나 jwt 토큰을 보내는 헤더(Authorization)는 기본적으로 차단이 되는데, 쿠키나 토큰을 서버로 보내고 싶을 때 사용한다.
🧨 +) 이 때 주의할 점은 서버에서 Access-Control-Allow-Credentials을 *
(와일드 카드)로 주면 안된다. 이 경우 에러가 발생한다!
const response = await axios.post('http://localhost:8083/login', {
id: this.username,
password: this.password
},
{
headers: {
'Content-Type': 'application/json'
}
});
if (response.status == 200) {
const { accessToken } = response.data;
axios.defaults.headers.common['Authorization'] = `Bearer ${accessToken}`;
this.$router.push('/');
}
//------------------------------------------------
const response = await axios.get('http://localhost:8083/test')
.then(response => {
console.log(response);
this.servermsg = response.data;
})
@CrossOrigin(
origins = "http://localhost:8080",
// allowCredentials = "false",
allowedHeaders = "*",
methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT,
RequestMethod.DELETE, RequestMethod.HEAD, RequestMethod.OPTIONS})
@RestController
public class JwtController {
}
withCredentials
옵션이 사용되지 않음)access-control-request-headers : authorization
가 포함된 다른 preflight 요청에서 403이 발생하는 걸 볼 수 있다.axios.defaults.withCredentials = true; //클라이언트
allowCredentials = "true", //서버(@CrossOrigin 내부)
서버와 클라이언트 모두 한줄만 추가해주면 된다.
Authorization 헤더를 포함한 preflight 요청에 ok응답이 오는 걸 볼 수 있다.
응답헤더를 보면 Access-Control-Allow-Credentials: true가 추가되어 온 것도 볼 수 있다.
CORS 정책을 지정해주기 위한 방법은 3가지가 있다.
1. 프론트 프록시 서버 설정
2. 서버에서 직접 헤더에 설정
3. 스프링 부트의 기능을 사용
웹 애플리케이션이 리소스를 직접적으로 요청하는 대신, 프론트엔드에서 프록시 서버를 사용하여 서버에게 리소스 요청을 전달하는 방법
이 방법을 사용하면, 웹 애플리케이션이 리소스와 동일한 출처에서 요청을 보내는 것처럼 보이므로 CORS 에러를 방지할 수 있다.
`http://example.com`라는 주소의 웹 애플리케이션이
`http://api.example.com`라는 리소스에서 데이터를 요청하는 상황
1) 웹 애플리케이션은 직접적으로 리소스에 요청하는 대신, http://example-proxy.com
라는 프록시 서버에 요청을 보낸다. 그러면 프록시 서버가 http://api.example.com
으로 요청을 전달하고, 응답을 다시 웹 애플리케이션에 반환한다. 이렇게 하면 브라우저 입장에서는 요청이 http://example-proxy.com
에서 보내진 것처럼 보인다.
2) 프론트엔드 서버는 /api에 대한 요청일 경우 port를 8080으로 바꿔 백엔드 서버에 요청을 보낸다. 백엔드 서버에서는 같은 origin에서 온 요청이라 올바르게 처리된다.
서버에서 Allow Header, Method 등등을 다 설정해주는 방법이다.
Access-Control-Allow-Origin
헤더를 사용하여 요청을 수락할 출처를 명시적으로 저장할 수 있다.
예시
'Access-Control-Allow-Origin': <origin> | *
'Access-Control-Allow-Origin': https://myshop.com
첫 번째와 같이 설정하면 출처에 상관없이 리소스에 접근이 가능하기 때문에 보안이 취약해진다. 아래와 같이 직접 허용할 출처를 명시해주는 방법이 더 좋다.
그 외에도 허용된 메서드를 지정하고, credentials를 true로 설정할 수 있다.
첫 번째 방법은 class마다 @CrossOrigin
어노테이션을 붙여주는 방법이다.
@CrossOrigin(
origins = "http://localhost:8080",
allowCredentials = "true",
allowedHeaders = "*",
methods = {RequestMethod.GET, RequestMethod.POST, RequestMethod.PUT,
RequestMethod.DELETE, RequestMethod.HEAD, RequestMethod.OPTIONS})
@RestController
public class MemberController {
}
*
로 주면 에러가 발생할 수 있으니 주의해야한다.이 방법은 class마다 모두 어노테이션을 붙여야하기 때문에 번거로울 수 있다. 그래서 전역적으로 configureation을 설정해주는 방법이 있다.
@Bean
public WebMvcConfigurer corsConfigurer() {
return new WebMvcConfigurer() {
@Override
public void addCorsMappings(CorsRegistry registry) {
registry.addMapping("/**")
.allowedOrigins("http://localhost:8080")
.allowCredentials(true)
.allowedMethods("GET", "POST", "PUT", "DELETE");
}
};
}
프론트엔드 지식이 있으니 직접 테스트해볼 수 있어서 더 깊게 이해할 수 있었던 것 같다. 앞으로는 CORS를 두려워하지 않게 될 것 같다.