안녕하세요. 오늘은 졸업작품을 준비하면서 겪었던 CSRF 문제에 대해서 정리해보고자 글을 작성하게 되었습니다. 비전공자라 혼자 졸업작품을 개발하다 보니, 백엔드에 초점을 두기 위해 프론트를 Thymeleaf로 구현하게 되었습니다. ㅠㅠ 팀원과 함께 더 좋은 프로젝트를 구성해보고 싶었는데 많이 아쉽습니다.
Thymeleaf로 form을 구현하면, 만나게 되는 문제가 CSRF입니다. CSRF를 먼저 정의하고, 제가 스프링 시큐리티에서 제공하는 CSRF 공격 방지 정책에 대해 잘못 이해한 부분을 공유하고, 올바르게 사용하는 방법을 작성하고자 합니다. 결론부터 말하면, 스프링 시큐리티가 다 해줍니다...!
먼저, CSRF에 대한 정의를 살펴보도록 하겠습니다. 해당 정의는 위키백과를 참고하였습니다.
1. CSRF
CSRF는 사이트 간 요청 위조(또는 크로스 사이트 요청 위조)는 웹사이트 취약점 공격의 하나로, 사용자가 자신의 의지와는 무관하게 공격자가 의도한 행위(수정, 삭제, 등록 등)을 특정 웹사이트에 요청하게 하는 공격을 말합니다.
2. CSRF 공격과정
- 공격자는 웹사이트에 로그인하여 정상적인 쿠키를 발급받습니다.
- 공격자는 다음과 같은 링크를 이메일이나 게시판 등의 경로를 통해 이용자에게 전달합니다.
- http://www.geocities.com/attacker
- 공격용 HTML 페이지는 다음과 같은 이미지태그를 가집니다.
- <img src= "https://travel.service.com/travel_update?.src=Korea&.dst=Hell">
- 이용자가 공격용 페이지를 열면, 브라우저는 이미지 파일을 받아오기 위해 공격용 URL을 엽니다.
- 이용자의 승인이나 인지 없이 출발지와 도착지가 등록됨으로써 공격이 완료됩니다.
- 해당 서비스 페이지는 등록 과정에 대해 단순히 쿠키를 통한 본인확인 밖에 하지 않으므로
공격자가 정상적인 이용자의 수정이 가능하게 합니다.
3. Spring Security CSRF 방지
4. csrf 검증 절차에 대한 궁금증과 해결 과정
위의 절차는 Spring Security의 CSRF 방지 알고리즘을 이해하고 난 후, codevang님 블로그에서 발췌한 내용입니다. 역시, 아는 만큼 보인다고…. 처음에는 완전히 이해하지 못한 상태에서 훑고 지나갔던 내용들이 완전하게 읽히게 되었습니다. 제가 궁금했던 내용은, 다음과 같습니다.
<form class="row" th:action="@{/api/v1/test/index}" th:object="${indexTestForm}" method="post">
<div>
<div>
<label th:for="source" class="form-label">소스</label>
<input type="text" class="form-control form-control-lg input-group-prepend" th:field="*{source}">
</div>
th:action으로 form을 구성하면, 자동으로 input hidden 값으로 _CSRF 토큰이 생성됩니다.
<input type="hidden" name="_csrf" value="094d12bb-b2f2-4fd8-b3d0-fa70f08c35df">
처음에는, csrf 토큰을 컨트롤러에서 생성해서 세션에 저장한 후, input에 hidden 값으로 수동으로 작성하였는데, th:action으로 설정하면 자동으로 hidden으로 _csrf값이 생성되었습니다.
그렇다면, "클라이언트에서 생성된 _csrf는 어디에 저장되어 검증되는 걸까?"라는 생각에 중단점을 걸고 확인해보았습니다.
5. HttpSessionCsrfTokenRepository
해답은 HttpSessionCsrfTokenRepository에 있었습니다. 해당 파일에 중단점을 걸고 확인하면,
public final class HttpSessionCsrfTokenRepository implements CsrfTokenRepository {
private static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
private static final String DEFAULT_CSRF_HEADER_NAME = "X-CSRF-TOKEN";
private static final String DEFAULT_CSRF_TOKEN_ATTR_NAME = HttpSessionCsrfTokenRepository.class.getName()
.concat(".CSRF_TOKEN");
private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
private String headerName = DEFAULT_CSRF_HEADER_NAME;
private String sessionAttributeName = DEFAULT_CSRF_TOKEN_ATTR_NAME;
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
if (token == null) {
HttpSession session = request.getSession(false);
if (session != null) {
session.removeAttribute(this.sessionAttributeName);
}
}
else {
HttpSession session = request.getSession();
session.setAttribute(this.sessionAttributeName, token); <<< 여기 1
}
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return (CsrfToken) session.getAttribute(this.sessionAttributeName);
}
토큰이 저장되며 세션에 _CSRF 토큰 정보가 저장됨을 알 수 있었습니다. 따라서, 클라이언트가 _CSRF가 지정된 form이 구성된 url에 접근하면 해당 repository를 사용하는 필터가 작동하여 토큰을 생성하고 세션에 그 값을 담아서 뷰가 로드될 수 있도록 구성하는 방식이었습니다.
클라이언트단에서 개발자 도구로 hidden 토큰을 확인하면, 세션에서 발급한 토큰과 일치하는 것을 확인할 수 있었습니다. 따라서, 클라이언트가 form을 작성한 후, post 요청을 보내면, 스프링 세션에 저장된 _csrf 토큰과 해당 form의 토큰을 비교하여 해당 요청을 처리하는 것을 알 수 있었습니다.
이상으로 Thymeleaf csrf에 대한 글을 마치겠습니다.
읽어주셔서 감사합니다~!
참고 자료:
csrf 위키백과 -https://ko.wikipedia.org/wiki/%EC%82%AC%EC%9D%B4%ED%8A%B8_%EA%B0%84_%EC%9A%94%EC%B2%AD_%EC%9C%84%EC%A1%B0
codevang님 tistory - https://codevang.tistory.com/282
타임리프에 csrf 설정 코드를 따로 넣어주지 않았는데도 동작하길래
한참 고민하고 찾아봤는데 th:action 이 자동으로 넣어주는군요..ㅎㅎ
블로그 글 정말 잘 읽었습니다. 감사합니다.