현재 프로젝트가 semi-stateless 환경입니다.
JWT를 쓰되, http only cookie를 쓰고있죠.
따라서 cross site 상황에서는 cookie가 자동 전송될 수 있습니다.
이걸 해결해야겠죠?
기존엔 아래와 같이 csrf를 비활성화 하였습니다.
.csrf(AbstractHttpConfigurer::disable)
하지만 csrf를 검색해보니 cookie 기반 jwt token 인증을 하는 경우에는 문제가 발생할 수 있다고 봤습니다.
쿠키는 same-origin
이기만 하면 전달하니까요.
따라서 csrf를 도입하게 되었습니다.
물론 메인 로직은 댓글 삭제밖에 없기 때문에 큰 문제는 없지만, YoutubeDataAPI는 videoId만 있으면 모든 댓글을 다 가져올 수 있기 때문에... 악의적으로 모든 댓글을 삭제시킬수도 있습니다.
이런 취약점을 해결해야합니다.
현재 저는 cookie 기반으로 xsrf-token
을 전달하려 하였습니다.
따라서 아래와 같이 간단히 작성하였죠.
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
)
csrf를 쓰되, cookie 기반으로 하겠다!는 의미입니다.
프론트에서도 간단하게 header를 추가할 수 있었습니다.
axiosInstance.interceptors.request.use(
(config) => {
const match = document.cookie.match(/XSRF-TOKEN=([^;]+)/)
if (match) {
// X-XSRF-TOKEN은 XSRF-TOKEN을 그대로 헤더로 전달한다!
config.headers['X-XSRF-TOKEN'] = match[1].trim();
}
return config
},
(error) => Promise.reject(error),
)
원래 이렇게 하면 되는 것이지만...
// 아래를 활성화
// logging.level.org.springframework.security.web.csrf=DEBUG
Invalid CSRF token found for
오류가 떴습니다. 원래는 돼야하는데... 왜 안되는 걸까요?
먼저 CsrfFilter.doFilterIntalnal
을 확인했습니다.
CsrfToken csrfToken = deferredCsrfToken.get();
String actualToken = this.requestHandler.resolveCsrfTokenValue(request, csrfToken);
if (!equalsConstantTime(csrfToken.getToken(), actualToken)) { ... }
디버깅 모드에서 csrfToken
, actualToken
의 값이 각각 [FROM_HEADER_UUID]
, null
이었습니다.
csrfToken
은 [FROM_HEADER_UUID]
인 것은 이해가 가지만 왜 actualToken
의 값이 null
일까요...?
resolveCsrfTokenValue
함수를 확인해봅시다.
일단 requestHandler
가 XorCsrfTokenRequestAttributeHandler
임을 기억해둡시다. 중요합니다 이거.
@Component
public class CsrfDebugFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
String headerToken = request.getHeader("X-XSRF-TOKEN");
String cookieToken = Arrays.stream(request.getCookies())
.filter(cookie -> "XSRF-TOKEN".equals(cookie.getName()))
.map(Cookie::getValue)
.findFirst()
.orElse("");
HttpSession session = request.getSession(false);
String sessionCsrfToken = null;
if (session != null) {
Object sessionCsrf = session.getAttribute("_csrf");
if (sessionCsrf instanceof CsrfToken) sessionCsrfToken = ((CsrfToken) sessionCsrf).getToken();
System.out.println("Session ID: " + session.getId());
}
System.out.println("X-XSRF-TOKEN (Header): " + headerToken);
System.out.println("XSRF-TOKEN (Cookie): " + cookieToken);
System.out.println("CSRF-TOKEN (Session): " + sessionCsrfToken);
filterChain.doFilter(request, response);
}
}
조금 길지만 로직을 뜯어보면
X-XSRF-TOKEN
의 값과 쿠키에서 XSRF-TOKEN
쿠키의 값을 가져옴JSESSIONID
값 기반)밖에 없습니다.
이걸 SecurityConfig에서 아래와 같이 사용하는 것이죠. 그러면 CsrfFilter보다 DebugFilter가 먼저 동작하므로 제대로 값이 할당되었는지 확인이 가능하게 됩니다.
.addFilterBefore(csrfDebugFilter, CsrfFilter.class)
참고로...
CsrfFilter → UsernamePasswordAuthenticationFilter 순서로 동작하니 JWT가 먼저 처리될 일은 없습니다!
JWT는 주로 UPAF 앞에서 검증하기 때문에 CSRF에 걸릴 일이 없습니다.
따라서 csrf를 적용했는데 자꾸 403 error가 뜬다고 하면 Filter를 볼 것이 아니라 제대로 로직을 적용했는가?를 먼저 확인해주시기 바랍니다.
resolveCsrfTokenValue
public String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String actualToken = super.resolveCsrfTokenValue(request, csrfToken);
return getTokenValue(actualToken, csrfToken.getToken());
}
actualToken
의 값을 얻는 과정입니다. 이 함수 내부의 함수들을 알아봅시다.
super.resolveCsrfTokenValue
default String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken) {
String actualToken = request.getHeader(csrfToken.getHeaderName());
if (actualToken == null) {
actualToken = request.getParameter(csrfToken.getParameterName());
}
return actualToken;
}
일단 header
에서 값을 가져오는 것을 확인합시다. 즉 어느 값이든 간에 header
에서 가져와야 하는 것입니다!
기본값은 X-XSRF-TOKEN
입니다. 따라서 이 값으로 header에 넣어 보내야 하는 것이죠.
getTokenValue
- 문제가 됐던 부분private static String getTokenValue(String actualToken, String token) {
byte[] actualBytes;
try {
actualBytes = Base64.getUrlDecoder().decode(actualToken);
}
catch (Exception ex) {
return null;
}
byte[] tokenBytes = Utf8.encode(token);
int tokenSize = tokenBytes.length;
if (actualBytes.length != tokenSize * 2) { <-- 이 부분에서 문제 발생!
return null;
}
...
}
actualBytes
는 27byte인데 tokenSize
는 32byte 입니다... cookie로 넘기는 값은 UUID 그 자체인데 그걸 actualbytes
로 받으니 문제가 발생하였던 것입니다.
일단은 Xor이 없는 Handler를 추가하였습니다. 이걸 해도 일단 동작하더라고요?
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
CsrfTokenRequestAttributeHandler
에는 아래의 함수가 있습니다.
resolveCsrfTokenValue
는 위에서 봤다시피 단순히 actualToken
을 가져오는 함수이고, 밑의 두 함수는 딱히 신경쓰지 않아도 되는 함수입니다.
XorHandler
와는 다르게 별도의 검증 로직이 없어서 더 간단하긴 합니다!
// implemented
@Override
default String resolveCsrfTokenValue(HttpServletRequest request, CsrfToken csrfToken)
// defined
@Override
public void handle(HttpServletRequest request, HttpServletResponse response, Supplier<CsrfToken> deferredCsrfToken)
public final void setCsrfRequestAttributeName(String csrfRequestAttributeName)
이를 통해 임시방통으로 해결할 수 있었습니다. 하지만 완벽한 해결은 아니죠.
Rest 환경에서 완벽한 해결 방법은...
POST/PATCH/PUT/DELETE 요청
↓
axios에서 intercept
↓
CSRF 발급 endpoint로 GET 요청
↓
CSRF 토큰을 Header에 넣어 요청
또는
SPA 페이지 로딩
↓
CSRF 발급 endpoint로 GET 요청
↓
CSRF 토큰을 store에 저장
↓
이후 요청때마다 Header에 넣어 요청
이런 방식으로 진행하면 됩니다!
첫 번째 방식은 계속 GET 요청을 보내니 자원 소모가 심한 편입니다.
두 번째 방식은 처음 한번만 받으니 문제가 없지만, 세션 만료시간에 민감하죠.
SPA에 주로 쓰는 방식은 두 번째 방식입니다! 사용자 환경이 더 중요하니까요.
두 번째 방식을 사용해보도록 하겠습니다!
적용에 앞서 몇 가지 해줘야 하는 선행 사항이 있습니다.
csrf.csrfTokenRequestHandler
이 부분을 없애주면 됩니다.STATELESS
에서 IF_REQUIRED
로 변경ALWAYS
를 써도 무방하긴 합니다. 요청이 올 때 session을 만들겠다! 인데 GET을 제외한 거의 모든 요청에 session을 만들고 저장하니까요.위를 바탕으로 코드를 작성해보도록 하겠습니다.
XorCsrfTokenRequestAttributeHandler
활성화
cors
...
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) <-- 제거
간단하게 현재 세션의 token을 반환하게 하였습니다.
// CsrfController
@GetMapping("/api/csrf-token")
public CsrfToken getCsrfToken(CsrfToken csrfToken) {
return csrfToken;
}
// Return Value DTO
// 원래는 DTO를 반환할 필요가 없지만,
// 구조를 파악하기에는 이게 좋을것 같아 DTO class로 작성했습니다!
@Getter
@Setter
public class CsrfTokenResponseDto {
private String headerName = "X-XSRF-TOKEN";
private String parameterName = "_csrf";
private String token;
}
이때 토큰은 XorHandler라면 xor 처리된 token을, 일반 Handler라면 UUID를 반환합니다.
// authStore.ts - pinia 사용 중
export const useAuthStore = defineStore('auth', () => {
const csrfToken = ref<string>('')
...
return { csrfToken };
}
// App.vue
interface CSRFToken {
headerName: string;
parameterName: string;
token: string;
}
const authStore = useAuthStore()
const { data } = await tokenAxiosInstance.get<CSRFToken>('/api/csrf-token');
authStore.csrfToken = data.token
... 이하 local storage의 refresh token을 이용하여 재로그인 하는 로직
// token-axios-instance.ts
import axios, { type AxiosInstance } from 'axios'
import { useAuthStore } from '@/stores/auth'
const tokenAxiosInstance: AxiosInstance = axios.create({
baseURL: import.meta.env.VITE_BACKEND_URL,
withCredentials: true,
})
tokenAxiosInstance.interceptors.request.use(
(config) => {
const authStore = useAuthStore();
const csrfToken = authStore.csrfToken;
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken;
}
return config
},
(error) => Promise.reject(error),
)
export { tokenAxiosInstance }
위와 같이 로직을 설정하면 추가 로직을 더 하지 않아도 SPA 기반 Csrf 방지를 할 수 있습니다!
App.vue는 수정할 필요가 없고, axiosInstance 생성 부분만 고쳐주면 됩니다!
store도 만들 필요가 없어져서 더 간단해지고 csrf 보안이 확실하긴 하지만 매 request마다 불필요한 GET 요청이 발생합니다.
요즘 인터넷이 아무리 빠르다고 해도 서버에서는 부담이 되는 것이 사실이기도 하고요.
// controller
위의 controller를 그대로 사용합니다!
// store
그냥 제거해도 됩니다!
// axios-insteceptor
tokenAxiosInstance.interceptors.request.use(
async (config) => {
if (['post', 'put', 'patch', 'delete'].includes(config.method as string)) {
try {
const { data } = await axios.get(`${import.meta.env.VITE_BACKEND_URL}[endpoint]`, {
withCredentials: true
})
const csrfToken = data.token;
if (csrfToken) {
config.headers['X-XSRF-TOKEN'] = csrfToken;
}
} catch (error) {
return Promise.reject(error);
}
}
return config
},
(error) => Promise.reject(error),
)
그래서 어떻게 해야하나?
UUID 기반의 xsrf는 매우 좋지 않습니다. 그래서 Spring Security도 XorHandler를 추가한 것 같고요.
이에 따라 XorHandler를 쓰되, csrf 발급 엔드포인트를 만들고 이 엔드포인트에서 발급받아 백엔드로 넘기는 방법을 사용해야합니다.
이게 싫다면요? handler를 교체해서 쓰는 방법밖에 없죠.
타임리프에서는요?
기본적으로 _csrf
메타데이터를 넣으면 알아서 넣어준다고 합니다! 따라서 html에 ${_csrf.token}
, ${_csrf.parameterName}
같은 메타데이터만 넣고 template을 반환하면 자동으로 값을 채워준다고 하네요!
근데 요즘 누가 thymeleaf 써요 죄다 SPA 쓰지...