CORS 알아보기

mylime·2024년 5월 29일
0
post-thumbnail

이 포스트는 2024.05.28에 작성되었습니다.



이전에 프론트엔드와 api통신을 하면서 cors 에러를 많이 만났다.
분명 포스트맨, swagger로는 잘 동작하던 api가 프론트와 통신하면 헤더값이 이상하게 들어오기도 하고, 프론트엔드에서 이상한 Options 메서드의 preflight 요청을 보내기도 했었다. 너무 스트레스를 받아서 예전 블로그 글에 cors에 대해 정리한 적도 있었다.

사실 프론트엔드 지식이 없었던 시절에는 cors를 테스트할 방법이 생각나지 않아 여러 상황을 대비해 configuration도 만들고, controller 상단에도 @CrossOrigin을 주렁주렁 달았었다. 하지만 이제는 프론트지식이 생겼으니 상황을 직접 테스트할 수 있게 되었다!

이번 기회에 확실하게 정리하고 가려고 한다.


(짤이 재밌어서 주워왔다. 이미지 출처 블로그 )



SOP(Same Origin Policy)


CORS에 대해 알아보기 전, CORS가 나오게 된 이유를 먼저 알아보자!
CORS가 나오게된 건 SOP와 관련이 깊다.

SOP는 한 출처(Origin)에서 로드한 문서나 스크립트가 다른 출처의 리소스와 상호 작용할 수 있는 방식을 제한하는 중요한 보안 메커니즘 (MDN)

SOP는 RFC 6454에서 처음 등장한 보안 정책이다. 이 정책은 Cross-Origin, 즉 다른 출처에서 리소스를 요청하는 것을 제한한다.


🧐 Origin이란?

origin은 출처라는 뜻이다. 여기서 말하는 origin은 URL에서 도메인만 뜻하는 게 아니라 프로토콜과 포트까지 모두 포함하는 개념이다. 즉, origin 구성요소는 3가지이다.

  • 프로토콜: https
  • 도메인: naver.com
  • 포트: 80
  • origin: https://naver.com

+) 인터넷 익스플로러는 port가 달라도 같은 출처로 인식됨

동일 출처 정책은 URI로 신뢰를 지정합니다. (RFC 6454)


🧐 Cross-Origin의 예시

cross-origin은 origin이 다른 것을 말한다. 예를 들어 http://localhost 라는 url이 있으면 Same-Origin과 Cross-Origin을 다음과 같다.

urlorigin이유
http://localhost:80Same-Origin
http://localhost/api/corsSame-Origin
http://localhost:8080Cross-Origin포트가 다름
http://127.0.0.1Cross-Origin브라우저는 string value 자체를 비교하기 때문
https://localhostCross-Originhttps와 http는 다름

🤔 브라우저가 Cross-Origin을 제한하는 이유?

브라우저는 보안상의 이유로 scripts에서 시작된 cross-origin HTTP 요청을 제한한다.

다음은 SOP가 없을 때 발생할 수 있는 보안 취약점의 예시이다.

  1. 사용자가 facebook에 로그인, facebook 인증토큰을 받아옴
  2. 공격자가 흥미진진한 내용과 링크를 메일을 통해 보냄
  3. 사용자가 링크를 클릭, http://hacker.comm주소로 접속
  4. 해당 주소에는 페이스북에 포스트 등록하는 스크립트가 작성되어있음 -> 링크 타고 들어간것만으로 스크립트 내용 실행됨
  5. 페이스북 인증토큰이 있는 상태이므로, 포스트가 게시 됨

이전에 JWT토큰을 보관하는 방법에 대해 정리하면서 CSRF에 대해 언급한 적이 있다. 위 예시는 CSRF의 대표적인 예시라고 볼 수 있다. 사용자가 다른 사이트에 인증되어있는 상태라는 점을 이용해 원하는 요청을 보낸다.


이 때 SOP를 사용한다면 결과는 달라진다. facebook은 요청의 origin을 확인하여 facebook이 아닌 것을 알아낸다. 같은 출처에서 보낸 요청이 아니기 때문에 SOP를 위반하였므로, 이 요청을 받아들이지 않는다.

SOP를 통해 악의적인 사이트가 다른 사이트의 중요한 데이터를 읽을 수 없게하여 CSRF등 잠재적인 공격위험을 방지할 수 있다.

(SOP덕분에 리소스를 안전하게 주고받을 수 있는 것이기 때문에 CORS에러를 너무 미워하지 말자!)


🤔 Cross-Origin 요청을 받아야하는 경우도 있지않나?

무조건 같은 출처의 요청만 허용하는 건 한계가 있고, 다른 출처의 리소스가 필요한 상황도 존재한다! 가장 쉬운 예시는 프론트 서버에서 백엔드 서버에 api요청을 보낼 때, 둘이 port나 도메인이 다른 경우이다.

이 때는 CORS 설정을 통해 출처가 다른 서버 간 리소스를 공유할 수 있다.



CORS란?


CORS(Cross-Origin Resource Sharing)는 브라우저가 리소스 로드를 허용해야 하는 자체 Origin이 아닌 모든 Origin(도메인, scheme or 포트)을 서버가 허용할 수 있도록 하는 HTTP 헤더 기반 메커니즘 (MDN)

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

SOP에서는 같은 Origin이 아닌 요청을 모두 막아버리는데, 서버에서 CORS설정을 통해 다른 Origin에서 리소스 요청, 응답을 허용할 수 있도록 한다. 허용된 origin들이 보낸 요청만 성공적으로 서버에 전달된다.


여기서 포인트는 권한을 서버에서 => 브라우저로 알려준다는 것이다!

Cross-Origin의 API를 사용하는 웹 애플리케이션은 서버 응답에 올바른 CORS 헤더가 포함되지 않는 한 동일한 출처에서만 리소스를 요청할 수 있다.


🤔 CORS가 뒤늦게 나온 이유

CORS 정책은 브라우저가 생길 때 부터 있었던 게 아니다.

이전에는 프론트엔드와 백엔드를 따로 구성하지 않고, 한 번에 구성하여 모든 처리가 같은 도메인 안에서 가능하게 했었다. 그래서 다른 출처로 리소스 요청을 보내거나 응답받을 일이 없었고, 다른 출처로 요청을 보내는 게 의심스러운 행위로 보일 수밖에 없었다.

하지만 시간이 지나 프론트엔드와 백엔드가 API를 통해 통신하는 방식이 당연해지기 시작하였는데, 이 경우 다른 도메인에 있는 경우가 많다. 그래서 CORS 정책이 추가되었다고 한다!

(CORS 정책이 뒤늦게 등장하였기 때문에 아직 cors에 대한 설정이 없는 서버가 있을 수 있다고 함. 이런 서버들은 보안문제가 생길 수 있는데, 이는 preflight 설명에서 더 자세히 언급하겠다!)



CORS 발생 예시


클라이언트와 서버가 도메인이 다른 경우

  • vue와 spring으로 웹프로젝트를 만드는 중
  • 프론트엔드는 localhost:8080로 띄웠고, 백엔드는 localhost:8081로 띄운 상황
  • 둘은 다른 origin이기 때문에 프론트엔드에서 api요청을 보내면 cors에러가 발생한다

토스페이먼츠 결제창 예시

  • 결제 승인을 요청하고 인증이 완료되어 리다이렉트 될 때 CORS 발생
  • 이는 최초 결제를 시도한 값이 리다이렉트 URL과 다르기 때문이다
    • 결제 요청 시 파라미터로 최초에 결제창을 연 주소와 출처가 같은 리다이렉트 URL을 넣어야함

CORS 실패로 인해 오류가 발생해도 보안상의 이유로 JavaScript에서는 오류에 대한 세부 사항을 사용할 수 없다. 이를 확인하려면 브라우저 콘솔에서 확인해야한다



CORS 동작방식


다른 출처로의 요청은 브라우저가 요청 헤더에 Origin을 함께 보내면서 시작된다.
Origin: http://localhost:8080

서버는 이 요청에 대한 응답을 할 때 Access-Control-Allow-Origin 헤더에 리소스 호출이 허용된 출처(Origin)임을 명시해준다.

이후 응답을 받은 브라우저는 자신이 보냈던 요청의 Origin과 서버가 보내준 응답의 Access-Control-Allow-Origin을 비교해본 후 이 응답이 유효한 응답인지 아닌지를 결정한다. 유효하지 않을 경우 CORS 에러를 띄운다.


✨ 여기서 중요한 사실은 CORS에러를 띄우는 로직은 브라우저에 구현되어 있는 스펙이라는 것이다

만약 CORS정책을 위반하는 요청을 서버에 보내더라도, 서버는 정상적으로 로직을 처리 후 응답을 보낼 수 있다. 그리고 그 응답을 받은 브라우저는 서버의 응답을 분석하여 CORS 정책이 위반되었는지를 판단하고, 위반하였다면 응답을 사용하지 않고 그냥 버린다.

(200ok 인데 CORS에러가 난 상황)

이 경우 CORS 정책을 위반하는 요청이 서버의 리소스를 변경할 수 있다는 위험을 가지고있다. 그래서 브라우저는 이런 위험을 방지하기 위해 preflight를 사용한다.


🚀 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가 사용할 수 있는 헤더
  • 이 요청헤더를 통해 본 요청은 authorization헤더를 포함하고 있고, 요청 메서드는 GET이라는 걸 알 수 있다.

+) 그렇다고 브라우저가 모든 요청에 대해 preflight를 날리진 않는다. 일부 조건에 맞는 요청은 preflight 없이 바로 실제요청을 보낸다. (GET, POST, HEAD 메서드만 가능) 이렇게 preflight를 먼저 날리지 않는 요청을 Simple Request라고 부른다.

Simple Request 는 요청을 날리면 서버가 cross-origin인지 확인 후 바로 cors에러를 내보내는 동작을 한다.


🤔 preflight를 쓰는 이유?

CORS를 모르는 서버를 위해서라고 한다.

CORS spec이 생기기 이전에 만들어진 서버들은 브라우저의 SOP request만 가능하다는 가정하에 만들어졌는데, cross-site request가 CORS로 인해서 가능해졌기 떄문에 이런 서버들은 cross-site request에 대한 security mechanism이 없다.

이로 인해 보안적으로 문제가 생길수 있는데, 이런 서버들을 보호하기 위해 CORS spec에 preflight request를 사용한다. 서버가 CORS를 인식하고 핸들할수있는지 먼저 확인을 함으로써 CORS를 인식하지 못하는 서버들을 보호할 수 있다.


preflight를 모르는 서버에서 발생할 수 있는 보안적 문제는 다음과 같다.

  • 게시판에서 클라이언트가 서버로 글을 수정하는 Patch요청을 보냄
  • CORS설정 없는 서버는 요청을 받으면 해당 요청대로 일단 리소스 변경이 일어난다
  • 서버는 cors설정을 안함 -> 응답의 ALLOW-ORIGIN 헤더가 비어있음
    • 브라우저는 이를 확인하고 client에게 cors에러를 띄운다
  • 게시글이 변경되지 않아야 되는 상황이지만, 이미 서버의 리소스는 변경되었다..

하지만 여기 preflight가 있다면 다음과 같이 문제를 해결할 수 있다.

  • 게시판에서 클라이언트가 서버로 글을 수정하는 Patch요청을 보낼거임
  • 본 요청을 보내기 전, 먼저 client는 브라우저에게 preflight를 요청함
  • 서버는 cors요청이 없기 때문에 일단 preflight에 대한 응답을 보냄
    • 해당 응답에는 ALLOW-ORIGIN이 설정되어 있지 않기 때문에 브라우저는 client에게 cors에러임을 알림
  • 이 상황에는 서버는 리소스변경을 전혀 하지 않았기 때문에 안전하게 지켜짐

🗽 credentials

서버는 요청과 함께 자격 증명(쿠키 및 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를 사용할 경우, 브라우저는 조금 더 빡빡한 검사조건이 추가된다.

  • credentials가 include인 경우, Access-Control-Allow-Origin 헤더에 * 를 사용하면 안된다.
  • 응답 헤더에는 반드시 Access-Control-Allow-Credentials: true가 존재해야한다.

+) preflight요청의 경우 요청의 credentials mode는 항상 same-origin인데, 후속 요청의 경우 그렇지 않을 수 있다. 따라서 Access-Control-Allow-Credentials 헤더는 preflight 요청에 대한 HTTP 응답의 일부로 표시되어한다



실제 요청 뜯어보기


1. preflight

위에서 설명했듯, 브라우저는 서버에게 실제 요청을 보내기 전 preflight 요청을 통해 확인 절차를 가진다. 즉, 실제 요청을 보내기 위해서는 2번의 요청이 나간다.

preflight 요청은 OPTIONS 메서드를 통해 요청이 가능한지 확인하고, 200번대 응답이 와야 실제 요청을 보낸다.


preflight 요청형식

OPTIONS /test HTTP/1.1

Access-Control-Request-Headers: authorization
Access-Control-Request-Method: POST
Origin: http://localhost:8080
  • Access-Control-Request-Headers
    : 실제 요청에서 추가할 헤더 종류. 나는 jwt토큰 전송 시나리오를 위해 클라이언트에서 임의로 authorization 헤더를 넣어줬다.
  • Access-Control-Request-Method
    : 실제 요청할 메서드 형식
  • Origin
    : request를 보내는 origin(클라이언트 주소)

preflight 응답형식

(아래의 응답은 직접 만들어준 응답이 아니고, 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 에는 실제요청이 포함하고 갈 헤더
  • 서버에서 credentials도 허용해줘서 Access-Control-Allow-Credentials 값이 true
  • Access-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으로 날아온다.


비교)

  • Content-Length는 수신자에게 전송된 메시지 본문의 크기(바이트)를 나타낸다. 둘 다 0이므로, 성공하든 실패하든 응답 바디가 비워져있다는 걸 알 수 있다.

+) Preflight의 캐싱

여러 번 요청을 보내다보면 preflight를 보내지 않고 바로 요청을 보내는 걸 볼 수 있는데, 이는 preflight 요청이 캐싱되기 때문이다. preflight를 매번 보내면 리소스적으로 좋지 않기 때문에 일정 기간동안 캐싱해두는 거라고 보면된다.

캐싱 기간은 서버에서 Access-Control-Max-Age 헤더를 통해 지정해줄 수 있다.


2. Credentialed Request

인증 관련 헤더를 포함할 때 사용하는 요청이다. 쿠키나 jwt 토큰을 보내는 헤더(Authorization)는 기본적으로 차단이 되는데, 쿠키나 토큰을 서버로 보내고 싶을 때 사용한다.

  • 클라이언트 측에서는 credentials: include 설정을 해주고,
  • 서버측에서는 Access-Control-Allow-Credentials를 true로 설정해줘야한다

🧨 +) 이 때 주의할 점은 서버에서 Access-Control-Allow-Credentials을 *(와일드 카드)로 주면 안된다. 이 경우 에러가 발생한다!

1) credentials = false 예시

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 {

}
  • 서버에서는 allowCredentials를 false로 해주고 클라이언트는 다음과 같이 post 요청을 보낸다.
    (axios는 기본적으로 withCredentials 옵션이 사용되지 않음)

  • Authorization 헤더를 포함하지 않는 요청은 잘 처리되는 걸 볼 수 있다.

  • 하지만 access-control-request-headers : authorization 가 포함된 다른 preflight 요청에서 403이 발생하는 걸 볼 수 있다.
  • 이는 Authroization헤더가 기본적으로 차단되기 때문이다.
  • JWT 등 헤더를 사용하는 방식으로 로그인을 구현하기 위해서는 서버에서 allow credentials를 설정해줘야한다.

2) credentials = true 예시

axios.defaults.withCredentials = true; //클라이언트
allowCredentials = "true", //서버(@CrossOrigin 내부)

서버와 클라이언트 모두 한줄만 추가해주면 된다.

Authorization 헤더를 포함한 preflight 요청에 ok응답이 오는 걸 볼 수 있다.
응답헤더를 보면 Access-Control-Allow-Credentials: true가 추가되어 온 것도 볼 수 있다.



CORS 해결법


CORS 정책을 지정해주기 위한 방법은 3가지가 있다.
1. 프론트 프록시 서버 설정
2. 서버에서 직접 헤더에 설정
3. 스프링 부트의 기능을 사용


1. 프론트 프록시 서버 설정으로 CORS 정책 우회

웹 애플리케이션이 리소스를 직접적으로 요청하는 대신, 프론트엔드에서 프록시 서버를 사용하여 서버에게 리소스 요청을 전달하는 방법

이 방법을 사용하면, 웹 애플리케이션이 리소스와 동일한 출처에서 요청을 보내는 것처럼 보이므로 CORS 에러를 방지할 수 있다.

예시)

`http://example.com`라는 주소의 웹 애플리케이션이 
`http://api.example.com`라는 리소스에서 데이터를 요청하는 상황

1) 웹 애플리케이션은 직접적으로 리소스에 요청하는 대신, http://example-proxy.com라는 프록시 서버에 요청을 보낸다. 그러면 프록시 서버가 http://api.example.com으로 요청을 전달하고, 응답을 다시 웹 애플리케이션에 반환한다. 이렇게 하면 브라우저 입장에서는 요청이 http://example-proxy.com에서 보내진 것처럼 보인다.

  • (프론트 서버에서 Webpack Dev Server로 리버스 프록싱하는 방법이 이 블로그에 잘 정리되어 있으니 참고하면 좋을 것 같다.)

2) 프론트엔드 서버는 /api에 대한 요청일 경우 port를 8080으로 바꿔 백엔드 서버에 요청을 보낸다. 백엔드 서버에서는 같은 origin에서 온 요청이라 올바르게 처리된다.



2. 서버에서 직접 헤더 설정

서버에서 Allow Header, Method 등등을 다 설정해주는 방법이다.
Access-Control-Allow-Origin 헤더를 사용하여 요청을 수락할 출처를 명시적으로 저장할 수 있다.

예시

'Access-Control-Allow-Origin': <origin> | *
'Access-Control-Allow-Origin': https://myshop.com

첫 번째와 같이 설정하면 출처에 상관없이 리소스에 접근이 가능하기 때문에 보안이 취약해진다. 아래와 같이 직접 허용할 출처를 명시해주는 방법이 더 좋다.

그 외에도 허용된 메서드를 지정하고, credentials를 true로 설정할 수 있다.


3. 스프링부트 제공 cors 지원

첫 번째 방법은 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 {

}
  • 여기서 allowCredentials가 true인 경우에 origins를 *로 주면 에러가 발생할 수 있으니 주의해야한다.

이 방법은 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");
			}
	};
}
  • 이렇게 config를 등록하면 전역적으로 cors설정이 가능하다.



마치며..


프론트엔드 지식이 있으니 직접 테스트해볼 수 있어서 더 깊게 이해할 수 있었던 것 같다. 앞으로는 CORS를 두려워하지 않게 될 것 같다.





참고자료

profile
깊게 탐구하는 것을 좋아하는 백엔드 개발자 지망생 lime입니다! 게시글에 틀린 정보가 있다면 지적해주세요. 감사합니다. 이전블로그 주소: https://fladi.tistory.com/

0개의 댓글