[Spring Security6] CORS, CSRF
브라우저는 기본적으로 Same-Origin Policy를 따르기에 브라우저에서 로드된 웹 페이지가 다른 출처의 리소스에 대한 요청을 제한한다. (보안을 위해 사용한다)
Cross Origin Resource Sharing (CORS) Same-Origin Policy 제약을 특정 조건 하에 완화시키기 위한 메커니즘으로, 다른 Origin에서 실행 중인 웹 페이지가 특정 리소스에 액세스 할 수 있도록 허용한다.
애플리케이션을 개발하다 보면 외부 출처에서 리소스나 api에 접근해야 하는 경우가 있는데, CORS는 이런 요구사항을 안전하게 만족시키기 위해 도입됐다.
여기서 사용되는 origin은 통신에 사용되는 프로토콜, 도메인, 포트번호를 포함하는 개념으로 세 가지 구성 요소가 동일하다면 두 URL은 같은 origin에서 오는 요청으로 인식된다.
예시를 하나 살펴보자.
리액트로 프론트엔드를 개발하고 스프링부트로 백엔드를 개발했다고 하자.
각각 애플리케이션들은 localhost:3000과 localhost:8080 에 배포됐다.
같은 프로토콜과 도메인을 사용하지만 포트번호가 다르기에 Same-Origin Policy에 위반되고 localhost:3000에서 localhost:8080으로 정보를 요청하는 경우 요청이 제한된다.
CORS는 HTTP 헤더를 통해 동작한다.
Access-Control-Allow-Origin : 어떤 출처가 리소스에 액세스 할 수 있는지 지정한다.
Access-Control-Allow-Methods : 리소스에 대한 액세스를 허용하는 HTTP 메서드를 지정한다.
Access-Control-Allow-Headers : 리소스에 액세서할 때 허용하는 헤더를 지정한다.
Access-Control-Allow-Credentials : 쿠키, HTTP 인증 등 자격 증명과 함께 요청을 허용할지 지정한다.
Access-Control-Max-Age : Preflight 요청의 결과를 캐싱할 시간을 지정한다.
여기서 Preflight 요청은 실제 요청을 보내기 전에 수행되는 요청으로, 실제 요청이 서버에 수락될지 확인하는 요청이다.
HTTP 메서드 중 하나인 OPTIONS 를 사용하고 실제 요청에 사용될 메서드와 헤더를 나타내는 헤더들을 포함한다.
서버가 실제 요청을 허용한다면 브라우저는 Preflight 요청 이후 실제 요청을 보낸다. (CORS 정책에 위반되는 경우 실제 요청을 보내지 않는다)
@RestController
@RequestMapping("/api/items")
@CrossOrigin(origins = "http://localhost:3000")
public class ItemController {
@GetMapping("/{id}")
public Item getItem(@PathVariable Long id) {
return null;
}
@PostMapping("/")
public void createItem(@RequestBody Item item) {
return null
}
}
@CrossOrigin 애너테이션을 사용해 컨트롤러 클래스나 메서드 단위로 CORS를 설정할 수 있다.
origins : 허용할 출처를 지정한다.
methods : 허용할 HTTP 메서드를 지정한다.
allowedHeaders : 허용할 요청 헤더를 지정한다.
exposedHeaders : 브라우저에 노출할 응답 헤더를 지정한다.
allowCredentials : 자격 증명과 함께 요청 여부를 설정한다.
maxAge : preflight 요청의 응답을 캐시할 시간을 설정한다.
실제 배포 환경에서는 @CrossOrigin 보다는 설정 파일에서 CORS 관련 설정을 진행한다.
일관성, 유지보수 측면에서 이 방법이 더 합리적이다.
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
http.securityContext((context) -> context
.requireExplicitSave(false))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowCredentials(true);
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L);
return config;
}
}))
.authorizeHttpRequests((requests)->requests
.requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards", "/user").authenticated()
.requestMatchers("/notices", "/contact", "/register").permitAll())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
@CrossOrigin 애너테이션으로 설정하는 것과 크게 다르지 않다.
Cross-Site Request Forgery (CSRF) 공격은 세션을 사용한다.
피해자는 특정 웹 사이트에 로그인 된 상태고, 공격자는 피해자가 아무 의심 없이 볼 수 있는 공격 스크립트를 준비한다.
이 공격 스크립트는 피해자가 로그인 한 사이트에 대해 특정 요청을 수행한다. (피해자는 세션 쿠키를 가지고있다)
img태그 등에 스크립트를 작성해 특정 form을 제출하는 스크립트 등을 사용한다.
스프링 시큐리티는 기본적으로 CSRF 방어 기능을 활성화한다.
모든 POST PUT 등 데이터를 수정하는 요청은 요청에 CSRF 토큰 값을 포함하고 있어야 서버가 해당 요청을 받아들인다.
CSRF 토큰은 시큐리티가 CSRF 공격을 방어하기 위해 사용하는 값으로, 사용자가 웹 애플리케이션에 로그인 할 때 CSRF 토큰을 생성하고 세션에 저장한 후 클라이언트에게 전달한다.
클라이언트는 서버로 요청을 보낼 때 CSRF 토큰을 포함한다.
서버는 요청을 검증할 때 CSRF 토큰과 세션에 저장된 토큰을 비교해 요청을 받아들일지를 결정한다.
즉, 사용자가 로그인 시 세션 쿠키와 CSRF 토큰 쿠키를 가지게 되고, 공격자는 CSRF 토큰 쿠키에 대한 정보를 가져갈 수 없어 공격에 실패한다.
CSRF 공격의 핵심은 이미 인증된 세션 상태를 악용해서 요청을 보내는 점이다.
서버에서 발행한 CSRF 토큰 값을 탈취해서 함께 요청한다면 공격에 성공할 수 있다.
따라서 비밀번호 변경, 계좌이체 등 중요한 작업을 수행할 때는 사용자에게 비밀번호를 다시 한 번 입력하도록 요청하는 등 추가 요청을 수행하도록 해서 CSRF 공격을 예방하거나, 토큰을 특정 시점에서 바꿔 공격자가 과거 토큰을 사용할 수 없도록 하는 등 추가 방어 로직을 작성하자.
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception {
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName("_csrf");
http.securityContext((context) -> context
.requireExplicitSave(false))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowCredentials(true);
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L);
return config;
}
})).csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers("/contact", "/register")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class)
.authorizeHttpRequests((requests)->requests
.requestMatchers("/myAccount", "/myBalance", "/myLoans", "/myCards", "/user").authenticated()
.requestMatchers("/notices", "/contact", "/register").permitAll())
.formLogin(Customizer.withDefaults())
.httpBasic(Customizer.withDefaults());
return http.build();
}
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";
...
}
public class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if(null != csrfToken.getHeaderName()){
response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
}
filterChain.doFilter(request, response);
}
}
SecurityContext는 현재 인증된 사용자의 보안 관련 세부 정보를 저장하는 공간이다.
requireExplicitSave(false) 메서드로 개발자가 명시적으로 SecurityContext의 변경사항을 저장함을 설정한다.
sessionManagement 메서드로 요청이 들어올 때 마다 세션을 생성하도록 설정한다.
CSRF 토큰을 발행하기 위해 CsrfTokenRequestAttributeHandler 객체를 생성하고 생성할 토큰의 이름을 설정하자.
setCsrfRequestAttributeName 으로 설정된 값은 HTTP 요청에서 CSRF 토큰 값을 가져올 때 사용하는 키로 사용된다.
회원가입 등 특정 페이지는 POST 메서드지만 CSRF로 딱히 공격할 부분이 없다.
ignoringRequestMatchers 메서드로 방어하지 않을 URL을 지정해주자.
csrfTokenRepository 메서드는 CSRF 토큰의 생성, 저장에 사용되는 구현체를 설정한다.
서버에서 어떻게 CSRF 토큰을 설정할지, 생성된 토큰을 어디에 저장할지, 요청에 포함되는 CSRF 토큰을 어떻게 검증할지를 결정한다.
CookieCsrfTokenRepository 구현체는 CSRF 토큰을 쿠키에 저장하고,
HttpSessionCsrfTokenRepository 구현체는 CSRF 토큰을 HttpSession (서버의 세션 저장소) 에 저장한다. 기본 설정으로 사용된다.
쿠키는 클라이언트 측에 저장된다.
RESTful 서비스처럼 상태가 없는 서비스에서는 서버 측에서 별도의 세션 관리를 수행하지 않는다.
쿠키에 저장될 경우 스크립트를 통해 쿠키에 접근할 수 있어 XSS 공격에 취약해질 수 있다.
클라이언트의 요청에 포함되는 쿠키에 CSRF 토큰 값이 있고, 서버에 저장된 토큰 값과 요청에 포함된 토큰 값이 일치하는지 확인해 요청을 검증한다.
사실 검증을 위해 서버에서도 CSRF 토큰 값을 저장하고 있어 완벽하게 Stateless는 아니다.
세션에 토큰을 저장할 경우 상대적으로 보안에 유리하다.
하지만 서버는 각 클라이언트에 대한 상태를 유지해야 하고, 서버가 분산된 환경에서는 세션 정보를 공유하기 위해 추가적인 구성이 필요할 수 있다.
사용자가 로그인 시 서버는 사용자별로 유일한 CSRF 토큰 값을 생성하고, 이 토큰은 서버에 저장한다.
서버는 토큰 값을 응답의 일부로 클라이언트에게 전달하고, 클라이언트는 이 값을 헤더나 form의 hidden 필드에 저장한다.
나중에 서버에게 요청 시 토큰 값을 함께 전송하고, 서버는 토큰 값을 검증한다.
토큰 값을 서버에서 저장할지 클라이언트에서 저장할지는 서버가 Stateful이냐 Stateless의 차이이다.
CsrfCookieFilter 클래스는 CSRF 토큰을 처리하는 필터를 구현한다.
doFilterInternal 메서드는 HTTP 요청이 들어올 때 마다 실행되고 클라이언트에게 CSRF 토큰을 전달하는 역할을 수행한다.
응답 헤더에 토큰을 추가하면 CookieCsrfTokenRepository 를 통해 토큰을 쿠키에 저장한다.
'Spring > Spring Security' 카테고리의 다른 글
[Spring Security6] Json Web Token (0) | 2023.08.11 |
---|---|
[Spring Security6] Authorization과 Filter (0) | 2023.08.09 |
[Spring Security6] 사용자 인증 (0) | 2023.08.06 |
[Spring Security6] Password Encoder (0) | 2023.08.05 |
[Spring Security6] 주요 구성요소 (0) | 2023.08.04 |
댓글
이 글 공유하기
다른 글
-
[Spring Security6] Json Web Token
[Spring Security6] Json Web Token
2023.08.11 -
[Spring Security6] Authorization과 Filter
[Spring Security6] Authorization과 Filter
2023.08.09 -
[Spring Security6] 사용자 인증
[Spring Security6] 사용자 인증
2023.08.06 -
[Spring Security6] Password Encoder
[Spring Security6] Password Encoder
2023.08.05