Skip to content
Polymorlog

XSS 보안, lucy filter 하나로 충분할까? 필터 적용과 보완책 정리

java, xss, lucy-filter18 min read

XSS 보안 취약점 발견

지난 모의해킹에서 'XSS 공격에 대한 보안 취약점이 발견되었습니다.' 라고 보안팀으로 부터 전달 받고, 스크럼에서 이를 누가 맡아서 할까 이야기를 나눴다. 이전부터 개별 API 에서 방지 처리를 하는 것 외에, 전체적인 요청에 대한 검증 처리를 해야한다고 느끼고 있었기 때문에 먼저 몇 가지 내 의견을 제시 했다. 필터에서 모든 요청에 대해 XSS 방지를 처리하면, 개별 API 엔드포인트에서 처리해야 하는 수고를 덜 수 있다는 점. 그리고 그렇게 하면, 엄밀히 말해 모든 요청에 대한 테스트를 다시 수행해야 한다는 점이었다.


의견을 내다보니 자연스럽게 나의 일이 되었다. 검증에 대한 고민은 일단 우리가 1월 부터 시도해보고 있는 공통 테스트 케이스가 있으니 이를 가지고 1차 검증 해보자로 결정이 났다. 다만 이 필터 처리로 인한 영향도를 팀원들이 이해하지 못하면, 기타 문제 상황에 대처하기 어렵다는 생각이 들었다. 그래서 팀원들에게도 설명할 겸, XSS가 무엇인지, lucy-filter를 왜 선택했는지, 선택하지 않은 대안은 무엇인지, 그리고 lucy-filter를 어떻게 이해하고 적용해야 하는지를 정리해 보고자 한다.

XSS 공격 방어에 대한 이해와 선택 과정

XSS 란 무엇이고 왜 중요한가

XSS 가 무엇인지, 그 중요도가 어떠한지를 모르면 이것을 lucy filter 로 처리하기로 했다는 것이 와닿기가 어려울 것이다. XSS(Cross Site Scrip, 크로스 사이트 스크립팅)는 악성 스크립트 삽입을 통해 공격하는 보안 취약점이다. 접두문자로 따지면 CSS가 되어야 하겠지만 CSS(Cascading Style Sheets)와 겹치는 것도 있고, 영미권에서는 Cross를 교차한다는 의미로 줄여 X로 대체하여 쓰기도 해서 XSS로 표기한다고 한다.


여하간에 악성 스크립트 삽입을 도대체 어떻게 한다는 것일까? 보통은 사용자 입력 값을 저장하고, 이를 다시 사용자에게 뿌리는 데이터에 대해 스크립트 코드를 등록하여 공격을 한다. 자세한 것은 실제 사례를 보면 이해가 빠를 것 같다.

트위터 리트윗 케이스

먼저 지금은 ‘X’로 이름이 변경된 트위터의 사례다. 2010년도 당시 트위터에서는 닉네임에 마우스를 올려놓으면 특정 이벤트가 발생하게 하는 XSS 공격이 있었다. 사용자 닉네임과 같은 입력 필드에 onmouseover 이벤트로 공격자가 원하는 스크립트를 심어둔 것이다. 마우스만 스치면 RT(리트윗)가 되는 바람에 삽시간에 RT가 퍼져나가면서 문제가 일파만파 커졌다고 한다. 지금 남은 자료로 보면 트위처 측에서는 아마 이스케이프 처리를 하면서 해당 문제를 조치한 것으로 보인다.

Zoom 채팅 링크 케이스

2010년이면 옛날 일이라고만 생각할 수도 있다. 그러나 비교적 최근에도 XSS 관련된 문제가 있었다. 그것도 글로벌한 기업의 서비스에서 말이다. (어쩌면 사용자가 많은 서비스를 대상으로 공격하는게 당연할 수도 있겠지만) 글로벌 화상 회의 서비스인 Zoom 에서 발생한 문제가 바로 그것이다. Zoom 에서의 XSS 공격은 화상 회의 내의 채팅에서 자동화 된 코드 스니펫 처리 등을 이용해 공격자가 원하는 코드를 실행시킨 XSS 공격이었다.


이처럼 XSS는 널리 알려져있는 문제지만 또 서비스의 크고 작음과 무관하게 발생하는 문제이기도 하다.

내가 XSS 처리로 lucy filter 를 선택한 이유

이 문제를 우리 서비스에서 해결하기 위해 고려한 측면은 크게 2가지 인데, 기술적인 제약 측면과 운영 상의 편의 측면이었다.

기술적인 제약 측면

먼저 기술적인 제약으로는 우리 서비스가 Spring Boot 와 Spring Security 를 사용하고 있다는 점이 중요했다. Spring Security 를 이미 사용중이므로 여기서 제공하는 간단한 XSS 방어 기능을 사용할 수도 있었다. SecurityFilterChain 쪽에 Content Security Policy 헤더에 대한 설정을 하는 것이다.


@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.headers()
.contentSecurityPolicy("script-src 'self')
(...)
.build();
}

이 설정의 다양한 옵션을 활용하면 inline script 실행을 차단할 수 있기 때문에, 비교적 간편하게 방어를 할 수 있다. 그러나 그러나 화면을 렌더링하는 코드 내에 이미 너무 많은 인라인 스크립트가 실행되고 있기 때문에 이런 식으로 막는 것은 배보다 배꼽이 더 커지는 상황이었다.(화면 코드의 기술 부채가 심각한 수준이랄까.)


다음으로 고려할 수 있는 옵션들은 MVC 의 구조상 곳곳에서 커스텀하게 처리하는 방법이 있었다. Filter를 별도로 구현한다던지 직렬회/역직렬화 구간에서 별도 설정을 넣는다던지 하는 것이다. 혹은 다른 ESAPISanitizer와 라이브러리를 쓰는 선택지도 있었다. 다만 lucy-filter가 앞단을 처리하게 하고 비즈니스 레벨 코드의 변경이 필요 없어 보이는데 반해, 이 라이브러리 들은 코드 상에서 매번 직접 응답을 한번 감싸주어야 한다는 것이 좀 불편해보였다.

운영 상의 편의 측면

어쩌면 결정적인 것은 운영 상의 이유였는데, 이미 사내 서비스에서 더러 lucy-filter를 사용 중이었기 때문에 전체 적인 러닝 커브가 줄어들 것이라고 생각을 했다. 또 역으로 그냥 단순히 코드를 복붙히는 식으로 lucy-filter가 적용된 서비스들은 각각의 더 적절한 설정을 제공할 기회라고도 판단했다.


그래서 최종적으로는 lucy-filter로 처리하기로 결정을 했다.

lucy filter의 이해와 적용 테스트를 위한 테스트 코드

lucy-filter는 네이버에서 만들었기 때문에 한글 가이드도 있고, 이미 잘 정리된 글들을 쉽게 찾아볼 수 있을 것이다. 대신 내가 주목하고 싶은 것은 필터가 동작하는 구조적 측면과 이 적용을 테스트하기 위해 어떤 방법을 했는지 설명하겠다.

lucy filter 구조적 측면

lucy-filter는 서블릿 필터로 구현되어 있기 때문에, 서블릿 필터의 구조를 이해하면 쉽게 이해할 수 있다. 서블릿 필터는 요청과 응답을 가로채서 필터 체인을 통해 처리하는 구조이다. lucy-filter는 이 필터 체인을 통해 요청을 가로채서 XSS 방어 처리를 한다. 필터를 적용하는 형태는 lucy-filter가 제공하는 XssEscapeServletFilter 를 필터로 등록해주면 된다.


@Configuration
public class WebConfig implements WebMvcConfigurer {
// (...) 중략
@Bean
public FilterRegistrationBean<XssEscapeServletFilter> filterRegistrationBean() {
FilterRegistrationBean<XssEscapeServletFilter> registrationBean = new FilterRegistrationBean<>();
registrationBean.setFilter(new XssEscapeServletFilter());
registrationBean.setOrder(1);
registrationBean.addUrlPatterns("/*");
return registrationBean;
}
}

이렇게 등록한 lucy-filter를 통해 모든 케이스가 처리되면 좋겠지만, application/json 타입 요청에 대한 필터 처리가 되지 않는다. 앞서 말했듯이 lucy-filter는 서블릿 필터로 처리하는데 이 떄 요청값을 읽어들이는 메세드가 고정되어 있어 특정 케이스에서는 필터 처리가 되지 않는 것이다.


자세히 살펴보자면 XssEscapeServletFilter에서 필터 체인을 등록할 떄, XssEscapeServletFilterWrapper을 이용하는데 이는 HttpServletRequestWrapper를 상속하고 있다. XssEscapeServletFilterWrapper에서 파라미터를 불러올때는 이 HttpServletRequestWrapper가 가지고 있는 ServletRequest 의 메소드를 통해 파라미터를 읽는다. 이떄 부모 클래스를 생성하며 HttpServletRequestServletRequest를 캐스팅 하고 있고, XssEscapeServletFilterWrapper가 이 request의 파라미터를 읽기 위해 제공하는 메소드는 getParameter, getParameterMap, getParameterValues 뿐이다. 따라서 HttpServletRequest를 통해 요청 파라미터를 읽는 요청 처리 유형 중에 이 3가지를 쓰지 않는 경우는 필터가 되지 않는 것이다.


[//]: # ((텍스트로만으로 이해하기 어려울 수도 있겠다. lucy-filter의 더 자세한 구조를 알고 싶다면 이 글을 참고해보자.))


따라서 JSON 데이터에 추가적인 조치가 필요했다. 이 MessageConverter에서 하도록 했는데 MappingJackson2HttpMessageConverter가 convert 하는 과정에서 특수문자를 이스케이프 하는 방향으로 구현했다. 보통 JacksonMessageConverter 로 사용하는 경우가 많기 때문에 ObjectMapper를 커스텀하여 이스케이프 처리를 할 수 있었다. MessageConverter가 직렬화/역직렬화 모두에 영향을 미치기 때문에 한 번의 설정으로 방지 처리가 되는 효과도 있다. 설정 코드는 아래와 같다.


@Configuration
public class WebConfig implements WebMvcConfigurer {
// (...) 중략
@Override
@Bean
public MappingJackson2HttpMessageConverter jsonEscapeConverter() {
ObjectMapper objectMapper = new ObjectMapper();
objectMapper.getFactory().setCharacterEscapes(new HTMLCharacterEscapes());
MappingJackson2HttpMessageConverter converter = new MappingJackson2HttpMessageConverter();
converter.setObjectMapper(objectMapper);
return converter;
}
}
// HTMLCharacterEscapes.java 는 CharacterEscapes 를 상속받아서 구현한 클래스

결국 요약하면 Spring을 통한 다양한 요청 처리 형식이 있고, 이를 각각 lucy-filter와 MappingJackson2HttpMessageConverter를 통해 처리한 것이다. 아래 표는 주요 형식에 대한 각 설정의 방지 가능 여부를 보여준다. 현재 설정으로는 요청 헤더나 multipart 부분이 빠져있지만, 파일을 입력 받는 엔드포인트가 없고, 요청 헤더 값을 특별히 입력하여 사용하는 곳이 없어 우선순위에서는 제외 되었다.


사용 위치주로 사용하는 HttpServletRequest 메서드Lucy XSS 필터 (XssEscapeServletFilter) 처리 가능 여부MappingJackson2HttpMessageConverter 처리 가능 여부
Spring @RequestParam (Form 데이터 처리)getParameter(), getParameterValues(), getParameterMap()✅ 가능❌ 불가능
Spring @RequestBody (JSON 요청 처리)getReader(), getInputStream()❌ 기본적으로 처리 안 됨✅ 가능 (Jackson ObjectMapper 설정 필요)
Spring @RequestHeader (헤더 값 조회)getHeader()❌ 기본적으로 처리 안 됨❌ 불가능
Spring 필터 (Filter)getParameter(), getParameterValues(), getQueryString(), getHeader()✅ (쿼리스트링과 getParameter*() 가능) / ❌ (getHeader() 불가능)❌ 불가능
Spring 인터셉터 (HandlerInterceptor)getParameter(), getQueryString(), getHeader()✅ (쿼리스트링과 getParameter*() 가능) / ❌ (getHeader() 불가능)❌ 불가능
Multipart 요청 (@RequestPart)getInputStream() (파일 업로드)❌ 기본적으로 처리 안 됨❌ 불가능

lucy filter 적용 테스트를 위한 테스트 코드

lucy-filter가 적용되었다면 이제 정상적으로 요청 응답 처리가 되는지 봐야한다. 더 자세한 부분도 있겠지만 간단히 확인해 볼 수 있는 몇 가지 테스트를 소개하겠다.


아래 테스트들이 요청하는 API 엔드포인트를 제공하는 Controller 는 테스트 패키지에 선언된 테스트 용도의 Controller 다. /api/test/xss 로 요청을 하는 경우 요청 파라미터 그대로 다시 응답하도록 했다. 이 경우 lucy-filter가 적용되었다면 요청 파라미터에 XSS 스크립트가 포함되어 있을 때 이를 이스케이프 처리하여 응답해야 한다. 테스트는 아래와 같이 진행했다.

@Test
@DisplayName("POST 요청에서 XSS 필터가 정상 작동하는지 테스트")
void testXssFilteringInPostRequest() throws Exception {
// Given
TestRequest request = new TestRequest();
request.setContent("<script>alert('xss');</script>");
request.setDescription("<img src='invalid' onerror='alert(\"xss\")'>");
// When & Then
mockMvc.perform(post("/api/test/xss")
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.content").value("&lt;script&gt;alert('xss');&lt;/script&gt;"))
.andExpect(jsonPath("$.description").value("&lt;img src='invalid' onerror='alert(&quot;xss&quot;)'&gt;"))
.andDo(print());
}

이 테스트가 정상적으로 통과하는 경우, 우리는 요청 값의 특수 문자가 정상적으로 이스케이프 되고 XSS 코드가 HTML 엔티티로 변환됨을 알 수 있다. 이와 유사한 방식으로 GET 요청, 다양한 XSS 패턴, Content-Type별 동작 차이 등을 추가적으로 검증할 수 있다. (거의 유사한 코드 베이스이기 때문에 테스트 코드 자체는 생략했다.)

마치며

혹 XSS 공격 방지를 위해 lucy-filter를 적용할 예정이라면, 아래와 같이 반문해 보았으면 좋겠다.


  • Q. 서비스의 문제 상황에서 XSS 필터 처리 쪽을 확인해봐야 하는 케이스를 떠올릴 수 있는가?
  • Q. lucy 필터 통해 XSS 방지를 원하는 대로 커스텀 해볼수 있을 것 같은가?
  • Q. 서비스가 겪을 다른 문제 상황에 따라, 더 적절한 XSS 방어책을 선택할 수 있는가?

만약 위의 질문에 대해 아직 긍정적으로 답하기 어렵다면, lucy-filter를 적용하기 전에 더 많은 고민을 해보는 것이 좋을 것 같다.(물론 같이 고민 해달라는 요청도 언제나 환영이다.) lucy-filter 자체는 블로그 글 몇 개 보며 따라할 수 있는 쉬운 구현이지만, 그 쉬움에 비해 기본적이고 중요한 기능이기 때문이다. 여유가 없어 놓쳤다면, 작은 노력으로 시도해 보면 좋을 것 같다.



📢 글의 작은 부분이라도 피드백 주시는 것은 언제나 환영합니다 🙌

참고

© 2025 by Polymorlog. All rights reserved.
Theme by LekoArts