아카이빙/보안

CORS 몰랐다면 클릭

biz-ninza 2024. 11. 11. 21:49

localhost:3000 이라는 브라우저 출처에서 localhost:44300/api/route 로 요청을 보냈는데 이 서버의 응답에는 Access-Control-Allow-Origin 이라는 헤더가 없으니 이상한 서버에서의 리소스라고 판단해서 위험해보이니 response 를 브라우저에서 사용하는 것을 막겠다는 뜻...

 

 


리액트 개발하다보면 필연적으로 마주치는 CORS 문제.

확실하게 정리해보기
 
 
 
목차
1. 아무 사이트에서나 요청을 받아주면 무슨일이 생길까?
2. CORS란?
3. CSRF란?
4. 스프링 시큐리티에서는 CSRF를 비활성화한다.
 
 
 
"CORS는 출처의 유효성을 검사하여 허가된 출처에서의 요청만 허용한다."


"CSRF는 인증된 사용자의 세션을 보호하여 악성 요청을 차단한다."
 
 
 

1. 아무 사이트에서나 요청을 받아주면 무슨일이 생길까?

 
웹 브라우저는 서로 다른 출처(origin)의 리소스 간 접근 제한을 막습니다.
이때 출처란 https://example.com:443과 같이 프로토콜/도메인:포트 형식을 의미합니다.
세가지가 같은 경우 동일 출처로 간주합니다.
 
만약 어떤 출처에서든 서버에 요청을 보낼 수 있게 되면 어떻게 될까요?
 
제가 사이트 (https://malicious.com) 를 하나 만들어 여러분을 속여 접속시켜보겠습니다.

여러분이 제 사이트에 접속하면, 여러분이 지금 Tistory에 로그인해서 인증받은 세션을 악용해, 제 사이트(https://malicious.com) 에서 Tistory에 API 요청을 보내고 응답으로 오는 민감한 정보를 수집하거나 결제,이체 등의 행위를 수행할 수가 있습니다.

이렇게 유저가 받은 세션을 이용해 의도하지 않은 요청을 보내는 방식을 CSRF 공격이라고 합니다.
 

<!DOCTYPE html>
<html>
<body>
  <h1>Free Gift</h1>
  <img src="http://bank.com/transfer?amount=1000&to=attacker" style="display:none;" />
</body>
</html>

(이 예시에서는 사용자가 이 html 로 만들어진 페이지를 방문하면, 이미지 태그를 통해 http://bank.com/transfer?amount=1000&to=attacker 요청이 자동으로 실행됩니다. 사용자가 이미 bank.com에 로그인되어 있다면, 이 요청은 인증된 상태로 처리됩니다.)
 
 
이러한 방식의 보안위협을 1차적으로 봉쇄하기 위해 사용하는 것이 CORS 정책입니다.
클라이언트가 자기도 모르게 가짜 사이트에 들어가 날리는 요청을 사전적으로 차단하는 것이죠.
 
혹은 악성사이트가 우리 서버에 API를 반복적으로 호출해서 서버 리소스를 소모시키고, 정상적인 사용자가 API를 이용할 수 없도록 하는 dos ( Denial of Service) 공격도 1차적으로 차단할 수 있습니다.
 
(물론 이 자체로만은 완전히 dos 를 막을 수는 없고 서버에서 날리는 요청의 수를 제한한다던지, CAPTCHA 같은 자동화 요청 방지 시스템을 쓴다던지하는 대책을 생각해야겠습니다)
 
 
 

2. CORS (Cross-Origin Resource Sharing)

 
기본적으로 타임리프나 JSP 같은 서버 사이드 렌더링 방식의 뷰를 사용한다면 유저는 해당 사이트 https://example.com에 접속하여 동적으로 렌더링된 페이지를 로드할 것이고, 같은 출처 https://example.com에 서버 요청을 보낼 것입니다.

전통적인 웹사이트에서는 요청을 하는 페이지 출처와 API서버 출처가 같으므로, 외부 사이트가 접근하는 것을 근본적으로 막는 것입니다. 
 
이것을 Same-Origin Policy 라고 합니다.

 


신뢰할 수 있는 출처를 정의하여 특정 리소스만 브라우저에서 허용하는 것입니다.
(보통 403, 405 오류가 발생한 이유는 CORS 정책 위반일 확률이 큽니다)
 
그러나 React,Vue.js 같은 현대 웹은 프로트엔드 서버와 API 서버가 다른 위치하게 됩니다.
페이지를 보여주는 프론트 웹서버와 요청을 받는 API 서버가 다릅니다.

 

따라서 해당 프론트엔드 서버에 대한 CORS 를 풀어줘야합니다.

 

// React에서 CORS 요청을 허용하도록 Axios 설정 예시
import axios from 'axios';

axios.defaults.baseURL = 'http://localhost:8080'; // Spring 백엔드 서버 주소
axios.defaults.withCredentials = true; // 쿠키를 전송하도록 설정 (필요한 경우)

리액트로 만든 프론트엔드 서버에서 위와같이 스프링부트 API서버로 요청을 전달한다면, CORS 정책으로 인해 클라이언트가 요청을 보내도 응답을 브라우저에서 사용할 수 없습니다.

 

 

 

( ✅ 25.2.11, 25.4.8 보충 )

 

CASE1. 프론트와 백엔드 모두 로컬에서 동작시켜서 프론트 서버 (http://localhost:3000) 에서 백엔드 서버 (http://localhost:8080) 로 API 요청을 했을때 IP주소는 같아도 포트번호가 다르면 다른 origin 으로 간주한다. CORS 발생

 

=> 주의해야하는게 react native 앱개발시 메트로 서버 localhost:8081 에서 로컬 스프링 서버 localhost:8080 으로 요청보내도 CORS 나옴. 포트번호가 달라도 CORS 날 수 있다는거 주의 

CASE2. 백엔드 서버, 프론트 서버 모두 컨테이너위에서 실행시키고 도커 네트워크로 묶었다면 <스프링서버 컨테이너이름>:8080 으로 접속해야한다. 도메인 자체가 달라지므로 도커 컨테이너 환경에서는 항상 CORS 발생 (컨테이너로 예를 들었지만 당연히 백엔드, 프론트서버 다른 도메인으로 배포했을 때도 같은 이유에서 CORS)

 

CASE3. https 페이지에서 http 로 요청을 보내도 이것을 다른 origin 으로 취급한다. 이것은 애초에 프로토콜이 달라서 불가능하거나 mixed contents 문제가 발생하므로 비현실적인상황이지만 여튼 스킴이 달라도 origin 이 다르다는 것을 얘기하고 싶었음.

 

스킴, 도메인, 포트 다 맞아야 같은 origin 으로 취급하고 하나라도 다르면 CORS 에러가 발생한다.

 

 


 

@Configuration
public class WebConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .cors() // CORS 설정 활성화
            .and()
            .csrf().disable(); // CSRF는 별도 설정 시 enable
    }

    @Bean
    public CorsConfigurationSource corsConfigurationSource() {
        CorsConfiguration configuration = new CorsConfiguration();
        configuration.setAllowedOrigins(List.of("http://localhost:3000")); // React 주소
        configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "DELETE"));
        configuration.setAllowCredentials(true);
        configuration.setAllowedHeaders(List.of("Authorization", "Content-Type"));

        UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
        source.registerCorsConfiguration("/**", configuration);
        return source;
    }
}

따라서 우리가 설정한 프론트엔드 서버에서의 요청은 위와같이 허용해주어야 합니다. 
origin 뿐 아니라 method, Credentials(쿠키나 인증정보), headers 등 세밀하게 허용해줄 수 있습니다.
* 이 때 CSRF를 비활성화해둔 것을 기억해주세요!
 

 

 

( ✅ 25.2.11 보충 )

정확히는 CORS는 브라우저의 보안정책임. 브라우저와 origin이 다른 서버에게서 받아온 리소스 공유를 허용할 것이냐의 문제임. 만약 Origin 이 다른 서버라도 'Access-Control-Allow-Origin' 헤더에 브라우저 Origin 을 명시해두고 리소스를 함께 반환한다면 브라우저는 클라이언트와 서버가 서로 신뢰할 수 있는 관계라고 보고 허용해줌. 

 

CORS 에러메세지만 봐도 알 수 있음. ~ 'No Acess-Control-Allow-Origin' header is present on the requested soruce. 

즉 브라우저에 요청된 소스에 해당 헤더가 없다는 뜻임. 시큐리티에서 allowedOrigin 해주는 것은 결국 Acess-Control-Allow-Origin 헤더에 프론트엔드에서 보낼 것으로 예상되는 origin 을 적어놓음으로써 서로 약속된 리소스라는걸 보여주는 것.

 

\\https://jinhoon227.github.io/posts/%EC%8A%A4%ED%94%84%EB%A7%81CORS/

 


 

3. CSRF (Cross-Site Request Forgery)

 
1절에서 언급했듯이, 제대로된 사이트에 로그인하여 세션 쿠키를 받은 이후 공격자가 가짜사이트를 들어오게 한 후 유저의 브라우저에게 남은 세션을 이용해서 세션의 주인이 원치않는 요청을 하도록 하는 것이 CSRF 공격입니다.
 
이 때 요청시 별도로 고유한 토큰을 생성해서 주면 어떨까요?
이후 클라이언트에서 요청시 이 토큰을 포함시켜 보내도록해 '정상적인 출처에서 온 요청인지' 다시 한 번 확인하는 것입니다.

즉 서버에서 클라이언트에게 세션을 줄 때 어떤 유저인지 기억하기 위한 세션정보와는 별도로 CSRF 토큰을 추가로 발급하고, 클라이언트에서 요청이오면 이 토큰이 있는지를 한 번 더 검사함으로써 신뢰성을 확보하는 것입니다.
 
다시말해 CSRF 방어의 핵심은 토큰입니다.
클라이언트 요청에 포함된 CSRF 토큰과 서버에 저장된 값이 일치한지 확인하는 과정을 거칩니다.
 
 
그런데 이 토큰은 탈취될 일이 없을까요?

 
세션 쿠키는 서버측에서 관리됩니다.
서버는 세션을 생성하고 해당 정보(세션 ID 등)를 쿠키로 전달하며 클라이언트 브라우저에 쿠키가 저장됩니다.
 
반면, CSRF 토큰은 일반적으로 클라이언트 측에서 관리됩니다.
즉 클라이언트는 CSRF 토큰을 받으면 브라우저의 세션 스토리지나 로컬 스토리지에서 관리하며 이 토큰을 서버에 보냅니다.
 
즉, 세션은 탈취되어도 CSRF 토큰은 클라이언트의 브라우저에 남아있으며 향후 클라이언트에서 직접 요청을 포함시켜 보내기 때문에 안전합니다.
 
다시 정리하면 CORS 는 허용된 출처에서만 요청하게끔하고, CSRF 는 이미 인증된 세션이 불법적으로 요청되는 것을 추가로 차단하는 방식입니다.
 
 

4. 스프링 시큐리티에서는 CSRF를 비활성화한다.

 
전통적인 웹 애플리케이션은 세션을 기반으로 클라이언트를 기억하고 연결을 유지하며 다음 요청을 처리합니다.
CSRF 공격은 기본적으로 클라이언트 이 브라우저의 세션을 이용합니다.

한편 REST API 방식으로 프로젝트를 설계한다면 세션을 유지할 필요가 없습니다.
REST API는 stateless 한 방식으로 독립된 요청, 응답을 처리하는 아키텍처이므로 세션이 아닌 JWT 방식을 이용하는게 잘 어울립니다.
 
(물론 JWT를 이용하는 근본적인 이유는 세션 서버를 두지않으므로 성능이 향상되고, MSA 환경 등에서 서버 확장시 유리한 이유가 더욱 크겠습니다만)
 
JWT 를 사용한다는 것은 세션방식을 사용하지 않는 것이기 때문에 원천적으로 CSRF 문제를 고민하지 않아도 됩니다.

따라서 스프링시큐리티, 더 정확히는 JWT 방식의 스프링 시큐리티 필터를 사용한다면 CSRF를 비활성화 하는 것입니다.
 
만약 CSRF 토큰을 활성화하면 클라이언트가 모든 요청에 별도로 필요도 없는 CSRF 토큰을 담아 보내야하므로 귀찮아지고 서버 측에서도 쓸모없는 CSRF 토큰 발급, 관리, 검증 등에 리소스를 낭비하게 됩니다.

게다가, CSRF 토큰을 사용한다는 것은 특정 브라우저를 서버에서 기억한다는 stateless 방식이므로 REST API 철학과 위배되겠네요