항해99 5주차 WIL - CORS

Ming-Gry·2022년 10월 23일
1

항해99 WIL

목록 보기
5/12
post-thumbnail

이번 포스팅은 프론트와 백엔드가 함께하는 프로젝트를 해봤다면 한 번쯤 겪어보고 들어봤을 CORS 에 대한 포스팅이다. 이것도 길다면 길겠지만 지난 포스팅들보다는 분량 조절을 좀 해보려고 한다.

1) CORS

1-1) CORS 란?

CORS 란?

CORS 란 Cross Origin Resource Sharing 의 약자로, 교차 출처 리소스 공유라는 뜻을 가지고 있다. 말이 어려울 수 있는데 '다른 출처의 리소스를 공유하겠다' 라는 뜻으로 받아들이는 것이 조금 더 이해가 쉬울 것 같다.

그럼 이 다른 출처라는 건 또 뭐고, CORS 는 왜 있는 것이고 어떻게 생겨먹었으며 어떻게 해결해 줘야 할까? 이제 밑에서 그 얘기를 더 해보도록 하자.

출처(Origin) 란?

프로토콜, 호스트, 포트를 통틀어 Origin 이라고 한다. 이 Origin 이 같은 것을 출처가 같다고 말하고, 다르면 Cross Origin 이 되는 것이다. 우리가 프로젝트를 하며 React 의 localhost 3000 포트SpringBoot8080 포트가 달랐기 때문에 CORS 에러가 발생하게 된 것이다. 아래의 예시에서 같은 출처는 어떤 것인지 살펴보자.

1. https://localhost
2. http://localhost:80
3. http://localhost
4. http://127.0.0.1
5. http://localhost/api/cors

위의 예시에서 같은 출처의 url 은 2, 3, 5번이다. 1번은 프로토콜이 다르며, 4번의 IP 주소는 localhost 가 맞기는 하나, 브라우저에서는 String Value 로 같은 출처를 판별하기 때문에 다른 것으로 판단한다. 여기서 2번과 3번은 포트가 있느냐 없느냐의 차이인데, 보통 http 는 host 뒤에 80 포트를 작성해주어도 되고 생략해서 써도 문제가 없다. 비슷한 예로 https 는 443 포트를 생략해서 쓴다. https 를 사용하는 서비스 호스트 뒤에 443 포트를 달고 접속을 해도 접속이 가능한 것을 확인할 수 있다.

결국엔 CORS 란 이렇게 출처가 다른 Origin 이 서로 리소스를 주고 받을 수 있도록 허용해주는 정책이라고 할 수 있겠다.

1-2) SOP

SOP 란 Same Origin Policy 의 약자로, 동일 출처 정책 정도로 해석해볼 수 있겠다. 위의 예시처럼 같은 출처에서만 리소스를 공유할 수 있도록 하는 정책이다. 2011년 RFC 6454에서 처음 등장하였는데, 그 이유는 XSS 와 CSRF 와 같은 보안 공격을 막기 위함이다. 그렇기 때문에 우리는 CORS 로 다른 출처의 리소스를 주고 받을 수 있도록 허용해줘야 한다.

XSS 와 CSRF 에 대한 내용은 아래의 포스팅에서 더욱 자세히 살펴보도록 하자.

XSS : https://junhyunny.github.io/information/security/spring-mvc/reflected-cross-site-scripting/
CSRF(Cross-Site Request Forgery) 공격과 방어 : https://junhyunny.github.io/information/security/spring-boot/spring-security/cross-site-reqeust-forgery/

1-3) CORS 를 더 깊이 살펴보기

CORS 는 Request 유형에 따라 3가지로 나눌 수 있고 Response 에는 지정된 필드들이 들어가는데 일단 Response 부터 살펴보고 각 Request 유형별로 어떻게 동작하는지 살펴보도록 하자.

CORS Response

CORS Response 를 하기 위해서는 서버 개발자가 추가로 작업을 해줘야 한다. 작업된 Response 는 HttpHeader 에 담겨 전달된다.

  • Access-Control-Allow-Origin : 허용된 Origin 요청, 요청 Origin 이 서버에 의해 허용되지 않으면 이 헤더 필드가 존재하지 않거나, 필드 Key 값은 존재하지만 Value 에 요청 Origin 값이 들어있지 않다.
  • Access-Control-Allow-Credentials : 요청된 credential mode 에 따라 true / false 값이 설정되어 Response 된다.
  • Access-Control-Allow-Methods : 요청한 url 에 대해서 어떤 http method 가 허용되는지 응답한다.
  • Access-Contrl-Max-Age : Preflight 결과를 캐시하는 시간을 설정한다.
  • Access-Control-Expose-Headers : 클라이언트에게 보여줄 헤더 목록을 설정한다.

Preflight Request

기본적으로 모든 CORS 요청은 Preflight 요청 후에 본 요청을 보낸다. 단어 뜻에서처럼 사전에 미리 보내는 예비 요청이다.

Preflight 요청은 우리가 알고 있는 Http Method 중 Post, Get, Patch 등이 아닌 Options 라는 메소드를 사용해 현재 브라우저의 Origin 을 서버에 보낸다.

워낙 이미지를 잘 그리셔서 가져왔다. 빨간색 예비 요청에 나와있는 것처럼 현재 브라우저의 Origin 을 보내면 서버는 Access-Control-Allow-Origin 으로 허용된 출처를 응답해준다. 여기서는 와일드카드로 모든 출처가 허용됐기 때문에 예비 요청에 성공했다. 예비 요청에 성공하면 본 요청을 보내 서버에 원래 요청하려 했던 정보들이 넘어간다.

만약 여기서 Access-Control-Allow-Origin 이 와일드카드가 아니라 아래와 같다고 해보자. 여기서 눈 여겨 봐야할 것은 마찬가지로 200 Ok 가 왔지만 CORS 에러가 발생했다는 점이다.

OPTIONS https://evanmoon.tistory.com/rss 200 OK
Access-Control-Allow-Origin: https://evanmoon.tistory.com
Content-Encoding: gzip
Content-Length: 699
Content-Type: text/xml; charset=utf-8
Date: Sun, 24 May 2020 11:52:33 GMT
P3P: CP='ALL DSP COR MON LAW OUR LEG DEL'
Server: Apache
Vary: Accept-Encoding
X-UA-Compatible: IE=Edge

그 이유는 브라우저는 자신이 보낸 예비 요청의 Origin서버가 응답해준 허용 Origin 을 비교한 후 이것이 같으면 본 요청을 보내게 되는데, Options 요청에는 성공했지만 Origin 이 서로 다르기 때문에 200 Ok 가 오고 CORS 에러가 발생한 것이다.

그렇다면 CORS 는 왜 굳이 본 요청 전에 예비 요청을 하면서 불필요해 보이는 리소스를 낭비하는 걸까? 아이러니하게도 서버를 보호하기 위해서다.


위와 같이 Preflight 가 없이 본 요청만 가게 된다면 서버에서 무언가 다 작업이 일어난 후에 Client 측에서 CORS 에러가 터지게 된다. 만약 본 요청이 Delete 라던지 Post 라고 하면 이미 서버에서는 관련 작업이 다 일어났는데 Client 는 에러 때문에 이를 확인할 수 없는 상황이 된다.

따라서 위와 같이 Preflight 요청을 보내 CORS 에러를 띄워주어 본 요청이 서버에 갈 수 없도록 막는 것이다.

그렇다고 매번 Preflight 요청을 보내야만 할까? 그건 아니다. Access-Contrl-Max-Age 를 설정해주면 Preflight 결과를 캐싱해 해당 시간 만큼은 본 요청만으로 접근이 가능하다.

Simple Request

Simple Request 는 Preflight 없이 본 요청으로부터 Access-Control-Allow-Origin 을 받고 CORS 정책 위반 여부를 검사하는 방식이다. 아래의 그림과 같은 방식이다.

하지만 아무 때나 단순 요청을 사용할 수 있는 것은 아니고, 특정 조건을 만족하는 경우에만 사용이 가능하다. 사실상 거의 사용이 불가능할 정도이다.

  1. 요청의 메소드는 GET, HEAD, POST 중 하나여야 한다.
  2. Accept, Accept-Language, Content-Language, Content-Type, DPR, Downlink, Save-Data, Viewport-Width, Width를 제외한 헤더를 사용하면 안된다.
  3. 만약 Content-Type를 사용하는 경우에는 application/x-www-form-urlencoded, multipart/form-data, text/plain만 허용된다.

1번 조건이야 Delete, Put, Patch 만 안쓰면 된다고 하지만, 2번 조건에서 Authorization 헤더도 사용 불가능하고, 3번에서 application/json 과 같은 요청도 보낼 수 없기 때문에 이 조건을 모두 만족 시키기란 어렵다.

Simple Request 는 그냥 이런게 있구나 정도만 하고 넘어가는게 나을 것 같다.

Credential Request

Header 에 인증 관련 정보를 함께 보낼 때 사용하는 요청이다. 동작 방식은 Preflight 와 비슷하지만 추가 옵션들이 들어간다. 그렇기 때문에 인증 관련 정보를 함께 보낼 때는 프론트와 백엔드 모두 관련 옵션을 설정해주어야 한다.

다만 이 옵션을 설정했을 경우에 반드시 해줘야 하는 하는 것이 두 가지 있다.

  1. Access-Control-Allow-Origin에는 * 를 사용할 수 없으며, 명시적인 URL이어야한다.
  2. 응답 헤더에는 반드시 Access-Control-Allow-Credentials: true가 존재해야한다.

이에 대해 설정하는 법은 아래에서 더 다뤄보도록 하고, 프론트 측에서는 axios, fetch, jquery, XMLHttpRequest 등에 따라 방법이 조금씩 다르기 때문에 아래의 포스팅을 참고하도록 하자.

[AXIOS] 📚 CORS 쿠키 전송하기 (withCredentials 옵션) : https://inpa.tistory.com/entry/AXIOS-%F0%9F%93%9A-CORS-%EC%BF%A0%ED%82%A4-%EC%A0%84%EC%86%A1withCredentials-%EC%98%B5%EC%85%98

1-4) CORS 를 해결하는 법

CORS 를 해결하는 법은 크게 3가지가 있다.

  1. Proxy 우회 (localhost 개발 환경에서만 가능)
  2. 어노테이션 활용
  3. Web Mvc Configuration 설정

일단 1번의 Proxy 를 우회하는 방법은 개발 환경에서만 사용 가능하고, 현직자에 따르면 정석적인 방법은 아니라고 한다. 어차피 개발 환경에서 잠시 사용하고 배포를 하면 문제가 다시 발생하기 때문이 아닐까라고 생각한다. 그래서 여기에서는 다루지 않고 2번과 3번에 대해 포스팅하도록 하겠다.

@CrossOrigin 어노테이션 활용

@CrossOrigin 을 Controller 에 사용해 Controller 전체에 사용할 수도, 아니면 Controller 하위 Method 에 사용할 수도 있다. 물론 하위 옵션을 통해 origins, methods, allowedHeaders, exposedHeaders, allowCredential, maxAge 를 설정할 수 있다. 옵션들의 차이는 아래에서 더 살펴보도록 하자.

@RestController
@RequiredArgsConstructor
@RequestMapping("/api/post")
@CrossOrigin(origins = "http://example.com", maxAge = 3600) //Controller 전체에 적용
public class PostController {

	private final PostService postService;
    
    @PostMapping
    public ResponseEntity<?> createPost(@RequestBody PostRequestDto postRequestDto) {
    	return postService.createPost(postRequestDto);
    }
    
    @GetMapping
    public ResponseEntity<?> getPostList() {
    	return postService.getPostList(postRequestDto);
    }
    
@RestController
@RequiredArgsConstructor
@RequestMapping("/api/post")
public class PostController {

	private final PostService postService;
    
    @PostMapping
    @CrossOrigin(origins = "http://example.com", maxAge = 2400) //Method 에 적용
    public ResponseEntity<?> createPost(@RequestBody PostRequestDto postRequestDto) {
    	return postService.createPost(postRequestDto);
    }
    
    @GetMapping
    public ResponseEntity<?> getPostList() {
    	return postService.getPostList(postRequestDto);
    }
    

WebMvcConfiguration 설정

위의 어노테이션을 사용하는 방법Controller 별, Method 별로 CORS 허용 방식을 다르게 가져갈 때 유용할 것이다. 그러나 CORS 허용을 전역적으로 사용하고 싶다면 이 방법이 훨씬 편하고 유지보수 하기에 수월하다. Configuration Class 하나만 설정해주면 되기 때문이다. WebMvcConfigurer 를 implements 하고 addCorsMapping 을 Override 해주면 끝이다. 이렇게 하는 이유는 Spring이 아닌 SpringBoot 를 사용하기 때문인데, 이러다 또 박찬호가 될 수 있으므로 각설하도록 하겠다.

@Configuration
public class WebMvcConfiguration implements WebMvcConfigurer {

    @Override
    public void addCorsMappings(CorsRegistry corsRegistry){
        corsRegistry.addMapping("/**")
                .allowedOrigins("http://localhost:3000")
                .allowedMethods("*")
                .exposedHeaders("Authorization", "RefreshToken")
                .allowCredentials(true);
    }
}

addMapping 은 Origins 이후에 오는 Path 를, allowedMethods 는 허용해주고자 하는 Method 를 가리킨다. 여기서는 와일드카드를 사용해 모두 열어주었다.

allowedOrigins 는 알다시피 허용해주고자 하는 출처이다. 현재는 http://localhost:3000 만 허용해주었지만 그 외에 다른 url 을 허용해주고 싶다면 콤마 이후에 쌍따옴표로 url 을 추가해주면 된다.

allowCredentials 를 true 로 설정하여 Authorization 과 RefreshToken 이 헤더에 담길 수 있도록 하였다.

여기서는 maxAge 를 설정해주지 않았지만 따로 설정을 해주어 Preflight 캐싱 시간을 설정할 수도 있다.

allowedHeaders 와 exposedHeaders 의 차이점은 요청의 header 냐 응답의 header 냐의 차이라고 볼 수 있다. 해당 옵션에 들어가서 보면 이렇게 나와있다.

	/**
	 Set the list of headers that a pre-flight request can list as allowed
	 for use during an actual request.
	 The special value "*" may be used to allow all headers.
	 A header name is not required to be listed if it is one of:
	 Cache-Control, Content-Language, Expires,
	 Last-Modified, or Pragma as per the CORS spec.
	 By default all headers are allowed.
	 */
	public CorsRegistration allowedHeaders(String... headers) {
		this.config.setAllowedHeaders(Arrays.asList(headers));
		return this;
	}
    
    /**
    비행 전 요청이 실제 요청 중에 사용할 수 있도록 허용된 것으로 나열할 수 있는 헤더 목록을 설정합니다.
    특수 값 "*"는 모든 헤더를 허용하는 데 사용될 수 있습니다.
    헤더 이름이 CORS 사양에 따라 캐시 제어, 콘텐츠 언어, 만료, 최종 수정 또는 프라그마 중 
    하나인 경우에는 나열할 필요가 없습니다.
	기본적으로 모든 헤더가 허용됩니다.
    */

	/**
	 Set the list of response headers other than "simple" headers, i.e.
	 Cache-Control, Content-Language, Content-Type,
	 Expires, Last-Modified, or Pragma, that an
	 actual response might have and can be exposed.
	 The special value "*" allows all headers to be exposed for
	 non-credentialed requests.
	 By default this is not set.
	 */
	public CorsRegistration exposedHeaders(String... headers) {
		this.config.setExposedHeaders(Arrays.asList(headers));
		return this;
	}
    
    /**
    단순 헤더를 제외한 응답 헤더 목록을 설정합니다. 
    실제 응답이 있을 수 있고 노출될 수 있는 캐시 제어, 내용 언어, 내용 유형, 만료, 최종 수정 또는 프래그마.
	특수 값 "*"를 사용하면 자격 증명이 없는 요청에 대해 모든 헤더를 노출할 수 있습니다.
	기본적으로 이 값은 설정되지 않습니다.
    */

파파고로 어느 정도 해석을 해봤는데 요약해보자면 allowedHeaders 는 RequestHeader 로 허용할 목록을 설정하는 것이고 exposedHeaders 는 ResponseHeader 로 허용할 목록을 설정한다고 볼 수 있다.

실제로 아래 포스팅의 사례에서처럼 exposedHeaders 에 JWT 등을 기재하지 않을 경우 브라우저에서 읽을 수 없는 것으로 확인되었다.

SpringBoot에서 CORS할 때 header, preflight 이슈 해결하기 : https://velog.io/@ojwman/spring-boot-cors-header-preflight

참고 :
[10분 테코톡] 🌳 나봄의 CORS : https://www.youtube.com/watch?v=-2TgkKYmJt4
[HTTP] Cross Origin Resource Sharing, CORS란? : https://wonit.tistory.com/307
[Spring Boot] CORS 를 해결하는 3가지 방법 (Filter, @CrossOrigin, WebMvcConfigurer) : https://wonit.tistory.com/572
[Spring Boot] 4. 로컬 개발을 위한 CORS 설정 - (1) w3c recommendation : https://letsmakemyselfprogrammer.tistory.com/44
[Spring Security] CORS : https://velog.io/@chullll/Spring-Security-CORS
CORS는 왜 이렇게 우리를 힘들게 하는걸까? : https://evan-moon.github.io/2020/05/21/about-cors/#credentialed-request
[AXIOS] 📚 CORS 쿠키 전송하기 (withCredentials 옵션) : https://inpa.tistory.com/entry/AXIOS-%F0%9F%93%9A-CORS-%EC%BF%A0%ED%82%A4-%EC%A0%84%EC%86%A1withCredentials-%EC%98%B5%EC%85%98
SpringBoot에서 CORS할 때 header, preflight 이슈 해결하기 : https://velog.io/@ojwman/spring-boot-cors-header-preflight

profile
항상 진심이지만 뭔가 안풀리는 개발 (주의! - 코린이가 배우고 이해한 내용을 끄적이는 공간이므로 실제 개념과 일부 다를 수 있음!)

0개의 댓글