1. CsrfFilter.
- CsrfFilter
- CsrfFilter는 Synchronizer Token 패턴을 사용하여 CSRF 공격을 방지함.
- 개발자는 상태를 변경하는 모든 요청에 대해 CsrfFilter가 호출되도록 해야함.
- 일반적으로 CsrfTokenRepository를 구현한 HttpSessionCsrfTokenRepository를 사용하여 CSRF 토큰을 HttpSession에 저장하는 방식을 사용함.
- Cross Site Request Forgery(CSRF)
- CSRF(Cross-Site Request Forgery, 사이트 간 요청 위조) 공격은 사용자가 의도하지 않은 요청을 강제로 실행하게 만드는 공격 기법.
- 공격자는 사용자의 인증된 세션을 악용해서 사용자가 원치 않는 액션을 실행하게 함.
- CSRF 토큰 클라이언트측으로 발급해주기.
- 기본 설정은
SSR(Server-Side Rendering, 서버 측에서 페이지를 렌더링하는 방식) 세션 방식으로 되어있음.
(STATELESS REST API에서는 사용할 일이 거의 없기 때문)
- Controller에서 View 응답시 HTML form 영역에 서버에 저장되어 있는
_csrf 토큰 값을 넣어주면 됨.
- STATELESS한 API 서버에서는 서버 세션을 사용하지 않기 때문에, CSRF 공격 위험이 낮음.
- CSRF 공격은 일반적으로 서버 세션을 이용한 인증 방식에서 발생할 수 있기 때문.
- JWT와 같은 토큰 방식을 사용하고 해당 토큰을 쿠키에 저장할 경우 CSRF 공격의 위험이 있을 수 있기 때문에 활성화하는 게 좋음.
1-1. CsrfTokenRepository.
package org.springframework.security.web.csrf;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
public interface CsrfTokenRepository {
CsrfToken generateToken(HttpServletRequest request);
void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response);
CsrfToken loadToken(HttpServletRequest request);
default DeferredCsrfToken loadDeferredToken(HttpServletRequest request, HttpServletResponse response) {
return new RepositoryDeferredCsrfToken(this, request, response);
}
}
- CSRF 토큰의 생성 및 관리는 CsrfTokenRepository 인터페이스를 구현한 클래스에게 위임 시킴.
- HttpSessionCsrfTokenRepository.
- 서버 세션에 토큰을 저장하고 관리함. (default)
- CookieCsrfTokenRepository.
- 직접 구현도 가능.
- CSRF 토큰 저장소 설정
http
.csrf((csrf) -> csrf.csrfTokenRepository(new HttpSessionCsrfTokenRepository()));
1-1-1. HttpSessionCsrfTokenRepository.
package org.springframework.security.web.csrf;
import java.util.UUID;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.springframework.util.Assert;
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);
}
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
HttpSession session = request.getSession(false);
if (session == null) {
return null;
}
return (CsrfToken) session.getAttribute(this.sessionAttributeName);
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
}
public void setParameterName(String parameterName) {
Assert.hasLength(parameterName, "parameterName cannot be null or empty");
this.parameterName = parameterName;
}
public void setHeaderName(String headerName) {
Assert.hasLength(headerName, "headerName cannot be null or empty");
this.headerName = headerName;
}
public void setSessionAttributeName(String sessionAttributeName) {
Assert.hasLength(sessionAttributeName, "sessionAttributename cannot be null or empty");
this.sessionAttributeName = sessionAttributeName;
}
private String createNewToken() {
return UUID.randomUUID().toString();
}
}
1-1-2. CookieCsrfTokenRepository
package org.springframework.security.web.csrf;
import java.util.UUID;
import java.util.function.Consumer;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.http.HttpHeaders;
import org.springframework.http.ResponseCookie;
import org.springframework.util.Assert;
import org.springframework.util.StringUtils;
import org.springframework.web.util.WebUtils;
public final class CookieCsrfTokenRepository implements CsrfTokenRepository {
static final String DEFAULT_CSRF_COOKIE_NAME = "XSRF-TOKEN";
static final String DEFAULT_CSRF_PARAMETER_NAME = "_csrf";
static final String DEFAULT_CSRF_HEADER_NAME = "X-XSRF-TOKEN";
private static final String CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME = CookieCsrfTokenRepository.class.getName()
.concat(".REMOVED");
private String parameterName = DEFAULT_CSRF_PARAMETER_NAME;
private String headerName = DEFAULT_CSRF_HEADER_NAME;
private String cookieName = DEFAULT_CSRF_COOKIE_NAME;
private boolean cookieHttpOnly = true;
private String cookiePath;
private String cookieDomain;
private Boolean secure;
private int cookieMaxAge = -1;
private Consumer<ResponseCookie.ResponseCookieBuilder> cookieCustomizer = (builder) -> {
};
public void setCookieCustomizer(Consumer<ResponseCookie.ResponseCookieBuilder> cookieCustomizer) {
Assert.notNull(cookieCustomizer, "cookieCustomizer must not be null");
this.cookieCustomizer = cookieCustomizer;
}
@Override
public CsrfToken generateToken(HttpServletRequest request) {
return new DefaultCsrfToken(this.headerName, this.parameterName, createNewToken());
}
@Override
public void saveToken(CsrfToken token, HttpServletRequest request, HttpServletResponse response) {
String tokenValue = (token != null) ? token.getToken() : "";
ResponseCookie.ResponseCookieBuilder cookieBuilder = ResponseCookie.from(this.cookieName, tokenValue)
.secure((this.secure != null) ? this.secure : request.isSecure())
.path(StringUtils.hasLength(this.cookiePath) ? this.cookiePath : this.getRequestContext(request))
.maxAge((token != null) ? this.cookieMaxAge : 0)
.httpOnly(this.cookieHttpOnly)
.domain(this.cookieDomain);
this.cookieCustomizer.accept(cookieBuilder);
ResponseCookie responseCookie = cookieBuilder.build();
if (!StringUtils.hasLength(responseCookie.getSameSite())) {
Cookie cookie = mapToCookie(responseCookie);
response.addCookie(cookie);
}
else if (request.getServletContext().getMajorVersion() > 5) {
Cookie cookie = mapToCookie(responseCookie);
response.addCookie(cookie);
}
else {
response.addHeader(HttpHeaders.SET_COOKIE, responseCookie.toString());
}
if (!StringUtils.hasLength(tokenValue)) {
request.setAttribute(CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME, Boolean.TRUE);
}
else {
request.removeAttribute(CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME);
}
}
@Override
public CsrfToken loadToken(HttpServletRequest request) {
if (Boolean.TRUE.equals(request.getAttribute(CSRF_TOKEN_REMOVED_ATTRIBUTE_NAME))) {
return null;
}
Cookie cookie = WebUtils.getCookie(request, this.cookieName);
if (cookie == null) {
return null;
}
String token = cookie.getValue();
if (!StringUtils.hasLength(token)) {
return null;
}
return new DefaultCsrfToken(this.headerName, this.parameterName, token);
}
public void setParameterName(String parameterName) {
Assert.notNull(parameterName, "parameterName cannot be null");
this.parameterName = parameterName;
}
public void setHeaderName(String headerName) {
Assert.notNull(headerName, "headerName cannot be null");
this.headerName = headerName;
}
public void setCookieName(String cookieName) {
Assert.notNull(cookieName, "cookieName cannot be null");
this.cookieName = cookieName;
}
@Deprecated(since = "6.1")
public void setCookieHttpOnly(boolean cookieHttpOnly) {
this.cookieHttpOnly = cookieHttpOnly;
}
private String getRequestContext(HttpServletRequest request) {
String contextPath = request.getContextPath();
return (contextPath.length() > 0) ? contextPath : "/";
}
public static CookieCsrfTokenRepository withHttpOnlyFalse() {
CookieCsrfTokenRepository result = new CookieCsrfTokenRepository();
result.cookieHttpOnly = false;
return result;
}
private String createNewToken() {
return UUID.randomUUID().toString();
}
private Cookie mapToCookie(ResponseCookie responseCookie) {
Cookie cookie = new Cookie(responseCookie.getName(), responseCookie.getValue());
cookie.setSecure(responseCookie.isSecure());
cookie.setPath(responseCookie.getPath());
cookie.setMaxAge((int) responseCookie.getMaxAge().getSeconds());
cookie.setHttpOnly(responseCookie.isHttpOnly());
if (StringUtils.hasLength(responseCookie.getDomain())) {
cookie.setDomain(responseCookie.getDomain());
}
if (StringUtils.hasText(responseCookie.getSameSite())) {
cookie.setAttribute("SameSite", responseCookie.getSameSite());
}
return cookie;
}
public void setCookiePath(String path) {
this.cookiePath = path;
}
public String getCookiePath() {
return this.cookiePath;
}
@Deprecated(since = "6.1")
public void setCookieDomain(String cookieDomain) {
this.cookieDomain = cookieDomain;
}
@Deprecated(since = "6.1")
public void setSecure(Boolean secure) {
this.secure = secure;
}
@Deprecated(since = "6.1")
public void setCookieMaxAge(int cookieMaxAge) {
Assert.isTrue(cookieMaxAge != 0, "cookieMaxAge cannot be zero");
this.cookieMaxAge = cookieMaxAge;
}
}
1-2. CsrfFilter.
package org.springframework.security.web.csrf;
import java.io.IOException;
import java.security.MessageDigest;
import java.util.Arrays;
import java.util.HashSet;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.core.log.LogMessage;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.security.crypto.codec.Utf8;
import org.springframework.security.web.access.AccessDeniedHandler;
import org.springframework.security.web.access.AccessDeniedHandlerImpl;
import org.springframework.security.web.util.UrlUtils;
import org.springframework.security.web.util.matcher.RequestMatcher;
import org.springframework.util.Assert;
import org.springframework.web.filter.OncePerRequestFilter;
public final class CsrfFilter extends OncePerRequestFilter {
public static final RequestMatcher DEFAULT_CSRF_MATCHER = new DefaultRequiresCsrfMatcher();
private static final String SHOULD_NOT_FILTER = "SHOULD_NOT_FILTER" + CsrfFilter.class.getName();
private final Log logger = LogFactory.getLog(getClass());
private final CsrfTokenRepository tokenRepository;
private RequestMatcher requireCsrfProtectionMatcher = DEFAULT_CSRF_MATCHER;
private AccessDeniedHandler accessDeniedHandler = new AccessDeniedHandlerImpl();
private CsrfTokenRequestHandler requestHandler = new XorCsrfTokenRequestAttributeHandler();
public CsrfFilter(CsrfTokenRepository tokenRepository) {
Assert.notNull(tokenRepository, "tokenRepository cannot be null");
this.tokenRepository = tokenRepository;
}
@Override
protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException {
return Boolean.TRUE.equals(request.getAttribute(SHOULD_NOT_FILTER));
}
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
DeferredCsrfToken deferredCsrfToken = this.tokenRepository.loadDeferredToken(request, response);
request.setAttribute(DeferredCsrfToken.class.getName(), deferredCsrfToken);
this.requestHandler.handle(request, response, deferredCsrfToken::get);
if (!this.requireCsrfProtectionMatcher.matches(request)) {
if (this.logger.isTraceEnabled()) {
this.logger.trace("Did not protect against CSRF since request did not match "
+ this.requireCsrfProtectionMatcher);
}
filterChain.doFilter(request, response);
return;
}
CsrfToken csrfToken = deferredCsrfToken.get();
String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken);
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) {
boolean missingToken = deferredCsrfToken.isGenerated();
this.logger
.debug(LogMessage.of(() -> "Invalid CSRF token found for " + UrlUtils.buildFullRequestUrl(request)));
AccessDeniedException exception = (!missingToken) ? new InvalidCsrfTokenException(csrfToken, actualToken)
: new MissingCsrfTokenException(actualToken);
this.accessDeniedHandler.handle(request, response, exception);
return;
}
filterChain.doFilter(request, response);
}
public static void skipRequest(HttpServletRequest request) {
request.setAttribute(SHOULD_NOT_FILTER, Boolean.TRUE);
}
public void setRequireCsrfProtectionMatcher(RequestMatcher requireCsrfProtectionMatcher) {
Assert.notNull(requireCsrfProtectionMatcher, "requireCsrfProtectionMatcher cannot be null");
this.requireCsrfProtectionMatcher = requireCsrfProtectionMatcher;
}
public void setAccessDeniedHandler(AccessDeniedHandler accessDeniedHandler) {
Assert.notNull(accessDeniedHandler, "accessDeniedHandler cannot be null");
this.accessDeniedHandler = accessDeniedHandler;
}
public void setRequestHandler(CsrfTokenRequestHandler requestHandler) {
Assert.notNull(requestHandler, "requestHandler cannot be null");
this.requestHandler = requestHandler;
}
private static boolean equalsConstantTime(String expected, String actual) {
if (expected == actual) {
return true;
}
if (expected == null || actual == null) {
return false;
}
byte[] expectedBytes = Utf8.encode(expected);
byte[] actualBytes = Utf8.encode(actual);
return MessageDigest.isEqual(expectedBytes, actualBytes);
}
private static final class DefaultRequiresCsrfMatcher implements RequestMatcher {
private final HashSet<String> allowedMethods = new HashSet<>(Arrays.asList("GET", "HEAD", "TRACE", "OPTIONS"));
@Override
public boolean matches(HttpServletRequest request) {
return !this.allowedMethods.contains(request.getMethod());
}
@Override
public String toString() {
return "CsrfNotRequired " + this.allowedMethods;
}
}
}