CSRF 공격을 막기 위한 CSRF 토큰
매번 스프링 시큐리티 + JWT를 사용했어서 항상 아래의 코드는 default로 깔고 들어갔었다.
.csrf(AbstractHttpConfigurer::disable)
즉 csrf는 기본적으로 disable을 하고 들어갔기에 자세한 내용을 다루지도 않았었다.
그런데 이번에 회사에서 SESSION을 사용하는데 스프링 시큐리티를 도입하면서 CSRF 토큰을 사용하기 시작했다.
(세션 vs JWT는 현재 정리 중에 있습니다.)
그래서 CSRF가 무엇인지, CSRF 토큰은 무엇인지, 스프링 시큐리티에서는 어떤 기능을 제공해 주는지 짚고 넘어가려고 한다.
CSRF(Cross Site Request Forgery) 공격
Cross Site Request Forgery, 즉 사이트 간 요청 위조 공격이라고 한다. 의미를 이해하기 전에 어떤 방법으로 공격을 하는지 예시를 같이 살펴보자.
아래 예시는 은행 웹 사이트에서 현재 로그인한 사용자로부터 다른 은행 계좌로 돈을 이체할 수 있는 form이 있다고 가정한다.
method로 post 요청을 보내고 3개의 입력 값을 받아서 submit을 하는 form 양식이다.
<form method="post"action="/transfer">
<input type="text" name="amount"/>
<input type="text" name="routingNumber"/>
<input type="text" name="account"/>
<input type="submit" value="Transfer"/>
</form>
실제로 HTTP 요청을 보내면 아래와 같이 날아가게 된다.
POST /transfer HTTP/1.1
Host: bank.example.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded
amount=100.00&routingNumber=1234&account=9876
이제 은행 웹 사이트에 인증한 다음 로그아웃하지 않고 악의적인 웹 사이트를 방문한다고 가정해보자.
아래는 악의적인 웹 사이트의 form 양식이다.
<form method="post" action="https://bank.example.com/transfer">
<input type="hidden" name="amount" value="100.00"/>
<input type="hidden" name="routingNumber" value="evilsRoutingNumber"/>
<input type="hidden" name="account" value="evilsAccountNumber"/>
<input type="submit" value="Win Money!"/>
</form>
돈을 따고 싶어서 submit을 누르면 의도치 않게 악의적인 사용자에게 100달러를 송금하게 된다.
이전에 인증을 받은 상태이기에 바로 출금이 가능하게 된 것이다. 해당 웹 사이트가 내 브라우저의 쿠키를 볼 수는 없지만 은행과 관련된 쿠키는 자동으로 전송되기에 문제가 되는 것이다.
위 예시는 Spring Security 공식 문서에서 제공하는 예시였다. 이게 이해가 될 수도, 안 될 수도 있다.
https://docs.spring.io/spring-security/reference/features/exploits/csrf.html
Cross Site Request Forgery (CSRF) :: Spring Security
When should you use CSRF protection? Our recommendation is to use CSRF protection for any request that could be processed by a browser by normal users. If you are creating a service that is used only by non-browser clients, you likely want to disable CSRF
docs.spring.io
간단하게 다시 정리하면 내가 의도하지 않은 작업을 수행하게 만드는 공격 방법이다.
CSRF 공격을 방지하는 방법으로 2가지가 있지만 여기서는 CSRF 토큰에 대해서 소개한다.
CSRF 토큰
Spring에서 가장 추천하는 방법이다. 가장 우세하고 포괄적인 방법이라고 소개하고 있다.

해당 방법은 HTTP 요청에 세션 쿠키 외에도 CSRF 토큰이라는 안전한 난수 값이 HTTP 요청에 존재하도록 하는 것이다.
서버에서 CSRF 토큰을 생성한 뒤 세션에 저장해 둔다. 이후 응답으로 CSRF 토큰을 클라이언트에게 넘겨준다.
(이 CSRF 토큰은 세션마다 존재하게 된다.)
클라이언트는 다음 요청을 보낼 때 CSRF 토큰을 헤더에 담아서 같이 전송한다.
서버에서는 세션에 있는 CSRF 토큰과 비교해서 일치하면 요청을 처리하고 일치하지 않으면 요청을 거절한다.
(이러면 403이 발생하는데 오늘도 이걸로 시간을 많이 잡아먹었다.)
스프링 시큐리티와 SSR(타임리프) 기준으로 다음과 같은 흐름으로 진행이 된다.
우선 시큐리티의 csrf 코드는 다음과 같다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity security) throws Exception {
security
.csrf(csrf -> csrf.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
return security.build();
}
위 방식을 사용하면 서버에서 난수로 이루어진 CSRF 토큰을 생성해 쿠키로 전달해 주게 된다. withHttpOnlyFlase() 옵션을 사용해서 자바스크립트에서 csrf 쿠키를 꺼내서 쓸 수 있도록 허용해 준다.
SSR의 경우 서버가 페이지를 렌더링 할 때 CSRF 토큰을 자동으로 form에 포함시키게 된다.
아래처럼 index.html을 만들게 되면
<!DOCTYPE html>
<html lang="en">
<html xmlns:th="http://www.thymeleaf.org">
<head>
<meta charset="UTF-8">
<title>Title</title>
</head>
<body>
<form th:action="@{api/test}" method="post">
<button>csrf 쿠키 발급 버튼</button>
</form>
<div>메인화면</div>
</body>
</html>
아래와 같이 쿠키에 난수로 된 CSRF 쿠키가 생성된 것을 확인할 수 있다.

여기서 의아했던 점은 우리는 CSRF라고 부르는데 XSRF라고 표기를 많이 진행한다. X의 의미는 CROSS여서 X로 많이들 표기한다.
처음에 여기까지 보고 "아 브라우저에서 XSRF 쿠키를 가지고 있으니 매 요청마다 이 쿠키를 보내서 자동으로 사용자 검증을 진행하는구나"라고 이해했었다.
실제로 XSRF 쿠키가 존재하는 상태로 요청을 보내면 잘 넘어가게 된다.

그러나 쿠키를 삭제하고 요청을 보낸다면? 이렇게 권한이 없다는 403 에러를 마주하게 된다.(이거 하루 종일 만났었다.)

요청 흐름을 전체적으로 살펴보자.
처음에 클라이언트의 요청이 들어오면 CsrfFilter를 진행하게 된다. 여기서 요청 경로가 csrf 보호가 필요하면 빨간색을, 필요하지 않다면 파란색을 타게 된다.

csrf 토큰이 존재하지 않을 경우 CookieCsrfTokenRepository의 generateToken 메서드를 호출해서 csrf 토큰을 생성한다.

csrf 토큰이 존재하면 꺼내서 사용하는 것을 확인할 수 있다. 여기서 actualToken이 실제 csrf 값을 파싱 한 값이다.
(여기는 전부 CsrfFilter의 로직이다.)

내부적으로는 아래와 같이 헤더에서 csrfToken 헤더 네임과 파라미터로 파싱 하는 것을 확인할 수 있다.

즉, 서버에서 난수 값을 가진 CSRF 토큰을 생성해서 클라이언트로 넘겨주면 클라이언트는 요청마다 CSRF 토큰을 보내서 현재 세션의 사용자라는 것을 인지하게 되는 것이다.(CSRF 토큰이 세션마다 존재하기에 가능한 것.)
그러면 왜 JWT를 사용하면 CSRF 토큰을 사용하지 않을까?
JWT는 세션을 대신해서 사용하는 토큰 인증 방식이다. 보통은 헤더에 명시적으로 JWT를 포함시켜 제공하고 서버에서는 검증을 진행하기 때문에 CSRF 공격이 어려워지게 된다.
즉, JWT를 같이 전송하지 않는 이상 공격이 불가능해지기에 CSRF 토큰은 비활성화를 진행하는 것이다.(만약에 JWT를 해킹당한다면 그건 어쩔 수 없다.)
아래는 스프링에서 제공하는 그림인데 이해하기 편할 것이라 생각한다.

1번에서 지연 로딩을 통해 토큰을 가져오고
3번에서 요청이 CSRF 보호가 필요한지 판단. -> 필요하면 4번으로 가고 6번에서 토큰을 검증한다.
여기서 토큰이 다르면 403이 발생하고 토큰이 일치하면 다음 필터를 진행하게 되는 것.
https://docs.spring.io/spring-security/reference/servlet/exploits/csrf.html
Cross Site Request Forgery (CSRF) :: Spring Security
To handle an AccessDeniedException such as InvalidCsrfTokenException, you can configure Spring Security to handle these exceptions in any way you like. For example, you can configure a custom access denied page using the following configuration: Configure
docs.spring.io
최대한 공식 문서 기반으로 내용을 정리해 보았습니다.