사용자A가 있다
A는 사이트B에 로그인한 상태였는데 그때 이메일에 들어있던 링크를 통해 사이트C로 이동했다.
A가 사이트C로 이동한 순간 사이트C에 포함된 악성 스크립트가 실행된다.
스크립트에는 사이트B에 대해 스크립트로 http POST요청을 보낸다.
만일 CSRF보호를 설정하지 않은 서버라면 서버는 이미 브라우저가 로그인 한상태이기 때문에 신뢰하고 그 요청에 대한 작업을 수행한다.
이렇게 정보가 유출되거나 원하지 않는 작업이 수행될 수 있다.
CSRF를 막기 위해 여러 방법이 쓰이는데 제일 대표적인 방법은 CSRF토큰을 이용하는 것이다.
대부분의 클라이언트는 POST요청전에 자연스럽게 GET요청을 보내게 되는데 서버는 GET요청시 uuid를 통해 생성된 토큰을 같이 넘겨 클라이언트가 POST요청을 보내면 다시 토큰을 받아 일치하면 요청을 보낸 이가 올바른 클라이언트가 맞다고 판단하고 요청을 처리한다.
스프링 시큐리티에서는 이러한 CSRF보호 처리에 필터를 사용한다.
앞선 글에 설명한 스프링 시큐리티는 기본으로 아무런 설정을 하지않으면 csrfFIlter필터를 필터체인에 적용한다.
csrfRepository를 통해 새 토큰을 생성하고
POST, DELETE등 변경 호출에 대해서는 csrf토큰 검사를 진행한다.
csrfFIlter뒤에 이전에 글에서 소개한 로그역활을 담당하는 필터를 추가하여 csrf토큰값을 읽고 요청시에 헤더에 추가한다.
public class CsrfTokenLogger implements Filter {
private final Logger log = LoggerFactory.getLogger(CsrfTokenLogger.class);
@Override
public void doFilter(ServletRequest servletRequest,
ServletResponse servletResponse,
FilterChain filterChain) throws IOException, ServletException {
Object o = servletRequest.getAttribute("_csrf");
CsrfToken token = (CsrfToken) o;
log.info("csrf token: {}", token.getToken());
filterChain.doFilter(servletRequest, servletResponse);
}
}
csrfFIlter는 servletRequest에 _csrf 토큰을 넣어주기 때문에 다음 필터에서 읽어올 수 있다.
필터체인 아래와 같이 설정했다.
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.authorizeHttpRequests(
authorizeRequests -> authorizeRequests
.anyRequest()
.permitAll());
http.addFilterAfter(
new CsrfTokenLogger(),
BasicAuthenticationFilter.class
);
return http.build();
}
컨트롤러를 만들어서 GET요청을 보내면

로그가 정상적으로 찍힌다.

403 forbidden status가 뜬다.
위csrf token을 이용해서 POSTMAN을 통해 X-CSRF-TOKEN이라는 키와 value를 추가해주면
아래와 같이 200status가 뜬다.

스프링 부트가 기본적으로 csrfFilter를 적용해준다는 것을 확인 가능하다.
csrfFilter는 토큰을 uuid를 이용해 생성하고 저장할때 csrfTokenRepository의 기본 구현을 사용하는데
기본값은 세션을 통해 토큰을 관리한다.
수평적 확장이 필요한 대형 어플리케이션에서는 이러한 세션구현을 변경할 필요가 있기 때문에
db에 csrf토큰을 저장해야한다.
종속성은 타임리프와 mysql connecter, spring data jpa 사용
설정은 생략.
@Entity
public class Token {
@Id @GeneratedValue
private int id;
private String identifier;
private String token;
//getter, setter생략
}
identifier필드는 사용자 식별을 위해 쓰인다.
여기에서는 세션id를 사용한다.
identifier를 사용하여 token을 가져오는 리포지토리 정의
public interface JpaTokenRepository extends JpaRepository<Token, Integer> {
Optional<Token> findByIdentifier(String identifier);
}
jpa를 이용하는 CustomCsrfTokenRepository정의
CsrfTokenRepository는 CSRF토큰에 대한 관리를 담당하기 떄문에
generateToken, saveToken, loadToken 추상 메서드를 가지고 있다.
@Component
public class CustomCsrfTokenRepository implements CsrfTokenRepository {
private final JpaTokenRepository jpaTokenRepository;
public CustomCsrfTokenRepository(JpaTokenRepository jpaTokenRepository) {
this.jpaTokenRepository = jpaTokenRepository;
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
String uuid = UUID.randomUUID().toString();
return new DefaultCsrfToken("X-CSRF-TOKEN", "_csrf", uuid);
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
String identifier = request.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = jpaTokenRepository.findByIdentifier(identifier);
if (existingToken.isPresent()) {
Token t = existingToken.get();
t.setToken(token.getToken());
} else {
Token t = new Token();
t.setToken(token.getToken());
t.setIdentifier(identifier);
jpaTokenRepository.save(t);
}
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
String identifier = request.getHeader("X-IDENTIFIER");
Optional<Token> existingToken = jpaTokenRepository.findByIdentifier(identifier);
if(existingToken.isPresent()) {
Token token = existingToken.get();
return new DefaultCsrfToken("X-CSRF-TOKEN", token.getIdentifier(), token.getToken());
}
return null;
}
}
DefaultCsrfToken는 CsrfToken인터페이스의 제일 간단한 구현체로 헤더의 이름, 토큰 값을 받아 변경이 불가능한 인스턴스를 만든다.
saveToken메서드에서는 이미 같은 세션id의 csrf이 있으면 jpa변경감지를 통해 값을 수정했다.
이제 csrf필터가 기본구현 csrfRepository 대신에 CustomCsrfTokenRepository를 사용하도록 설정해야한다.
@Configuration
@EnableWebSecurity
public class ProjectConfig {
@Autowired
private CsrfTokenRepository csrfTokenRepository;
@Bean
public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception {
http.csrf(c -> {
c.csrfTokenRepository(csrfTokenRepository);
});
http.addFilterAfter(
new CsrfTokenLogger(),
BasicAuthenticationFilter.class
);
http.authorizeHttpRequests(
authorizeRequests -> authorizeRequests
.anyRequest()
.permitAll());
return http.build();
}
}
간단한 컨트롤러를 GET, POST에 대해 만들면 GET에 대해 csrfFIlter는 X-CSRF-TOKEN이라는 값을 만든다.
GET요청을 X-IDENTIFIER 헤더 필드와 함께 보내면 DefaultCsrfToken이 uuid로 csrf토큰을 만들어 준다.

이제 헤더 필드에 이전 요청과 동일한 X-IDENTIFIER, 위 csrf token을 X-CSRF-TOKEN과 함께 요청하면 200 status가 리턴된다.
