본문 바로가기
Spring Security

[Spring Security] CORS, CSRF란?

by 개발자 JACOB 2023. 6. 14.

CORS란?

CORS란 “Cross-Origin Resource Sharing”의 약자입니다. CORS는 프로토콜인데, 서로 다른 origin일 시 리소스와 상호 작용하기 위해 클라이언트인 브라우저에서 실행되는 스크립트입니다. 예를 들어 UI 앱에서 서로 다른 도메인인 API를 호출할 시 CORS로 인해 기본적으로 차단됩니다. 이는 대부분의 브라우저에서 구현되는 W3C의 스펙입니다.

따라서 CORS는 보안이나 공격과 같은 문제가 아니라 서로 다른 Orgin 간의 데이터 및 통신을 할 때 브라우저에서 이를 중지하기 위해 제공하는 기본 보호 기능입니다.

예를 들어 큰 규모의 IT 기업일 경우 백엔드 서버와 프론트엔드 서버의 IP가 서로 다릅니다. 이때 프론트엔드 서버에서 클라이언트가 로그인을 했을 때, 백엔드의 로그인 API가 호출될 것입니다. 이때 백엔드에서 프론트엔드 도메인을 CORS 허용 설정하지 않으면 접근이 차단되는 것입니다. 반대로 말하자면 JSP, 타임리프와 같은 백엔드와 프론트엔드가 같이 구동되는 서버 사이드 렌더링의 경우라면, CORS 설정은 신경쓰지 않아도 무방합니다. 왜냐면 하나의 아이피에 프론트와 백엔드가 구동되기 때문입니다.

그림에서는 hello.world.com 출처에서 bye.world.com이란 다른 출처의 api를 호출했습니다. 이때 CORS를 허용해야 api를 통해 리소스에 접근할 수 있습니다.  따라서 백엔드에서 코드를 추가해 CORS를 허용해야 합니다.

1. @CrossOrgin()

해당 어노테이션을 클래스나 메소드에 붙이면, 해당 클래스와 메소드는 CORS를 허용합니다.

  • @CrossOrgin(origins = "도메인명")
    특정 도메인만 허용
  • @CrossOrgin(origins = "*")
    모두 허용

하지만 메소드마다 애노테이션을 붙이는 것은 번잡합니다. 따라서 구성 정보를 설정하는 것이 간편합니다.

2. Configuration 설정

SecurityFilterChain을 스프링 빈으로 등록할 때 CORS와 관련된 설정을 할 수 있습니다. 아래는 스프링부트 3.0 이상입니다.

@Configuration
public class ProjectSecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                        CorsConfiguration config = new CorsConfiguration();
                        config.setAllowedOrigins(Collections.singletonList("http://hello.world.com"));
                        config.setAllowedMethods(Collections.singletonList("*"));
                        config.setAllowCredentials(true);
                        config.setAllowedHeaders(Collections.singletonList("*"));
                        config.setMaxAge(3600L); //1시간
                        return config;
                    }
                }))
                .authorizeHttpRequests((requests) -> requests
                .requestMatchers("/myHome", "/myAccount", "/user").authenticated()
                .requestMatchers("/register").permitAll())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults())
                .build();
    }
    
    ...
}

이렇게 setAllowedOrigins 메소드에서 CORS를 허용할 도메인을 기입하면 됩니다.

정리
CORS는 보안 또는 해커의 공격과 관련된 것이 아닌, 서로 다른 도메인에서 데이터를 통신하려고 할 때 브라우저에서 이를 중지하기 위해 발생하는 기본 보호 기능입니다.

2. CSRF란?

일반적인 Cross-Site Request Forgery”(CSRF 또는 XSRF)은 명시적인 동의 없이 사용자를 대신하여 웹 어플리케이션에서 악의적인 행동을 취하는 공격입니다. 일반적으로 사용자의 신원을 직접적으로 도용하지는 않지만, 사용자의 의지와 상관없이 악의적인 행동을 취합니다. 웹사이트 coupang.com과 해커의 웹사이트 hacker.com을 한 고객이 사용하고 있다고 가정하겠습니다.

 

1. 쿠팡의 고객이 coupang.com에 로그인할 때 백엔드 서버에서는 브라우저에 저장할 쿠키를 줍니다.

2. 이때 위의 쿠팡 고객은 같은 브라우저의 다른 탭으로 hacker.com 웹사이트에 접속했습니다.

3. 고객이 악의적인 링크를 누르자마자, coupang.com에 악의적인 요청이 갑니다. 쿠팡의 백엔드 서버 입장에서는 로그인 쿠키가 이미 동일한 브라우저에 존재하고 있고 해커의 계정 정보 변경과 같은 악의적인 요청이 동일한 coupang.com이란 도메인에서 이루어지고 있기 때문에, 고객의 요청인지 해커의 요청인지 구별할 수 없습니다.

해결법

CSRF 공격을 처리하기 위해서는, 해당 HTTP 요청이 사용자 인터페이스(UI)를 통해 이루어졌는지 확인해야 합니다. 이때 등장하는 것이 CSRF 토큰입니다. CSRF 토큰은 CSRF 공격을 방지하는 데 사용되는 임의의 토큰입니다. 토큰은 사용자 세션마다 고유해야 하며, 쉽게 추측할 수 없도록 긴 임의의 값이어야 합니다. 다시 예시를 보겠습니다.

1. 쿠팡의 고객이 coupang.com에 로그인할 때 백엔드 서버에서는 브라우저에 저장할 쿠키를 줍니다. 이 쿠키에는 특정 유저 세션을 위해 임의로 생성된 고유한 CSRF 토큰이 저장됩니다. CSRF 토큰은 세션 쿠키에 노출되지 않도록 HTML 폼에 숨겨진 매개변수 내에 삽입됩니다.

2. 이때 위의 쿠팡 고객은 같은 브라우저의 다른 탭으로 hacker.com 웹사이트에 접속했습니다.

3. 고객이 악의적인 링크를 누르자마자, coupang.com에 악의적인 요청이 갑니다. 쿠팡의 백엔드 서버 입장에서는 로그인 쿠키가 이미 동일한 브라우저에 존재하고 있고 해커의 계정 정보 변경과 같은 악의적인 요청이 동일한 coupang.com이란 도메인에서 이루어지고 있기 때문에, 고객의 요청인지 해커의 요청인지 구별할 수 없습니다. 따라서 백엔드 서버에서는 Authentication 쿠키 외에  CSRF 토큰이 담긴 쿠키를 확인합니다. 이 쿠키는 최초로 생성될 때와 동일해야 합니다.

 

CSRF 토큰은 최종 클라이언트의 요청이 동일한 UI에서 오는지 확인하기 위해 어플리케이션 서버에서 사용됩니다. 어플리케이션 서버는 CSRF 토큰이 일치하지 않으면 요청을 거부합니다.

해커가 자바스크립트를 실행할 때에는 hacker.com이란 도메인에서 실행됩니다. 쿠팡의 고객이 로그인할 때 쿠키에 저장된 CSRF 토큰은 coupang.com이란 도메인에 저장되었습니다. 해커의 자바스크립트가 실행되는 hacker.com이란 도메인은 이러한 쿠키가 저장되지 않았습니다. 이러한 제약 때문에 해커가 아무리 hacker.com 도메인에서 자바스크립트를 실행해도 쿠팡의 백엔드 서버에서는 CSRF 쿠키의 토큰이 있어야 로직을 수행하기 때문에 동작하지 않습니다.

Configuration 설정

@Configuration
public class ProjectSecurityConfig {
    @Bean
    SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
        return http
                .cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
                    @Override
                    public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
                        CorsConfiguration config = new CorsConfiguration();
                        config.setAllowedOrigins(Collections.singletonList("http://localhost:9090"));
                        config.setAllowedMethods(Collections.singletonList("*"));
                        config.setAllowCredentials(true);
                        config.setAllowedHeaders(Collections.singletonList("*"));
                        config.setMaxAge(3600L); //1시간
                        return config;
                    }
                }))
                .csrf(csrf -> csrf
                .csrfTokenRequestHandler(requestHandler)
                .ignoringRequestMatchers("/contact", "/register")
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
                /* BasicAuthenticationFilter 이후에 CsrfCookieFilter를 실행한다.
                   BasicAuthenticationFilter는 로그인 이후에 동작하는 필터*/
                ).addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
                .authorizeHttpRequests((requests) -> requests
                        .requestMatchers("/myHome", "/myAccount", "/user").authenticated()
                        .requestMatchers("/register").permitAll())
                .formLogin(Customizer.withDefaults())
                .httpBasic(Customizer.withDefaults())
                .build();
    }
}

csrf 관련 설정 람다가 추가되었습니다.

.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())

관련 설정은 프론트엔드와 백엔드 서버가 다를 때, 프론트엔드에서 자바스크립트로 백엔드의 쿠키를 읽으려고 할 때 읽는 것을 허용하기 위한 설정입니다. CookieCsrfTokenRepository의 주석에서도 해당 옵션을 허용할 것을 권장하고 있습니다.

/**
 * A {@link CsrfTokenRepository} that persists the CSRF token in a cookie named
 * "XSRF-TOKEN" and reads from the header "X-XSRF-TOKEN" following the conventions of
 * AngularJS. When using with AngularJS be sure to use {@link #withHttpOnlyFalse()}.
 */
public final class CookieCsrfTokenRepository implements CsrfTokenRepository {
    static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";

	static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";

	static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
}

CsrfCookieFilter입니다.

@Slf4j
public class CsrfCookieFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        /**
         * CsrfToken을 헤더에 보내면, Spring Security는 해당 토큰과 동일한 쿠키를 생성해서 반환한다.
         */
        CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
        if(csrfToken.getHeaderName() != null){
            log.info("csrfToken.getHeaderName() = {}, csrfToken.getToken() = {}", csrfToken.getHeaderName(), csrfToken.getToken());
            response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
        }
        filterChain.doFilter(request, response);
    }
}

필터에서는 먼저 사용 가능한 HttpServletRequest에서 Csrf 토큰을 읽고 있습니다. 그 다음 스프링 시큐리티가 csrf 토큰을 생성한 경우 (csrf 토큰이 null이 아니라면) 응답 헤더에 헤더의 이름과 값을 채웁니다. 이후에는 필터 체인을 호출하는데 이렇게 한다면 응답을 보낼 때마다 Http 헤더에 csrf 토큰 값이 표시됩니다.

이 로직에서 응답 헤더만 보냈고 csrf 토큰 쿠키는 생성하지 않습니다. 스프링 시큐리티는 개발자가 응답 해더에 CsrfToken의 값을 채울 때마다 CSRF 쿠키를 자동으로 만들어줍니다. 한번 웹사이트에 로그인한 후 로그를 확인해보겠습니다.

Http Header에 X-XSRF-TOKEN이란 이름과 임의의 값을 설정한 모습입니다. 개발자 모드를 통해 헤더의 값을 보겠습니다.

Http Header에 X-Xsrf-Token이란 이름과 임의의 값이 성공적으로 설정되었습니다. 한번 쿠키도 만들어졌는지 볼까요?

개발자는 쿠키를 만드는 로직을 수행하지 않았지만, 응답 해더에 CsrfToken의 값을 채웠기 때문에 스프링 시큐리티가 자동으로 쿠키를 만들어준 모습입니다. 백엔드 서버의 로그에 찍힌 토큰의 값과, 쿠키의 값이 9baf...로 동일한 것을 확인할 수 있습니다.