XSRF 넌 대체 뭔데 날 괴롭히냐

HanSH·2025년 6월 12일
0

SpringBoot 삽질기

목록 보기
10/12

현재 프로젝트가 semi-stateless 환경입니다.
JWT를 쓰되, http only cookie를 쓰고있죠.
따라서 cross site 상황에서는 cookie가 자동 전송될 수 있습니다.
이걸 해결해야겠죠?

CSRF의 도입 배경

기존엔 아래와 같이 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 함수를 확인해봅시다.
일단 requestHandlerXorCsrfTokenRequestAttributeHandler임을 기억해둡시다. 중요합니다 이거.

DebugFilter의 적용

@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);
    }
}

조금 길지만 로직을 뜯어보면

  1. request의 header에서 X-XSRF-TOKEN의 값과 쿠키에서 XSRF-TOKEN 쿠키의 값을 가져옴
  2. 현재 활성화 되어있는 세션을 가져옴(쿠키의 JSESSIONID값 기반)
  3. session이 있다면 sessionCsrf 토큰 가져옴

밖에 없습니다.
이걸 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 환경에서 완벽한 해결 방법은...

Csrf Token을 interceptor로 미리 fetch

POST/PATCH/PUT/DELETE 요청
         ↓
axios에서 intercept
         ↓
CSRF 발급 endpoint로 GET 요청
         ↓
CSRF 토큰을 Header에 넣어 요청

또는

SPA 페이지 로딩
         ↓
CSRF 발급 endpoint로 GET 요청
         ↓
CSRF 토큰을 store에 저장
         ↓
이후 요청때마다 Header에 넣어 요청

이런 방식으로 진행하면 됩니다!
첫 번째 방식은 계속 GET 요청을 보내니 자원 소모가 심한 편입니다.
두 번째 방식은 처음 한번만 받으니 문제가 없지만, 세션 만료시간에 민감하죠.
SPA에 주로 쓰는 방식은 두 번째 방식입니다! 사용자 환경이 더 중요하니까요.

두 번째 방식을 사용해보도록 하겠습니다!

로직 적용

적용에 앞서 몇 가지 해줘야 하는 선행 사항이 있습니다.

  1. 위에서 사용했던 일반 Handler를 XorHandler로 전환
    • csrf.csrfTokenRequestHandler 이 부분을 없애주면 됩니다.
  2. csrf 발급 엔드포인트 추가
  3. 세션 상태를STATELESS에서 IF_REQUIRED로 변경
    • 사실 ALWAYS를 써도 무방하긴 합니다. 요청이 올 때 session을 만들겠다! 인데 GET을 제외한 거의 모든 요청에 session을 만들고 저장하니까요.

위를 바탕으로 코드를 작성해보도록 하겠습니다.

SecurityConfig

XorCsrfTokenRequestAttributeHandler 활성화

cors
  ...
  .csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler()) <-- 제거

Csrf Assign Controller

간단하게 현재 세션의 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를 반환합니다.

Frontend

// 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 쓰지...

profile
저는 말하는 싹 난 감자입니다

0개의 댓글