CSRF 토큰을 통한 보호
이 글은 Synchronizer Token Pattern에 기반한 CSRF 활용의 빠른 지침이다.
Q1. 클라이언트는 어떻게 CSRF 토큰을 얻나요
방법1. 서버가 HTML 렌더링 시 meta
태그에 토큰 집어 넣어 주기
<meta name="csrf-token" content="{{#_csrf}}token{{/_csrf}}">
방법2. 서버가 HTML 렌더링 시 form
태그에 hidden _csrf
필드 집어 넣어 주기
<html>
<body>
<form method="POST" enctype="multipart/form-data" action="/넘길페이지">
<div>
<input type="hidden" name="_csrf" value="{{#_csrf}}token{{/_csrf}}" />
<input type="submit" value="Upload" />
</div>
</form>
</body>
</html>
방법3. 서버의 API 호출하기
RESTful 서버는 뷰 렌더링을 하지 않으므로, CSRF 토큰을 획득할 수 있는 별도 API를 클라이언트에게 제공합니다.
- 사례: SAP Netweaver API 문서
요청:GET /mcm/json
헤더:X-CSRF-Token: fetch
- 앱은 CSRF 토큰을 획득하기 위해 헤더에
X-CSRF-Token: fetch
를 포함하여야 한다. - 서버는 이를 확인하고 CSRF 토큰을 생성하고 유저의 세션 테이블에 저장한다.
- 서버는 응답 헤더의
X-CSRF-Token
에 CSRF 토큰 값을 담아 응답한다. - 이후 앱은 요청을 보낼 때 헤더에 CSRF 토큰을 포함하여야 한다.
- 앱은 CSRF 토큰을 획득하기 위해 헤더에
클라이언트(자바스크립트)의 대응
jQuery.ajax("/mcm/json",{
type: "GET",
contentType: 'application/json',
dataType: 'json',
beforeSend: function(xhr){
xhr.setRequestHeader('X-CSRF-Token', 'fetch');
},
complete : function(response) {
jQuery.ajaxSetup({
beforeSend: function(xhr) {
xhr.setRequestHeader("X-CSRF-Token",response.getResponseHeader('X-CSRF-Token'));
}
});
}
});
클라이언트(자바)의 대응
public class CustomAuthenticationProvider extends StandardAuthenticationProvider {
private String token = "fetch";
@Override
public Map<String, List<String>> getHTTPHeaders(String url) {
Map<String, List<String>> httpHeaders = super.getHTTPHeaders(url);
if(httpHeaders==null) {
httpHeaders = new HashMap<String, List<String>>();
}
httpHeaders.put("X-CSRF-Token", Collections.singletonList(token));
return httpHeaders;
}
@Override
public void putResponseHeaders(String url, int statusCode, Map<String, List<String>> headers) {
super.putResponseHeaders(url, statusCode, headers);
if(headers!=null) {
for(String headerName:headers.keySet()) { // loop for a ignore case check -> header names are case-insensitive (RFC 2616)
if(headerName!=null && headerName.equalsIgnoreCase("X-CSRF-Token") && !headers.get(headerName).isEmpty()) {
this.token = headers.get(headerName).get(0);
}
}
}
}
}
방법4. 쿠키로 내려받기
이후 내용으로 설명.
Q2. 서버는 토큰을 어떻게 생성하나요?
public class WebSecurityConfig extends WebSecurityConfigurerAdapter {
@Override
protected void configure(HttpSecurity http) throws Exception {
http
...
.csrf()
.csrfTokenRepository(cookieCsrfRepository())
.and();
}
@Bean
HttpSessionCsrfTokenRepository sessionCsrfRepository() {
HttpSessionCsrfTokenRepository csrfRepository = new HttpSessionCsrfTokenRepository();
// HTTP 헤더에서 토큰을 인덱싱하는 문자열 설정
csrfRepository.setHeaderName("X-CSRF-TOKEN");
// URL 파라미터에서 토큰에 대응되는 변수 설정
csrfRepository.setParameterName("_csrf");
// 세션에서 토큰을 인덱싱 하는 문자열을 설정. 기본값이 무척 길어서 오버라이딩 하는 게 좋아요.
// 기본값: "org.springframework.security.web.csrf.HttpSessionCsrfTokenRepository.CSRF_TOKEN"
csrfRepository.setSessionAttributeName("CSRF_TOKEN");
return csrfRepository;
}
@Bean
CookieCsrfTokenRepository cookieCsrfRepository() {
CookieCsrfTokenRepository csrfRepository = new CookieCsrfTokenRepository();
csrfRepository.setCookieHttpOnly(false);
csrfRepository.setHeaderName("X-CSRF-TOKEN");
csrfRepository.setParameterName("_csrf");
csrfRepository.setCookieName("XSRF-TOKEN");
//csrfRepository.setCookiePath("..."); // 기본값: request.getContextPath()
return csrfRepository;
}
}
스프링 시큐리티 설정
csrf().disable()
을 하지 않으면 됩니다!csrf().csrfTokenRepository(repo)
토큰 생성 후 이것을 저장시킬 장소(Repository)를 골라야 합니다.- 서버 유저 세션에 저장하려면
HttpSessionCsrfTokenRepository
- 브라우저 쿠키에 저장하려면
CookieCsrfTokenRepository
- 데코레이터 패턴으로 토큰을 느긋하게 저장하는
LazyCsrfTokenRepository
- 서버 유저 세션에 저장하려면
방법1. 토큰을 만들면 유저의 세션에 저장할래요
시큐리티 설정에서 csrf().csrfTokenRepository(sessionCsrfRepository())
처럼 HttpSessionCsrfTokenRepository
를 주입합니다.
이제 클라이언트가 임의의 요청을 보내면 CsrfFilter
가 끼어들어 CSRF 토큰을 생성하고 HttpSession
과 HttpServletRequest
에 토큰을 저장합니다.
클라이언트 측이 해당 토큰을 얻어야하므로 다음과 같이 컨트롤러를 작성해 봅니다. 클라이언트는 /csrf
주소로 접근하여 응답 헤더의 X-CSRF-TOKEN
를 보고 CSRF 토큰을 얻을 수 있습니다.
@RequestMapping("/csrf")
@Controller
public class CsrfController {
private static final Logger logger = LoggerFactory.getLogger(CsrfController.class);
@RequestMapping(method = RequestMethod.GET)
public ResponseEntity<String> getOrCreateCsrfToken(HttpSession session, HttpServletRequest request) {
final DefaultCsrfToken csrfToken = (DefaultCsrfToken) session.getAttribute(
"CSRF_TOKEN");
assert(csrfToken == request.getAttribute(csrfToken.getParameterName()));
return ResponseEntity.ok()
.header(csrfToken.getHeaderName(), csrfToken.getToken()).body("Check your response header!");
}
}
방법2. 토큰을 만들면 브라우저 쿠키에 저장할래요
시큐리티 설정에서 csrf().csrfTokenRepository(cookieCsrfRepository())
처럼 CookieCsrfTokenRepository
를 주입합니다.
이제 클라이언트가 임의의 요청을 보내면 CsrfFilter
가 끼어들어 CSRF 토큰을 생성하고 HttpServletRequest
에 토큰을 저장합니다. 최초에 한하여 응답 헤더에 Set-Cookie
로 CSRF 토큰을 내려 보냅니다.
최초 접근 이후 클라이언트는 CSRF 토큰을 쿠키로 보유하는 상태로, 요청 헤더 혹은 파라미터에 동일 토큰을 서버로 실어 보냅니다. 서버는 쿠키와 요청 파라미터 또는 헤더를 비교하여 유효한 CSRF가 담겼는지 검증합니다.
다음 절차로 송수신 되는 패킷을 확인해 봅시다.
- 브라우저 쿠키 비우기
- 서비스의 임의 페이지 접근 후 응답 헤더 Set-Cookie 확인 (
XSRF-TOKEN
)
CsrfFilter가 하는 일을 알고 싶어요
코드를 보십시오.
- csrfToken은 토큰 레포지토리의 제 각기 방식대로 가져옵니다.
- 쿠키에 담긴 CSRF 토큰 가져오기
- 세션에 담긴 CSRF 토큰 가져오기
- 유저는 GET을 통해 CSRF 토큰을 생성 받았다는 것이 대전제인데, missingToken = true이면 시나리오를 벗어난 요청이 온 걸로 볼 수 있습니다. 따라서 거부 응답을 내립니다.
- actualToken은 요청 헤더 혹은 파라미터에서 얻었습니다.
- csrfToken과 actualToken이 서로 다르면, 역시 이상한 시나리오이므로 비정상 토큰에 대한 거부 응답을 내립니다.
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
request.setAttribute(HttpServletResponse.class.getName(), response);
CsrfToken csrfToken = this.tokenRepository.loadToken(request);
final boolean missingToken = csrfToken == null;
if (missingToken) {
csrfToken = this.tokenRepository.generateToken(request);
this.tokenRepository.saveToken(csrfToken, request, response);
}
request.setAttribute(CsrfToken.class.getName(), csrfToken);
request.setAttribute(csrfToken.getParameterName(), csrfToken);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
filterChain.doFilter(request, response);
return;
}
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
if (!csrfToken.getToken().equals(actualToken)) {
if (this.logger.isDebugEnabled()) {
this.logger.debug("Invalid CSRF token found for "
+ UrlUtils.buildFullRequestUrl(request));
}
if (missingToken) {
this.accessDeniedHandler.handle(request, response,
new MissingCsrfTokenException(actualToken));
}
else {
this.accessDeniedHandler.handle(request, response,
new InvalidCsrfTokenException(csrfToken, actualToken));
}
return;
}
filterChain.doFilter(request, response);
}
Q3. 클라이언트는 어떻게 리퀘스트를 전송해야 하나요
방법1. 헤더에 CSRF 토큰을 넣어 보내세요
jQuery를 활용 중이라면 모든 요청 헤더에 CSRF 토큰을 포함하도록 할 수 있습니다.
서버와 합의된 헤더 형식을 지켜 CSRF 토큰을 적재합시다. 기본적으로 기대되는 헤더명은 X-CSRF-TOKEN
or X-XSRF-TOKEN
입니다.
$.ajaxSetup({
headers: {
'X-CSRF-TOKEN': $('meta[name="csrf-token"]').attr('content')
}
});
방법2. 파라미터에 CSRF 토큰을 넣어 보내세요
form
에 CSRF 토큰을 위한 hidden 필드가 존재한다면 이 방식으로 동작합니다.
기본적으로 서버가 기대하는 파라미터명은 _csrf
입니다.
<html>
<body>
<form method="POST" enctype="multipart/form-data" action="/넘길페이지">
<div>
<input type="hidden" name="_csrf" value="a60159ae-9b7f-45dc-9c97-3a5f14a39cbd" />
<input type="submit" value="Upload" />
</div>
</form>
</body>
</html>
방법3. 쿠키로 받았으면, 쿠키로 보내지는 거 아닌가요⋯?
CSRF 토큰은 히든 요청 파라미터나 헤더에 실어 보내시고, 쿠키에는 싣지 않는게 좋겠습니다. 마찬가지로 서버는 CSRF 토큰을 쿠키에서 뽑아 유효성 검증을 수행하지 않아야 합니다.
이 부분에서 CookieCsrfTokenRepository
는 정말 아쉽습니다. CSRF 토큰을 쿠키에도 꼭 실어 보내게 하는 서버가 있다면, 서버 측 세션 관리가 없는 이상 상황이라고 의심해야 합니다. 그런 서버는 요청 쿠키에 담긴 CSRF 토큰과, 요청 파라미터 혹은 요청 헤더에 담긴 토큰을 비교해 무상태로 검증을 수행합니다.
이 시나리오에서, 공격자는 자신이 발급 받은 CSRF 토큰을 희생자의 유저 에이전트에 손쉽게 심어 놓고 임의의 요청을 실행시킬 수 있습니다. 따라서 CSRF 토큰은 세션에 묶여 있어야 하며, 쿠키를 요구하는 서버는 없어야 합니다. 더 궁금하신 내용을 찾아 보실 수 있게 PortSwigger에 정리된 글과 OWASP 치트 시트를 첨부합니다.
'웹 개발 > 스프링 프레임워크' 카테고리의 다른 글
[스프링MVC] 어노테이션 이야기 - REST와 @ResponseBody (0) | 2021.12.14 |
---|---|
[스프링] 다양한 작업 실행 전략 이야기 - 스케줄링 및 비동기 실행 (0) | 2021.12.14 |
[스프링 Security] CSRF 토큰 이야기 - csrf() 켰더니 로그아웃이 안 되네? (0) | 2021.12.14 |
[스프링MVC] 서비스 RPC화 이야기 - Deprecated된 HTTP Invoker (0) | 2021.12.14 |
[스프링MVC] 파일 업로드 이야기 - multipart/form-data 요청 다루기 (0) | 2021.12.14 |