CORS 정복기

노력을 즐기는 사람·2021년 8월 8일
13
post-thumbnail

CORS 얼마나 알고 계신가요?
전 X도 모릅니다. (1도 모른다는 뜻^^) 그래서 CORS 에러가 발생하면 항상 구글에다 이렇게 검색했어요.


그리고 모든 요청에 대하여 CORS를 허용해버렸습니다. 근데 CORS가 뭘까요?
아직 잘 모르겠지만 보안을 위해서 존재하는 일종의 프로토콜이라고 합니다.

그러니까 명색이 백엔드 개발자라는 양반이 보안을 신경쓰지 않았다는 말이죠. 왜 그랬을까요?

  • 어차피 내가 개발한 서비스는 아무도 관심이 없으니까.
  • 설령 서비스가 공격당해도 민감한 정보들이 없으니까.

상당히 아마추어 같은 이유입니다. 이젠 프로답게 CORS를 해결해보고 싶습니다.

CORS란?

Cross Origin Resource Sharing의 약자입니다. 반대되는 개념도 있습니다.
반대되는 개념은 Same Origin Policy라고 부릅니다.

각설하고 CORS를 단어 단위로 살펴보겠습니다.
Cross: 교차, Origin: 출처, Resource: 자원, Sharing: 공유

말 그대로 서로 다른 출처끼리 자원을 공유하겠다는 뜻입니다. 그림으로 살펴볼까요?


(출처: MDN 공식문서)

그림 왼쪽에 있는 웹 페이지는 domain-a.com 에 접속하면 볼 수 있는 웹 페이지입니다.
domain-a.com에 접속하면 웹 페이지를 출력하기 위해서 총 5개의 API 요청이 발생하고 있는데요.
그 중 셋은 Same Origin 이고 둘은 Cross Origin 입니다.

상단의 파란색 네모 박스안에 있는 API들은 Same Origin 입니다.
domain-a.com 에서 웹 서버 domain-a.com 로 요청을 보냈기 때문에 Same Origin이죠.

그러면 하단의 빨간색 네모 박스안의 API들은 왜 Cross Origin 일까요?
domain-a.com 에서 domain-b.com으로 요청을 보냈기 때문입니다.

서버는 허용되지 않은 Origin으로부터 요청을 받으면 요청을 거절합니다.
즉, 빨간 박스에 있는 컨텐츠는 서버로부터 제공받지 못한다는 뜻입니다.

위의 그림으로 한 가지 사실을 알게 되었습니다.

요청을 보내는 곳(Client)요청을 받는 곳(Server)도메인이 다르면 Cross Origin이라고 부르는구나!

맞는 말이지만 Cross Origin은 도메인이 다를 때만을 이야기하는건 아닙니다.

CORS는 언제 발생할까?

말 그대로 Cross(서로 다른) Origin 끼리 통신할 때 발생합니다.
그러면 Origin은 뭘까요? 아래 그림의 URL을 같이 볼까요?

URL의 구조 중 일부에만 코멘트를 달아놨습니다. (포트는 생략이 가능합니다.)
Origin은 프로토콜, 호스트, 포트 셋을 의미합니다.
셋 중 하나만 달라져도 CROS가 발생합니다.

몇 가지 예시를 들어보겠습니다.

예시CORS 여부
http://velog.io --> https://velog.ioO 프로토콜이 다름
https://api.velog.io --> https://velog.ioO 호스트가 다름
https://velog.io:8080 --> https://velog.ioO 포트가 다름
https://velog.io/write --> https://velog.ioX
https://velog.io/write?id=plzprayme --> https://velog.ioX

간단하죠?

그런데 서로 다른 Origin 간의 통신은 절대로 불가능한걸까요?
서로 다른 Origin 간의 통신이 필요할 수도 있지 않을까요? 그럴땐 어떻게 해야할까요?

결론부터 말하면 HTTP Header를 조작해서 허용할 수 있습니다.
조금 더 구체적으로 말하자면, 요청을 보내는 Origin에 대한 정보를 추가하면 됩니다.

CORS를 허용하는 방법은 Preflight, Simple Request두 가지가 있는데요.
두 가지를 살펴보겠습니다.

Preflight

Preflight 단어의 뜻을 살펴보겠습니다.
먼저, 미리의 뜻을 가지고 있는 접두사 pre날다의 뜻을 가진 flight 가 합성된 것 같습니다.
그러면 미리 날려본다. 이런 뜻이겠네요.

실제로 Preflight는 브라우저가 본(main) 요청을 날리기 전에 날리는 사전 요청입니다.
그렇다면 API 요청이 발생할 때마다 사전 요청을 날릴까요? 그렇지 않습니다.

사전 요청을 날리지 않는 조건이 있습니다. Simple Request를 살펴보겠습니다.

Simple Request

main 요청이 Simple Request의 조건에 딱 맞다면 preflight가 필요 없습니다.
그렇다면 Simple Request의 조건이 뭘까요?

Simple Request의 조건

  • HTTP METHOD가 GET, HEAD, POST 중 하나일 때
  • Request 객체 생성 시 자동으로 생성되는 헤더들(Accept, Accept-Language, Content-Language, Content-Type) 외의 다른 HTTP Header가 조작되지 않은 요청
  • Content-Type의 값이 application/x-www-form-urlencoded, multipart/form-data, text/plain 중 하나일 때
  • XHR 객체로 API를 날릴 때 xhr.upload.addEventListener()를 호출하지 않은 요청
  • API 요청을 날릴 때 ReadableStream 객체가 사용되지 않은 요청

실제로 그런지 예제를 작성해서 확인해보겠습니다.

const request = new Request('http://localhost:8081')

fetch(request, { method: 'GET' })
  .then(response => alert(response))

Fetch API로 작성한 아주 간단한 API 요청인데요.
Request 객체의 헤더를 조작하지 않은 GET 요청입니다.

API를 주고 받는 Origin 들을 그림으로 살펴보겠습니다.

localhost:8081 -> localhost:8080로 보내는 요청입니다. 즉, 호스트가 다르니 CORS죠.
이제 요청 결과를 확인해볼까요?

브라우저 -> 개발자도구 -> 네트워크 탭을 캡쳐해서 살펴보겠습니다.

Preflight가 없죠. Simple Request이기 때문에 브라우저는 Preflight를 날리지 않았습니다.

그런데 CORS 오류 라고 적혀있는게 보이시나요? Preflight는 없지만 통신에는 실패합니다.
허용되지 않은 Cross Origin이기 때문이죠.

CORS 오류는 조금 후에 해결하기로 하고 헤더를 조작해서 Preflight를 발생시켜보겠습니다.
Preflight가 발생하면 API는 아래 그림처럼 되겠네요.

위에 그림처럼 만들기 위해서 아래 코드를 살펴보겠습니다.

const request = new Request('http://localhost:8081')
request.headers.set("something", "new") // 여기 추가했음

fetch(request, { method: 'GET' }) 
  .then(response => alert(response))

간단한 헤더를 추가했습니다. Key는 something이고 Value는 new입니다.
즉, Simple Request 조건인 "헤더를 추가하지 않는다" 를 어겼습니다.

요청 결과를 확인해봅시다.

이전에는 없던 preflight가 발생했습니다.
근데 preflight는 서버로 날아가서 무슨 일을 해주는걸까요?

Preflight

Preflight는 실제 요청을 보내기 전, 실제 요청이 안전한지 판단하기 위해서 보내는 사전 요청입니다.
그러면 Preflight는 서버에서 뭘 하길래 main 요청의 안전 여부를 판단할 수 있을까요?

PreflightRequestResponse 객체를 살펴보면 알 수 있습니다.

Preflight Request

CORS는 HTTP Header의 값을 조작함으로써 발생되며, HTTP Header의 값을 조작함으로써 허용 됩니다.
또, HTTP Header 값을 조작함으로써 거절할 수도 있습니다.

PreflightRequest 객체에서는 Access-Control-Request-로 시작하는 헤더를 기억해야 합니다.
영어 단어 그대로 접근 제어(Access Control)를 요청(Request)하는 헤더인데요.
Preflight Request는 main 요청의 정보들을 Access-Control-Request- 헤더들에 저장합니다.

앞서 살펴본 예제에서 우리는 분명히 main 요청의 헤더를 조작했습니다.
main 요청의 HTTP Method는 GET이고 something이라는 헤더를 추가했었죠.
그러면 Preflight Request 헤더에 아래와 같이 두개의 헤더가 추가됩니다.

  • Acess-Control-Request-Method: GET
  • Access-Control-Request-Headers: something

개발자 도구의 네트워크를 켜서 확인해봅시다.

Preflight Request의 HTTP METHOD는 OPTIONS입니다.
그리고 실제 요청에 추가했었던 something 이라는 헤더가 추가 되어 있는 것은 확인할 수 있습니다.

통신하며 자동으로 추가되는 즉, new Request() 를 통해서 자동으로 추가되는 헤더들을 제외하고, 새로 추가된 헤더들도 허용 해달라는 의미입니다.

그러면 이 Preflight Request 를 받은 후에 서버는 어떻게 할까요?

Preflight Response

서버에서는 Preflight Request 의 헤더를 기반으로 Preflight Response를 응답합니다.
구체적으로는, Access-Control-Allow-로 시작하는 헤더를 추가해서 응답합니다.

그런데 서버가 알아서 해주지는 않고 프로그래밍이 필요합니다.
그러니까 사전에 프로그래밍 된 Origin만 허용됩니다.

그럼 위 예제의 Preflight Request를 날렸다면 어떤 Response를 받아야 할까요?

정답은

  • Access-Control-Allow-Headers: something
  • Access-Control-Allow-Mehotd: OPTIONS, GET
  • Access-Control-Allow-Origin: http://localhost:8080

가 추가되야 합니다.

서버에 로직을 추가하고 다시 볼까요? 저는 SpringBoot 서버에 추가하겠습니다.

@Configuration
public class CorsConfig implements WebMvcConfigurer {
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/")                     // 해당 URL에 오는 요청에
            .allowedOrigins("http://localhost:8080") // 허용하는 Origin 추가
            .allowedMethods("GET")                   // 허용하는 Method 추가
            .allowedHeaders("Something")             // 허용하는 Header 추가
            .maxAge(3600L);
    }
}

이렇게 추가하고 다시 Request를 날리면 오류 없이 잘 동작합니다.
개발자 도구에서 확인해봅시다.

허용이 되었으니 아래처럼 요청들이 성공한 것을 볼 수 있습니다.
그리고 빨간 박스 중 위에 위치한 preflight API 명세를 살펴보면 아래와 같습니다.

서버에서 추가한 헤더들이 Response 헤더에 잘 추가되어 있습니다.

이렇게 CORS에 대해서 알아봤습니다.
이번엔 제가 개발에 참여한 프로젝트의 CORS 설정을 고쳐봅시다.

내 프로젝트는 왜 CORS였을까?

이 글을 작성한 이유이기도 합니다.
제가 개발에 참여한 사이드 프로젝트가 한동안 CORS 때문에 고생을 했습니다.
우리 프로젝트는 왜 CORS가 발생했을까요?

먼저, 우리 프로젝트의 Client와 Server의 Origin을 그림으로 살펴보겠습니다.

앗, 클라이언트의 Origin과 서버의 Origin이 달랐군요. 그리고 클라이언트의 Origin은 여럿이네요.
http:localhost:3000 는 개발환경이고, https://sgsg.space는 배포 환경입니다.
반면에 서버는 https://api.sgsg.space에서 요청을 기다리고 있었죠.

우리 프로젝트의 모든 요청에는 Authorization 헤더가 추가됩니다.
즉, 헤더를 추가했기 때문에(Simple Request가 아니기 때문에) Preflght도 발생했습니다.

다시 말해서 Preflight 에 대한 Response, 본 요청에 대한 Response 를 조작할 필요가 있었습니다.
어떻게 해결했을까요?

나, 어쩌면 개방적일지도?

모든 Origin을 허용해버렸습니다. 이렇게요.

아래 네모 박스를 보면 Access-Control-Request-Headers에는 authorization 하나 뿐이지만,

위의 네모 박스처럼 Access-Control-Allow-Origin: *을 사용해서 모두 열어놨구요.
Access-Control-Allow-Headers에는 사용하지 않는 헤더들도 모두 넣어버렸습니다.
왜냐구요? StackOverFlow를 복붙 했으니까요.

지금이라도 수정하겠습니다. 어떻게 수정해야 할까요? 정답은 아래 네모 박스에 나와 있었습니다.
Access-Control-Request-Headers에는 authorization 하나 뿐이거든요.

쇄국정책을 펼쳐보자

우리 프로젝트의 아키텍처를 아주 간단하게 살펴보겠습니다.

Preflight는 NginX 선에서 처리됩니다. 본 요청은 SpringBoot까지 보내집니다.
따라서 NginX에서는 Preflight 요청을 처리했고, 본 요청은 SpringBoot 에서 처리했습니다.

이전에는 어떻게 처리했는지 살펴볼까요?

Before

이전에 적용되어 있던 NginX 설정과 SpringBoot 코드입니다.

# nginx.conf

http {

  # TODO
  server {
    
    # TODO
    location {
    
      if ($request_method = OPTIONS) { 
        # 모든 Origin 허용
        add_header "Access-Control-Allow-Origin" "*"; 
        # PUT DELETE 허용하지 않음
        add_header "Access-Control-Allow-Methods" "GET, POST, OPTIONS, HEAD"; 
        # 사용하지 않는 헤더도 허용
        add_header "Access-Control-Allow-Headers" "api_key, Authorization, Origin, X-Requested-With, Content-Type, Accept"; 
        return 204;
      }
      
    } 
  } 
}
@Configuration
public class WebConfig implements WebMvcConfigurer { // Confing
    private static final long MAX_AGE_SECS = 3600L;

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
            .allowedOrigins("*") // 모든 Origin 추가
            .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") // 사용하지 않는 METHOD (PATCH) 추가
            .allowedHeaders("*") // 모든 헤더 추가
            .maxAge(MAX_AGE_SECS);
    }
}

사용하지 녀석들을 이것저것 허용했고, 정작 사용하는 메서드의 일부는 허용하지 않았습니다.

After

이전의 악취나는 설정들을 어떻게 개선했는지 살펴보겠습니다.

# nginx.conf
http {

  # TODO
  server {
    
    # TODO
    location {
    
      # 올바른 Preflight Request 인지 확인하기 위한 변수
      set $FLAG "";
      
      # Preflight Reuquest인지 확인하기
      if ($request_method = OPTIONS) {
        set $FLAG "A";
      }
      
      # 허용된 Origin인지 확인하기
      if ($http_origin ~* (https://sgsg.space|http://localhost:3000)) {
        set $FLAG "${FLAG}B"
      }

      # 위의 조건들이 TRUE면 요청을 허용한다.
      if ($FLAG = "AB") {
        add_header "Access-Control-Allow-Origin" $http_origin; // 일부 origin만 허용
        add_header "Access-Control-Allow-Methods" "GET, POST, DELETE, PUT, OPTIONS"; // 사용하는 METHOD만 허용
        add_header "Access-Control-Allow-Headers" "Authorization"; // 사용하는 헤더만 허용
        add_header "Access-Control-Max-Age" "3600"; // 캐싱 타임 설정
        return 204;
      }
      
    } 
  } 
}

코드는 길어졌지만 안전하고 뭔가 깔끔해졌습니다.
이제 스프링부트는 어떻게 변경했는지 알아보겠습니다.

public class CorsFilter extends OncePerRequestFilter { // Filter
    
    // 허용하는 Origin 목록
    private static final List<String> ALLOWED_ORIGINS = new ArrayList<>(
        Arrays.asList(
            "https://sgsg.space",
            "http://localhost:3000"
        )
    );

    @Override
    protected void doFilterInternal(
        HttpServletRequest request,
        HttpServletResponse response,
        FilterChain filterChain
    ) throws ServletException, IOException {
        
        try {
            String origin = request.getHeader("Origin");
            // Origin 검증
            validate(origin);
            // 검증 후 헤더 추가
            response.addHeader("Access-Control-Allow-Origin", origin);
            response.addHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, OPTIONS");
            response.addHeader("Access-Control-Allow-Headers", "Authorization");
            response.setIntHeader("Access-Control-Max-Age", 3600);

        } catch (BadRequestException e) {
            // 검증 실패 시 공격 Origin 로깅
            logger.info(e.getMessage());
        }

        filterChain.doFilter(request, response);
    }
}

Before는 WebMvcConfigurer.addCorsMappings() 을 오버라이딩 해서 사용했다. 반면에 After는 Filter를 사용합니다.

허용된 Origin 인지 검사하기 위해서 반드시 Request 객체의 Origin을 확인해야 했습니다.
그렇지만 WebMvcConfigurer.addCorsMappings() 에서는 Request 객체를 까볼 수 없었습니다.
어쩔 수 없이 Filter를 하나 정의해서 Response 객체를 수정하게 되었습니다.

더 개선할 수 없을까?

한 쪽에서만 관리하기

지금은 Preflight는 NginX에서 관리합니다. 또, 본 요청은 SpringBoot에서 관리합니다.
이러면 허용되는 Origin들을 관리하기가 어려워집니다.
예를들어, SpringBoot에서는 허용 Origin을 추가했는데, NginX에는 까먹고 추가를 안할 수도 있습니다.

이런 문제를 해결하고 싶어서 한쪽에서만 관리를 하고 싶었는데 실패했습니다.
아직 원인을 정확히 파악하지 못했습니다. NginX와 SpringBoot에 대한 깊은 이해가 필요해 보입니다.

허용 METHOD 한정하기

사실 허용하는 METHOD가 지나치게 많습니다.
Access-Control-Request-Method 에 담겨있는 Method들만 추가하고 싶은게 그게 안됩니다.
Request 객체의 HTTP Header들을 까봐도 Access-Control-Allow-Methods가 없었거든요.

당연합니다. Access-Control-Allow-* 시리즈들은 Preflight에만 추가되기 때문이죠.

잘 동작하는지 확인하기

짜잔~~ 잘 동작합니다.
왼쪽에 컨텐츠들도 잘 불러오고, 오른쪽에 API 들도 통신이 원활하게 된 모습입니다.

profile
노력하는 자는 즐기는 자를 이길 수 없다

4개의 댓글

comment-user-thumbnail
2021년 8월 31일

감사합니당

1개의 답글
comment-user-thumbnail
2022년 12월 26일

감사합니다 너무 깔끔하네요!

답글 달기
comment-user-thumbnail
2023년 2월 21일

감사합니다.

답글 달기