Spring security/01.Security

10. CSRF 처리

  • -

이번 포스트에서는 대표적인 보안 공격 중 하나인 CSRF(Cross Site Request Forgery)의 대책에 대해 살펴보자.

CSRF 대책

 

CSRF?

CSRF(Cross Site Request Forgery: 크로스 사이트 요청 위조)란 웹에서 행해지는 대표적인 해커의 공격 유형 중 하나이다. 다음 이미지는 어떻게 CSRF 공격이 일어나는지 보여준다.

자료 출처: https://www.indusface.com/blog/cross-site-request-forgery-csrf-sleeping-giant-hackers-world/

 

  1. www.fictitiousbank.com의 고객인 bob은 정상 은행 사이트에 로그인 하고 정상적인 쿠키가 발행된다.
  2. 우리의 희생자 Bob은 "저리에 돈을 빌려준다"는 혹하는 이메일을 받고 첨부된 이미지를 클릭한다.
  3. 하지만 그 이미지의 경로에는 공격자에게 이체하는 동작이 연결되어있었다. ㅜㅜ
  4. 이제 Bob의 이체 동작은 쿠키를 타고 www.fictitiousbank.com 서버로 정상적으로 날아간다.
  5. 서버에서는 Bob의 정당한 행동으로 파악되기 때문에 아무런 의심 없이 처리하고 밥은 망하게 된다. ㅠㅠ

이런 일을 방지하려면 어떤 절차가 필요할까?

서버에서는 이게 정말 Bob이 한 일인지 확인할 필요가 있다.  Spring Security에서는 CSRF 토큰이라는 것을 통해 이것을 처리한다.

 

CSRF 토큰

CSRF 공격을 방지하기 위해서 Spring Security가 채택한 방법은 CSRF 토큰이라는 인증 정보를 이용하는 것이다.

CSRF 토큰 활용 절차

 

  1. 입력 화면(login form)을 요청하는 request를 서버로 날린다.
  2. 서버는 요청을 받고 검증용의 CSRF 토큰 "ABC"를 생성한다.
  3. 서버는 클라이언트로 응답할 때 CSRF 토큰을 request에 담아서 넘긴다. (<form>은 이 토큰을 저장해줘야 한다.)
  4. username/password와 함께 CSRF 토큰이 서버로 전송된다.
  5. 서버는 파라미터에 묻어온 토큰과 서버가 가지고 있던 토큰이 일치하는지 판단하고 불일치하면 request를 거부한다.

물론 이런 과정을 개발자가 직접 처리하지는 않는다. Spring Security 프레임워크가 대부분 일을 처리하고 개발자가 할 일은 적절한 설정(CSRF 활성화)과 토큰 관리이다.

더보기

Spring Security의 CSRF 처리

다음은 Spring Security의 CSRF 처리 절차이다.

CsrfFilter의 처리 절차
  1. 먼저, 지속된 CsrfToken이 나중에 (4)에서 로드될 수 있도록 CsrfTokenRepository에 대한 참조를 보유하는 DeferredCsrfToken이 로드된다.
  2. DeferredCsrfToken에서 생성된 Supplier<CsrfToken>이 CsrfTokenRequestHandler에 전달된다. 이 헨들러는 요청에 CSRF토큰을 추가해서 애플리케이션에서 사용할 수 있도록 한다.
  3. 메인 CSRF 보호 처리가 시작되고 현재 요청에 CSRF 보호가 필요한지 확인한다. 필요하지 않은 경우 CsrfFilter 처리가 종료되고 다음 필터 체인이 계속진행된다.
  4. CSRF 보호가 필요한 경우 최종적으로 DeferredCsrfToken에서 지속된 CsrfToken이 로드된다.
  5. 클라이언트가 제공한 실제 CSRF 토큰을 CsrfTokenRequestHandler를 사용하여 확인되고 실제 CSRF 토큰이 저장된 CsrfToken과 비교된다.
  6. 유효하면 필터 체인이 계속되고 처리가 종료된다.
  7. 실제 CSRF 토큰이 유효하지 않거나 누락된 경우 AccessDeniedException이 AccessDeniedHandler로 전달되고 처리가 종료된다.

 

CSRF 처리

 

CSRF 처리 활성화 및 저장소 지정

HttpSecurity#csrf 메서드는 csrf를 비활성화 시키거나 저장소를 지정해서 활성화 시킬 수 있다.

http.csrf(csrf -> csrf.disable());    // csrf 비활성화
http.csrf(Customizer.withDefaults()); // csrf 활성화 - 기본 값

Customizer.withDefaults()의 기본 설정이 갖는 내용은 다음과 같다.

http.csrf(csrf -> {
    csrf
        // CsrfTokenRepository 설정 - 기본값은 HttpSessionCsrfTokenRepository
        .csrfTokenRepository(new HttpSessionCsrfTokenRepository())
        
        // CSRF 검증에서 제외할 요청 패턴들
        .ignoringRequestMatchers(
            "/logout",  // 로그아웃
            "/csrf",    // CSRF 토큰 조회
            "/favicon.ico",
            "/**/*.png", "/**/*.gif", "/**/*.jpg", "/**/*.css", "/**/*.js"  // 정적 리소스
        )
        
        // CSRF 토큰 생성 및 검증을 처리할 필터
        .csrfTokenRequestHandler(new XorCsrfTokenRequestAttributeHandler());
});

 

CSRF 처리 커스터마이징

CSRF 처리에 대한 부분도 당연히 커스터마이징 가능하다. 일단 저장소를 기본 값인 세션이 아닌 Cookie 로 변경할 수 있다. 또한 REST 서비스를 주로 한다면 /api/** 아래로 오는 요청은 CSRF 검증에서 제외할 수도 있다.

http.csrf(csrf -> csrf
    .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
    .ignoringRequestMatchers("/api/**")  // API 요청은 CSRF 검증 제외
);

XorCsrfTokenRequestAttributeHandler는 HttpServletRequest 파라미터로 _csrf를 추가해서 CsrfToken을 사용할 수 있게 한다. 또한 만약 요청 헤더에서 값을 가져올 때는 X-CSRF-TOKEN 또는 X-XSRF-TOKEN 헤더 중 하나에서 CSRF 토큰을 가져온다.

다음은 CsrfToken 객체의 속성이다.

속성 내용 기본 값
token CSRF 토큰의 값  
parameterName CSRF 토큰이 저장된 request parameter이름 _csrf
headerName 헤더를 통해 토큰을 송신할 때 헤더명 X-CSRF-TOKEN

SecurityConfig에서 csrf 설정을 다음과 같이 수정해보자.

http.csrf(csrf -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository())
    .ignoringRequestMatchers("/logout", // logout 요청은 무시
            "/csrf", // csrf 요청은 무시
            "/favicon.ico", "/**/*.png", "/**/*.jpg", "/**/*.js", "/**/*.css", // 정적 리소스
            "/api/**" // API 요청
    ));

 

화면 수정

이제 마지막으로 할 일은 화면단의 form에서 csrf 관련 태그들을 작성해주면 된다. <form>이 여러 곳에 등장하기 때문에 태그를 include에 작성해서 재사용하자.

{{! csrf 템플릿 : /templates/include/csrf.html}}
<input type="hidden" name="{{_csrf.parameterName}}" value="{{_csrf.token}}"/>

이제 <form>에서 csrf.html을 include 시키면 작업 완료다.

{{^SPRING_SECURITY_CONTEXT}}
<form action="/login" method="post">
    {{>include/csrf}}
    <label for="username">아이디:</label>
    <input type="text" id="username" name="username" required />
    <label for="password">비밀번호:</label>
    <input type="password" id="password" name="password" required />
    <button type="submit">로그인</button>
</form>
{{/SPRING_SECURITY_CONTEXT}}

이제 /login을 실행시키고 개발자도구를 이용해서 <form>을 분석해보면 csrf 관련 태그가 생성된 것을 확인할 수 있다.

_csrf관련 내용이 추가된 폼

 

그리고 추가로 logout을 호출할 때 post 방식으로 변경하고 csrf 토큰을 넘겨줘야 한다. HttpSecurity의 기본 logout 동작은 csrf 토큰을 확인하기 때문이다.

<form action="/logout" method="post">
    {{>include/csrf}}
    <button type="submit">로그아웃</button>
</form>

 

테스트 수정

csrf를 활성화 시켰기 때문에 테스트 과정에서도 csrf 토큰이 삽입되어야 한다. 이를 위해 mock 요청을 만들 때 with를 통해 mock csrf 정보를 전달한다.

@Test
  @DisplayName("로그인에 실패하면 /login?error로 redirect되고 redirect된 페이지에서 메시지 확인하기")
  void 인증_처리_테스트_로그인실패() throws Exception {
    mockMvc
        .perform(
            MockMvcRequestBuilders.post("/login")
                .with(SecurityMockMvcRequestPostProcessors.csrf())
                .param("username", "doding")
                .param("password", "wrong"))
        .andDo(MockMvcResultHandlers.print()) // 실행 결과를 콘솔에 출력
        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
        .andExpect(MockMvcResultMatchers.redirectedUrl("/login?error"));

    mockMvc
        .perform(MockMvcRequestBuilders.get("/login").param("error", ""))
        .andExpect(MockMvcResultMatchers.model().attribute("error", "ID/비밀번호를 확인하세요."));
  }

  @Test
  @DisplayName("로그인 성공한 사용자의 로그아웃 처리")
  void 로그인성공_로그아웃처리() throws Exception {
    mockMvc
        .perform(
            MockMvcRequestBuilders.post("/login")
                .with(SecurityMockMvcRequestPostProcessors.csrf())
                .param("username", "doding")
                .param("password", "1234"))
        .andDo(MockMvcResultHandlers.print()) // 실행 결과를 콘솔에 출력
        .andExpect(MockMvcResultMatchers.status().is3xxRedirection())
        .andExpect(MockMvcResultMatchers.redirectedUrl("/"));

    mockMvc
        .perform(
            MockMvcRequestBuilders.post("/logout")
                .with(SecurityMockMvcRequestPostProcessors.csrf()))
        .andExpect(MockMvcResultMatchers.redirectedUrl("/login?logout"));

    mockMvc
        .perform(MockMvcRequestBuilders.get("/login").param("logout", ""))
        .andExpect(MockMvcResultMatchers.view().name("user/login"))
        .andExpect(MockMvcResultMatchers.model().attribute("logout", "로그아웃되었습니다."));
  }
Contents

포스팅 주소를 복사했습니다

이 글이 도움이 되었다면 공감 부탁드립니다.