[Spring Boot] CORS와 Preflight에 관한 이슈

change·2020년 9월 20일
1
post-thumbnail

이 포스트에서는 CORS와 Preflight 관련 이슈에 대해서 정리 해보려고 한다.

🤔 CORS 란?

Cross-Origin Resource Sharing(교차 출처 리소스 공유)의 약자로 브라우저에서 실행 중인 스크립트에서 시작되는 cross-origin HTTP 요청을 제한하는 브라우저 보안 기능이다.

브라우저는 same-origin policy(동일 출처 정책)에 의해 cross-origin의 리소스를 요청을 차단하는데 cross-origin 요청의 종류는 아래와 같다.

  • 다른 도메인 (ex: example.com에서 test.com으로)
  • 다른 하위 도메인 (ex: example.com에서 store.example.com으로)
  • 다른 포트 (ex: example.com에서 example.com:81로)
  • 다른 프로토콜 (ex: https://example.com에서 http://example.com으로)

아래는 MDN Web Docs 사이트에 정리되어있는 cross-origin의 예시인데 위 4가지 경우가 URL에 적용됐을 때 어느 부분이 문제인지 표로 정리되어있어 참고하기 좋을 것 같다.

기준 URL : http://store.company.com/dir/page.html

| URL | Outcome | Reason |
|---|:---:|---|
| http://store.company.com/dir2/other.html | same origin|path만 다른경우 |
| http://store.company.com/dir/inner/another.html | same origin | path만 다른경우 |
| https://store.company.com/page.html | cross origin | 프로토콜이 다른 경우 |
| http://store.company.com:81/dir/page.html | cross origin | 포트가 다른경우 |
| http://news.company.com/dir/page.html | cross origin | 도메인이 다른경우 |

자료출처 : MDN Web Docs


🤬 CORS 에러

에러 발생 예시

기본적으로 CORS 관련 에러가 발생하면 서버에 요청 시 정상적인 데이터를 받아오지 못하는데 브라우저 개발자 도구로 확인해보면 아래와 같은 에러 메시지가 표시된다.

이미지에 표시된 메시지는 http://localhost:8080에서 http://localhost:9090으로 보낸 요청이 CORS 정책에 의해 차단되었다는 내용인데 위에서 봤던 cross-origin의 4가지 경우 중 포트가 다를 경우에 해당한다.


해결방안

CORS 에러 발생 시 가장 쉬운 해결방안은 서버 단에서 특정 origin 혹은 모든 origin을 허용하도록 설정해주기만 하면 된다.

다음은 Spring Boot 기준 CORS 허용 방법 두 가지 예시이다.

@CrossOrigin Annotation 활용

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ApiController {

    /*
        @CrossOrigin(origins = “허용주소:포트”)
        모든 origin 허용은 @CrossOrigin(origins="*") 설정
    */
        
    // http://localhost:8080 에서 들어오는 요청만 CORS 허용
    @CrossOrigin(origins = "http://localhost:8080")
    @PostMapping("/")
    public String postSuccess() {
        return "REST API 호출 성공~!!";
    }
}

WebConfig 설정

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    /*
        http://localhost:8080 에서 들어오는 모든 요청 CORS 허용
    */
    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:8080");
    }
}

Spring Boot에서 위 예시와 같이 두 가지 방법 중 하나를 설정하면 cross-origin 요청이 성공하는 것을 확인할 수 있다.


🙄 그렇다면 Preflight 는?

Preflight 란?

CORS 관련하여 검색을 하다 보면 preflight라는 단어를 자주 보게 된다.

preflight는 우리말로 하면 말 그대로 미리 보내는 것 , 사전 전달이라고 할 수 있는데 이 뜻을 잘 생각해보면 어떤 역할을 하는 것인지 이해가 쉬울 것 같다.

기본적으로 브라우저는 cross-origin 요청을 전송하기 전에 OPTIONS 메소드로 preflight를 전송한다.

이때 ResponseAccess-Control-Allow-OriginAccess-Control-Allow-Methods가 넘어오는데 이는 서버에서 어떤 origin어떤 method를 허용하는지 브라우저에게 알려주는 역할을 한다.

브라우저가 결과를 성공적으로 확인하고 나면 cross-origin 요청을 보내서 그 이후 과정을 진행한다.

아래 소스코드는 chromium 에서 preflight 요청을 생성하는 부분만 따로 가져온 것으로 중간쯤 보면 OPTIONS 메소드를 비롯해 preflight 관련 헤더를 넣는 것을 확인할 수 있다.

소스코드 전체 과정을 이해할 필요까진 없고 이렇게 브라우저에서 preflight 요청을 생성한다 라는 것만 참고하고 넘어가면 될 것 같다.

Chromium Source

std::unique_ptr<ResourceRequest> CreatePreflightRequest(
    const ResourceRequest& request,
    bool tainted,
    const base::Optional<base::UnguessableToken>& devtools_request_id) {
  DCHECK(!request.url.has_username());
  DCHECK(!request.url.has_password());

  std::unique_ptr<ResourceRequest> preflight_request =
      std::make_unique<ResourceRequest>();

  // Algorithm step 1 through 5 of the CORS-preflight fetch,
  // https://fetch.spec.whatwg.org/#cors-preflight-fetch.
  preflight_request->url = request.url;
  preflight_request->method = net::HttpRequestHeaders::kOptionsMethod;
  preflight_request->priority = request.priority;
  preflight_request->destination = request.destination;
  preflight_request->referrer = request.referrer;
  preflight_request->referrer_policy = request.referrer_policy;
  preflight_request->mode = mojom::RequestMode::kCors;

  preflight_request->credentials_mode = mojom::CredentialsMode::kOmit;
  preflight_request->load_flags = RetrieveCacheFlags(request.load_flags);
  preflight_request->resource_type = request.resource_type;
  preflight_request->fetch_window_id = request.fetch_window_id;
  preflight_request->render_frame_id = request.render_frame_id;

  preflight_request->headers.SetHeader(net::HttpRequestHeaders::kAccept,
                                       kDefaultAcceptHeaderValue);

  preflight_request->headers.SetHeader(
      header_names::kAccessControlRequestMethod, request.method);

  std::string request_headers = CreateAccessControlRequestHeadersHeader(
      request.headers, request.is_revalidating);
  if (!request_headers.empty()) {
    preflight_request->headers.SetHeader(
        header_names::kAccessControlRequestHeaders, request_headers);
  }

  if (request.is_external_request) {
    preflight_request->headers.SetHeader(
        header_names::kAccessControlRequestExternal, "true");
  }

  DCHECK(request.request_initiator);
  preflight_request->request_initiator = request.request_initiator;
  preflight_request->headers.SetHeader(
      net::HttpRequestHeaders::kOrigin,
      (tainted ? url::Origin() : *request.request_initiator).Serialize());

  // We normally set User-Agent down in the network stack, but the DevTools
  // emulation override is applied on a higher level (renderer or browser),
  // so copy User-Agent from the original request, if present.
  // TODO(caseq, morlovich): do the same for client hints.
  std::string user_agent;
  if (request.headers.GetHeader(net::HttpRequestHeaders::kUserAgent,
                                &user_agent)) {
    preflight_request->headers.SetHeader(net::HttpRequestHeaders::kUserAgent,
                                         user_agent);
  }

  // Additional headers that the algorithm in the spec does not require, but
  // it's better that CORS preflight requests have them.
  preflight_request->headers.SetHeader("Sec-Fetch-Mode", "cors");

  if (devtools_request_id) {
    // Set |enable_load_timing| flag to make URLLoader fill the LoadTimingInfo
    // in URLResponseHead, which will be sent to DevTools.
    preflight_request->enable_load_timing = true;
    // Set |devtools_request_id| to make URLLoader send the raw request and the
    // raw response to DevTools.
    preflight_request->devtools_request_id = devtools_request_id->ToString();
  }
  return preflight_request;
}

출처 : chromium github


Preflight 관련 설정

질문! 그럼 cross-origin 요청을 보낼 때마다 매번 preflight 요청을 보내나요?

결론부터 말하자면 매번 보내지 않는다.

서버 설정을 통해서 preflight 결과의 캐시를 일정 기간 동안 저장시킬 수가 있다.

이 캐시 정보가 살아있는 시간 동안은 cross-origin 요청에 대해서 preflight를 생략하고 바로 요청 전송이 가능하다.

Spring Boot 에서의 설정방법은 아래 코드에서 확인할 수 있다.

@CrossOrigin Annotation 활용한 maxAge 설정

import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class ApiController {

    // maxAge=3600 설정을 통해 3600초 동안 preflight 결과를 캐시에 저장
    @CrossOrigin(origins="http://localhost:8080", maxAge=3600)
    @PostMapping("/")
    public String postSuccess() {
        return "REST API 호출 성공~!!";
    }
}

WebConfig maxAge 설정

import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.config.annotation.CorsRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;

@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry registry) {
        registry.addMapping("/**")
                .allowedOrigins("http://localhost:8080")
                .maxAge(3600); // 3600초 동안 preflight 결과를 캐시에 저장
    }
}

앞서 보았던 CORS 해결방안에서 설정해준 코드에 maxAge 관련 설정만 추가해주면 간단하게 적용 가능하다.


마치며...

CORS 관련 주제를 포스팅하게 된 계기는 회사 업무 중 발생한 오류를 해결하는 과정을 기록으로 남기고 싶다는 생각 때문이었다.

사실 업무에서 발생한 오류는 위 내용만으로는 해결할 수 없었다.

업무 환경에서는 cross-origin 요청이 Web Server를 거쳐서 WAS로 들어오는 방식으로 구성되어 있었기 때문에 Web Server에서 preflight 요청에 대한 설정을 맞춰주지 않으면 아무리 위에 작성한 설정들을 적용해도 preflight 에러를 뱉을 뿐이었다.

preflight만 해결되면 정상적인 호출이 가능한 상황이지만 Web Server 설정에 관해서는 아는 게 없고 고객은 얼른 해결해달라 요구하고...

급하게 구글링하고 설정을 맞춰도 preflight에서 계속 403 에러가 발생하는 답답한 상황...

결국은 화면 단에서 Ajax로 POST 요청을 보내던 것을 백 엔드 서버에서 POST 요청을 보내도록 변경해버렸다.

preflight는 브라우저에서 보내는 것이기 때문에 POST 요청을 브라우저에서 보내지 않고 서버에서 Java로 POST 요청을 보내면 당연히 아무 문제 없던 거였다...=

사실 POST 요청에 API 인증정보를 추가해서 보내야 하니 개발자 도구에서 인증정보가 다 노출되는 Ajax 요청 방식보다는 서버 단에서 POST 요청을 보내는 게 보안 적으로도 훨씬 안전한 방법인듯하여 결과는 만족스러웠다.

아무튼, 꼭 이번 이슈 때문이 아니더라도 Web Server 설정에 대해서는 더 공부가 필요할 것 같다.

0개의 댓글