2024. 3. 13. 04:08ㆍSpring/Spring Security
[ CSRF ]
CORS는 웹 브라우저에서 제공하는 하나의 보호기능이다. 하지만 CSRF는 실제로 서버에 가해지는 공격이다. CSRF 공격은 사용자의 의지와 무관한 공격자의 의도대로 서버에 특정 요청을 일반 사용자가 보내는 것이다. 처음 말을 들었을 때 무슨 말인지 정확하게 이해가 안됐었다. 일단 아래의 예시를 한 번 봐보자.
어느 날 메일로 XX 카드에서 이벤트 행사를 하니, 웹 사이트에 접속을 하라는 요청을 받았다. 이 때 이메일을 받은 사용자는 웹 사이트에 접속을 했고 이벤트 페이지가 있었지만 흥미가 없는 이벤트라 페이지를 닫았다. 그런데 갑자기 카드 회사에서 100만원이 빠져나가버렸다는 문자가 왔다. 무슨 일일까?
이렇게 CSRF 공격은 공격자가 실제 사용자가 접속하고자 하는 사이트와 매우 유사한 위조 사이트를 URL로 안내한다. 공격자는 실제로 URL이 bank.com이라면 back.com 같은 방법으로 비슷한 도메인 이름을 가진 사이트를 만들고 내부 페이지도 유사하게 꾸며놓는다. 그럼 사용자는 바뀐 걸 인식하지 못한다.
만약에 bank.com에서 송금을 하는 API 스펙이 아래와 같이 있다고 해보자.
POST /transfer HTTP/1.1
Host: bank.com
Cookie: JSESSIONID=randomid
Content-Type: application/x-www-form-urlencoded
amount=100.00&routingNumber=1234&account=9876
공격자가 API 스펙을 알면 API 스펙에 맞게 미리 요청을 위조 사이트의 HTTP 코드나 JavaScript 코드에 hidden으로 숨겨 넣을 수 있다. 아래의 코드는 하나의 예시로 공격자의 계좌로 100만원을 보내게 하는 송금 API 내용을 미리 작성한 공격자의 숨겨진 코드이다.
<form method="post"
action="https://bank.example.com/transfer">
<input type="hidden"
name="amount"
value="100.00"/>
<input type="hidden"
name="routingNumber"
value="evilsRoutingNumber"/>
<input type="hidden"
name="account"
value="evilsAccountNumber"/>
<input type="submit"
value="Win Money!"/>
</form>
이렇게 만들어진 위조 사이트에 사용자가 그 링크를 클릭해 접속하면 위의 자바스크립트 코드가 사용자의 어떤 행동없이 접속했다는 이유만으로 작동된다. csrf 보호가 없는 사이트에서는 사용자 브라우저 내부에 있는 쿠키를 사용해서 bank.com으로 보내지고 사용자의 통장에선 100만원이 빠져나간다.
이것이 가능한 이유는 요청이 사용자가 실제로 원하는 사이트(bank.com)로 위조 사이트(back.com)가 대신 요청을 보내기 때문이다. 그래서 웹 브라우저는 bank.com으로 요청이 가니 bank.com에서 보낸 쿠키를 통해 요청을 한다. 그렇기 때문에 공격자는 쿠키 정보를 얻을 필요없이 피해자의 웹 브라우저의 쿠키를 사용해서 요청을 보낼 수 있었던 것이다.
[ CSRF 토큰 ]
중요한 건 bank.com 서버 측에서는 cors 보호가 있더라도 스펙에 맞는 요청을 받았을 때 이게 위조 사이트에서 bank.com으로 보낸 건지 실제 사용자가 bank.com에서 접근해서 공격한 건지 구분을 할 수가 없다는 것이다. 이 문제를 해결하기 위해 CSRF 토큰이 등장했다. CSRF 토큰이 있는 상황을 생각해보자.
서버에서는 사용자가 로그인을 하면 쿠키와 함께 랜덤한 CSRF 토큰을 생성해서 함께 보낸다. 이렇게 되면 back.com이라는 위조 사이트가 bank.com으로 사용자 브라우저에 있는 쿠키를 통해 악성 요청을 보낸다. 이 때 웹 브라우저에서는 인증 정보 쿠키만을 보낸다. 서버측은 csrf 토큰까지 기대했는데 csrf 토큰을 못 받았으니 위조 사이트에서 보낸 요청이므로 403 응답을 서버는 내보낸다.
이게 가능한 이유는 CSRF 토큰은 쿠키형태로 보내지는 게 아니고 Header나 Body/Payload로 보내진다. 뷰 애플리케이션 내부의 코드를 통해 인증과정에 해당 정보를 보관해달라 부탁을 하면, 이제부터는 CSRF 토큰로 보호되어야 하는 요청에는 서버에서 보낸 토큰 정보가 hidden으로 HTML 양식이나 Javascript 코드 부분에 추가로 작성이 돼서 나온다.
<form method="post"
action="/transfer">
<input type="hidden"
name="_csrf"
value="4bfd1575-3ad1-4d21-96c7-4ef2d9f86721"/>
<input type="text"
name="amount"/>
<input type="text"
name="routingNumber"/>
<input type="hidden"
name="account"/>
<input type="submit"
value="Transfer"/>
</form>
기존 CSRF 공격방식은 유사한 템플릿 코드를 작성하고 위조 사이트에 요청을 보내는 JavaScript나 HTML 코드를 추가하는 방식인데, 위조 사이트에서는 실제 사이트의 뷰 애플리케이션이 작성한 CSRF 토큰을 읽지 못한다. 그래서 위조 사이트에서의 요청은 CSRF 토큰을 서버로 보내지 못하고 서버는 위조 사이트에서 보낸 것임을 알아차리고 403 오류를 내보낸다.
[ Spring Security와 CSRF 토큰 구현 ]
CSRF 토큰을 구현하기 전에 모든 요청에서 CSRF 토큰이 필요한지를 생각해봐야 한다. 유저의 정보 변경과 관련된 API나 게시글을 올리는 API 같은 경우에는 보호를 받을 필요가 있다. 왜냐하면 유저에게 손해를 줄 수 있기 때문이다.
하지만 단순 문의글을 보내는 API나 단순 서버에서 공지사항 데이터를 불러오는 메서드는 공격자가 대신 요청해도 아무런 피해를 주지 않기 때문에 CSRF 토큰 방식을 사용하지 않아도 된다. 이는 csrf 토큰을 사용하지 않는 메서드를 csrf 부분에서 ignoringRequestMatchers를 통해 직접 지정할 수 있다.
Get Method API는 자동으로 이 설정을 적용시켜주므로 Post Method API 중 보호받아야 될 API만 정보를 추가해주면 된다.
확인용 코드:
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception{
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName("_csrf");
http.securityContext((context) -> context
.requireExplicitSave(false))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
.csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers( "/register", "/contact")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()));
return http.build();
}
위에서 추가된 CsrfTokenRequestAttributeHandler는 CSRF 토큰과 요청 속성을 지정하게 하는 클래스이다. 여기서는 어떤 이름의 헤더 혹은 쿠키로 UI 애플리케이션에 이 정보를 전달할 지 지정할 수 있다. 아랫 줄에 보면 CsrfTokenRepository라는 게 있는데, 쿠키와 헤더에서 CSRF 토큰을 보내기 위한 이름을 지정할 수 있다. 기본 값으로 쿠키 이름은 'XSRF-TOKEN', 헤더 이름은 'X-XSRF-TOKEN'으로 되어있다.
보내는 부분을 작성 완료했으면 요청을 받았을 때도 CSRF 토큰을 처리할 수 있게 하는 코드를 작성해야 한다. 이 필터는 요청을 받았을 대 CSRF 토큰을 확인하는 코드이다. null 부분을 체크하는 건 로그인 때는 요청에서 CSRF 토큰이 없을 것이니 서버에서 만든 CSRF 토큰으로 응답을 채워주는 코드라고 생각하면 된다.
확인용 코드:
public class CsrfCookieFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
CsrfToken csrfToken = (CsrfToken) request.getAttribute(CsrfToken.class.getName());
if(null != csrfToken.getHeaderName()){
response.setHeader(csrfToken.getHeaderName(), csrfToken.getToken());
}
filterChain.doFilter(request, response);
}
}
그리고 이제 다시 설정에서 addFilterAfter를 통해 CsrfCookieFIlter를 적용시켜주면 CSRF 토큰 부분의 구현은 끝이난다. 여기서 BasicAuthenticationFilter 이후로 해놓는 이유는 BasicAuthenticationFilter는 로그인 인증과 관련된 부분이기 때문이다. 이 필터에서 걸러지면 인증된 사용자가 아니다. 따라서 이 필터 이후에 CsrfCookieFilter를 세워 놓아야지 인증된 사용자가 보낸 요청에서만 생성된 CSRF 토큰 값을 요청에서 가져오고 응답에 추가해서 줄 수 있다.
또한 앞의 context.requiredExplicitSvae랑 sessionManagement는 Spring Security가 자체적으로 생성하고 저장하는 세션 정보이며, 이는 JWT 토큰에 비해 불편한 점이 많아 실제 운영에서 쓰이지 않으니 자세하게 알 필요는 없다.
확인용 코드:
@Bean
SecurityFilterChain defaultSecurityFilterChain(HttpSecurity http) throws Exception{
CsrfTokenRequestAttributeHandler requestHandler = new CsrfTokenRequestAttributeHandler();
requestHandler.setCsrfRequestAttributeName("_csrf");
http.securityContext((context) -> context
.requireExplicitSave(false))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.ALWAYS))
.cors(corsCustomizer -> corsCustomizer.configurationSource(new CorsConfigurationSource() {
@Override
public CorsConfiguration getCorsConfiguration(HttpServletRequest request) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(Collections.singletonList("http://localhost:4200"));
config.setAllowedMethods(Collections.singletonList("*"));
config.setAllowCredentials(true);
config.setAllowedHeaders(Collections.singletonList("*"));
config.setMaxAge(3600L);
return config;
}
})).csrf((csrf) -> csrf.csrfTokenRequestHandler(requestHandler).ignoringRequestMatchers( "/register", "/contact")
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.addFilterAfter(new CsrfCookieFilter(), BasicAuthenticationFilter.class);
return http.build();
}
위처럼 구현을 하면 로그인이 될 시 스프링에서는 'XSRF-TOKEN'이라는 이름으로 CSRF 토큰을 보내준다. 그러면 프론트 엔드 서버에서는 해당 토큰을 저장하는 로직을 구현해야 하고 쿠키가 아니라 'X-XSRF-TOKEN' 헤더 형태로 바꿔서 스프링의 웹서버에 넘겨주는 과정을 코드로 추가해야 한다. 같이 협업을 하고 위와 같은 방식으로 프로젝트를 진행한다면 이 사실을 반드시 프론트엔드 개발자에게 알려주자.
[ 참고 자료 ]
https://docs.spring.io/spring-security/reference/features/exploits/csrf.html#csrf-explained
Spring Security 6 초보에서 마스터되기 최신 강의! - Eazy Bytes
'Spring > Spring Security' 카테고리의 다른 글
스프링 시큐리티 - 필터와 필터 디버깅 (0) | 2024.03.18 |
---|---|
스프링 시큐리티 3. 인가( JWT 기반 Token 인가 구현 ) (0) | 2024.03.17 |
스프링 시큐리티 2. 인증(AuthenticationProvider 작동 방식과 커스텀 AuthenticationProvider 작성) (0) | 2024.03.16 |
스프링 시큐리티 1. 암호화(PasswordEncoder를 통한 회원 가입 구현) (0) | 2024.03.16 |
CORS와 CORS 설정 (0) | 2024.03.13 |