CORS에러 해결방법

devkwon·2024년 1월 31일

CORS 에러?

CORS(Cross-Origin-Resource-Sharing)란 다른 출처에서도 리소스를 공유한다는 것이다. 현재 브라우저는 SOP 정책을 지키고 있는데, 이를 위반하는 요청을 하게 되면 CORS 오류가 발생하는 것이다.

Cross-Origin?

cross-origin이란 다음 중 한 가지라도 다른 경우를 말한다.

1.프로토콜 - http와 https는 프로토콜이 다르다.
2.도메인 - domain.com과 other-domain.com은 다르다.
3.포트 - 8080포트와 3000포트는 다르다.

SOP(Same-Origin Policy)

Same-Origin Policy란 말 그대로 Cross-Origin이 아닌 동일한 출처에서만 리소스를 공유할 수 있다는 것이다.

만약 출처와 상관없이 모두 자유롭게 통신을 한다면 XSS,CSRF 공격에 취약해진다.

다음과 같은 시나리오를 생각해보자.

  1. 사용자가 악성 사이트에 접속한다.

  2. 이때 해커가 몰래 심어놓은 악의적인 자바스크립트가 실행되어, 사용자가 모르는 사이에 어느 포털 사이트에 요청을 보낸다.

  3. 그럼 포털 사이트에서 해당 브라우저의 쿠키를 이용하여 로그인을 하거나 등 상호작용에 따른 개인 정보를 응답 값을 받은뒤, 사이트에서 해커 서버(hacker.example.com)로 재차 보낸다.

  4. 이외에도 사용자가 접속중인 내부망의 아이피와 포트를 가져오거나, 해커가 사용자 브라우저를 프록시처럼 악용할 수도 있다. 

이렇듯 악의적인 공격에서부터 웹사이트를 지키기 위해서 SOP가 존재하는 것이다.

출처 비교는 브라우저가 한다.

출처를 비교하는 로직은 서버에 구현된 스펙이 아닌 브라우저에 구현된 스펙이다. 서버의 오류가 아니니 당황해하지 말자.

교차 출처 리소스 공유 (Cross-Origin Resource Sharing)

단어 그대로 다른 출처의 리소스 공유에 대한 허용/비허용 정책이다.
아무리 보안이 중요하지만, 개발을 하다 보면 기능상 어쩔 수 없이 다른 출처 간의 상호작용을 해야 하는 케이스도 있으며, 또한 실무적으로 다른 회사의 서버 API를 이용해야 하는 상황도 존재한다. 따라서 이와 같은 예외 사항을 두기 위해 CORS 정책을 허용하는 리소스에 한해 다른 출처라도 받아들인다는 것이다.

브라우저의 CORS 기본 동작 살펴보기

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

기본적으로 웹은 HTTP 프로토콜을 이용하여 서버에 요청을 보내게 되는데,
이때 브라우저는 요청 헤더에 Origin 이라는 필드에 출처를 함께 담아 보내게 된다.

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

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

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

이후 응답을 받은 브라우저는 자신이 보냈던 요청의 Origin과 서버가 보내준 응답의 Access-Control-Allow-Origin을 비교해본 후 차단할지 말지를 결정한다.
만약 유효하지 않다면 그 응답을 사용하지 않고 버린다. (CORS 에러 !!)
위의 경우에는 둘다 http://localhost:3000이기 때문에 유효하니 다른 출처의 리소스를 문제없이 가져오게 된다.

결국 CORS 해결책은 서버의 허용이 필요

위의 브라우저의 CORS 동작 과정을 살펴보니, 길고 길었던 여정의 결론은 서버에서 Access-Control-Allow-Origin 헤더에 허용할 출처를 기재해서 클라이언트에 응답하면 되는 것이었다. 즉, 백엔드 개발자가 고쳐야될 부분인 것이다.

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

CORS 작동 방식 3가지

예비 요청 (Preflight Request)

사실 브라우저는 요청을 보낼때 한번에 바로 보내지않고, 먼저 예비 요청을 보내 서버와 잘 통신되는지 확인한 후 본 요청을 보낸다. 
즉, 예비 요청의 역할은 본 요청을 보내기 전에 브라우저 스스로 안전한 요청인지 미리 확인하는 것이다.
이때 브라우저가 예비요청을 보내는 것을 Preflight라고 부르며, 이 예비요청의 HTTP 메소드를 GET이나 POST가 아닌 OPTIONS라는 요청이 사용된다는 것이 특징이다.

예비 요청의 문제점과 캐싱

요청을 보내기 전에 OPTIONS 메서드로 예비 요청을 보내 보안을 강화하는 목적의 취지는 좋다. 그러나 결국은 실제 요청에 걸리는 시간이 늘어나게 되어 어플리케이션 성능에 영향을 미치는 크나큰 단점이 있다.
특히 수행하는 API 호출 수가 많으면 많을 수록 예비 요청으로 인해 서버 요청을 배로 보내게 되니 비용 적인 측면에서 폐가 될 수 있다. 따라서 브라우저 캐시(Cache)Visit Website 를 이용해 Access-Control-Max-Age 헤더에 캐시될 시간을 명시해 주면, 이 Preflight 요청을 캐싱 시켜 최적화를 시켜줄 수 있다.

단순 요청 (Simple Request)

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

다만, 심플한 만큼 특정 조건을 만족하는 경우에만 예비 요청을 생략할 수 있다.
대표적으로 아래 3가지 경우를 만족 할때 만 가능하다.

  • 요청의 메소드는 GET, HEAD, POST 중 하나여야 한다.
  • Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width 헤더일 경우 에만 적용된다.
  • Content-Type 헤더가 application/x-www-form-urlencoded, multipart/form-data, text/plain중 하나여야한다. 아닐 경우 예비 요청으로 동작된다.

인증된 요청 (Credentialed Request)

인증된 요청은 클라이언트에서 서버에게 자격 인증 정보(Credential)를 실어 요청할때 사용되는 요청이다.
여기서 말하는 자격 인증 정보란 세션 ID가 저장되어있는 쿠키(Cookie) 혹은 Authorization 헤더에 설정하는 토큰 값 등을 일컫는다.
즉, 클라이언트에서 일반적인 JSON 데이터 외에도 쿠키 같은 인증 정보를 포함해서 다른 출처의 서버로 전달할때 CORS의 세가지 요청중 하나인 인증된 요청으로 동작된다는 말이며, 이는 기존의 단순 요청이나 예비 요청과는 살짝 다른 인증 형태로 통신하게 된다.

인증된 요청은 다음과 같은 특수한 제약조건이 생긴다.

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

코드

본인은 json 방식의 요청을 사용해서, 단순 요청을 사용할 수 없었다. 따라서 다음과 같은 방식을 사용했다.

@Component
public class CustomCorsFilter implements Filter {

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        Filter.super.init(filterConfig);
    }

    @Override
    public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) req;
        HttpServletResponse response = (HttpServletResponse) res;

        response.setHeader("Access-Control-Allow-Origin", "http://localhost:5173");
        response.setHeader("Access-Control-Allow-Credentials", "true");
        response.setHeader("Access-Control-Allow-Methods","GET,POST,PUT");
        response.setHeader("Access-Control-Max-Age", "3600");
        response.setHeader("Access-Control-Allow-Headers",
                "Origin, X-Requested-With, Content-Type, Accept, Authorization");

        chain.doFilter(req, res);
    }

    @Override
    public void destroy() {
        Filter.super.destroy();
    }

0개의 댓글