본문 바로가기
Backend/Spring

[Spring] Spring Boot XSS Filter 생성하기

by 볼링치는 개발자 2023. 12. 31.
반응형

XSS란?

XSS(Cross Site Scripting)란 사이트 간 스크립팅이라는 웹 보안 취약점입니다.

악의적인 사용자가 취약한 웹 애플리케이션에 스크립트를 삽입해 공격하는 것입니다.

XSS 공격 유형

  1. Reflected XSS : URL을 통해 Script 실행
  2. Stored XSS : Script를 서버에 저장하여 요청/응답 과정을 통해 실행
  3. DOM Based XSS : 피해자의 브라우저가 DOM생성할 때 악성 script 실행

위험성

  1. 쿠키 및 세션정보 탈취
  2. 악성 프로그램 다운 유도
  3. 의도하지 않은 페이지 노출

이 글에서는 제가 XSS 보안 취약점을 해결하기 위한 과정을 공유하려 합니다.

조사 결과, XSS 방지할 수 있는 다양한 후보가 있었습니다.

(결론부터 말하자면, 일반적인 케이스에서는 4.번이 해결책이 되며, 저는 5.번으로 해결하였습니다.)

  1. lucy-xss-filter
  2. lucy-xss-servlet-filter
  3. ObjectMapper 커스터마이징
  4. filter 자체 제작

참고) 여기서 XSS 필터링이란 아래처럼 script 태그들을 변환하여 script가 실행되지 않도록 하는 것입니다.

변환 전: <img src=# onerror=alert(document.cookie)>
변환 후: &ltimg src=# onerror=alert(document.cookie)&gt;

 

 

1. luxy-xss-filter (실패)

lucy-xss-filter는 들어오는 요청 중 HTML 태그들을 치환하는 네이버에서 제작한 filter입니다.

https://github.com/naver/lucy-xss-filter

XSS 필터링이 필요한 곳에 개발자가 필터를 삽입하여 방어하는 방법입니다.

< → &lt;
> → &gt;
" → &quot;
' → &#39;

하지만 lucy-xss-filter는 아래와 같은 한계점으로 사용하지 않았습니다.

  • 휴면에러로 인해 필요한 곳에 XSS 방어코드 누락
  • 불필요한 곳에 XSS 방어코드 적용
  • XSS 방어코드 분산되어 유지보수 비용 증가

 

2. lucy-xss-servlet-filter (실패)

lucy-xss-servlet-filter는 lucy-xss-filter와 달리 애플리케이션으로 들어오는 모든 요청 파라미터에 대해 XSS 방어로직을 수행합니다.

https://github.com/naver/lucy-xss-servlet-filter

설정한 url, 파라미터 명 등으로 filtering 제외 설정도 가능합니다.

설정 파일 하나로 XSS 방어절차를 관리할 수 있으며, XSS 방어가 누락되지 않는 장점이 있습니다.

다만 일괄 적용되기 때문에 정확한 필터링 룰 설정이 필요하고, 제작자분들도 기존 서비스에 적용하는 것을 비추천하고 있습니다.

lucy-xss-servlet-filter README 일부분

단, lucy-xss-servlet-filter는 form-data, request param에만 적용되고, RestAPI의 Http POST 등 요청으로 넘어오는 request body(JSON data 등)는 필터링이 안 되는 단점이 있어서 제가 직면한 문제는 해결이 불가능했습니다.

 

3. ObjectMapper 커스터마이징 (실패)

ObjectMapper는 RestAPI로 들어오는 요청의 JSON body를 Java DTO로 변환해 주고, Java DTO를 HTTP 응답 메시지로 변환하는 역할을 수행합니다.

이를 활용해서 아래와 같이 XSS를 방지할 수 있습니다.

  1. 들어온 요청의 JSON 데이터를 DTO로 변환할 때 필터링
  2. 보낼 응답의 DTO를 JSON 데이터로 변환할 때 필터링

이 중에 2. 번을 택해 악성 script가 서버에 저장되더라도 클라이언트 측으로 전송 시 HTML 태그가 필터링되어 script 실행이 안되도록 적용해 보겠습니다.

HandlerInterceptor의 postHandle()

Response에 공통으로 처리해줘야 하는 상황이 생기면 가장 먼저 떠오르는 것이 HanderInterceptor의 postHandle() 메서드입니다.

HandlerInterceptor의 postHandle() 메서드

다만, HttpServletResponse에서 response body를 꺼내는것도 어렵고, 꺼냈다 하더라도 원하는대로 XSS 필터링 하여 수정하여 다시 HttpServletResponse 에 담는 것도 쉽지 않습니다.

MessageConverter 커스터마이징

Jackson 같은 Mapper를 통해 Http Message와 JSON 간 변환이 이루어집니다.

서버가 응답을 돌려줄 때 Mapper가 서버의 JSON 문자열을 HttpMessage로 변환할 텐데, 이때 XSS 필터링 처리하는 방법으로 시도해 보았습니다.

  1. 필터링 처리할 특수 문자와 특수 문자 인코딩 값 지정
  2. ObjectMapper에 특수 문자 처리 기능 적용
  3. MessageConverter에 커스텀 한 ObjectMapper 설정
  4. WebMvcConfigurerAdapter를 통해 MessageConverter 추가

위의 단계들을 아래에서 상세하게 코드로 작성해보았습니다.

 

1. 필터링 처리할 특수 문자와 특수 문자 인코딩 값 지정

public class HTMLCharacterEscapes extends CharacterEscapes {

    private final int[] asciiEscapes;

    private final CharSequenceTranslator translator;

    public HTMLCharacterEscapes() {

        // 1. XSS 방지 처리할 특수 문자 지정
        asciiEscapes = CharacterEscapes.standardAsciiEscapesForJSON();
        asciiEscapes['<'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['>'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['&'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['\"'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['('] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes[')'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['#'] = CharacterEscapes.ESCAPE_CUSTOM;
        asciiEscapes['\''] = CharacterEscapes.ESCAPE_CUSTOM;

        // 2. XSS 방지 처리 특수 문자 인코딩 값 지정 (Apache Commons Lang3에서 지원하는 것 쓰면 불필요)
        translator = new AggregateTranslator(
            new LookupTranslator(EntityArrays.BASIC_ESCAPE()),  // <, >, &, " 는 여기에 포함됨
            new LookupTranslator(EntityArrays.ISO8859_1_ESCAPE()),
            new LookupTranslator(EntityArrays.HTML40_EXTENDED_ESCAPE()),
            // 여기에서 커스터마이징 가능
            new LookupTranslator(
                new String[][]{
                    {"(",  "&#40;"},
                    {")",  "&#41;"},
                    {"#",  "&#35;"},
                    {"\'", "&#39;"}
                }
            )
        );
    }

    @Override
    public int[] getEscapeCodesForAscii() {
        return asciiEscapes;
    }

    @Override
    public SerializableString getEscapeSequence(int ch) {
        return new SerializedString(translator.translate(Character.toString((char) ch)));

        // 참고 - 커스터마이징이 필요없다면 아래와 같이 Apache Commons Lang3에서 제공하는 메서드를 써도 된다.
        // return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) ch)));
    }
}

 

 

2. ObjectMapper에 특수 문자 처리 기능 적용

3. MessageConverter에 커스텀 한 ObjectMapper 설정

4. WebMvcConfigurerAdapter를 통해 MessageConverter 추가

@Bean
public WebMvcConfigurerAdapter controlTowerWebConfigurerAdapter() {

  return new WebMvcConfigurerAdapter() {

    @Override
    public void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
      super.configureMessageConverters(converters);

      //4. WebMvcConfigurerAdapter에 MessageConverter 추가
      converters.add(htmlEscapingConveter());
    }

    private HttpMessageConverter<?> htmlEscapingConveter() {
      ObjectMapper objectMapper = new ObjectMapper();
            
      //2. ObjectMapper에 특수 문자 처리 기능 적용
      objectMapper.getFactory().setCharacterEscapes(new HTMLCharacterEscapes());

      //3. MessageConverter에 ObjectMapper 설정
      MappingJackson2HttpMessageConverter htmlEscapingConverter =
        new MappingJackson2HttpMessageConverter();
      htmlEscapingConverter.setObjectMapper(objectMapper);

      return htmlEscapingConverter;
    }
  };
}

 

 

하지만 위 기능은 WebMvcConfigurerAdapter가 스프링 부트 버전이 올라가면서 Deprecated 되어 적용이 불가능합니다.

 

그럼 해결책으로, WebMvcConfigurer을 구현하는 Config 클래스를 작성해 보겠습니다.

@Slf4f
@Configuration
@EnableWebMvc
public class WebMvcConfig implemtns WebMvcConfigurer {

  /* 기존 소스코드 중략 */

  //커스텀한 MessageConverter 추가
  @Override
  public void configureMessageConverters(List<HttpMessageConverter<?> converters) {
    log.info(">>>> [WebMvcConfig]");
    converters.add(htmlEscapingConverter());
  }

  //커스텀한 ObjectMapper를 MessageConverter에 추가
  private HttpMessageConverter<?> htmlEscapingConverter() {
    ObjectMapper objectMapper = new ObjectMapper();
    objectMapper.getFactory().setCharacterEscapes(new HTMLCharacterEscapes());

    return new MappingJackson2HttpMessageConverter(objectMapper);
  }
}

 

 

여기까지 설정하였을 때, 일반인 RestAPI에서는 XSS 필터링이 성공적으로 되었습니다.

(일반적으로 여기까지 하셨으면 웬만한 Java Backend 애플리케이션에서는 XSS 방어가 수행될 것입니다)

XSS 방어가 되는 모습

 

단, 제가 담당하고 있던 웹 애플리케이션은, 특이하게 솔루션으로 되어있어, RestAPI 형식으로는 가능하지만, 솔루션 기능이 먹통이 되었습니다.

이유는 이전에 WebMvcConfig 클래스에서 설정한 @EnableWebMvc 때문입니다.

Spring에서는 기본적으로 별도 설정이 없으면 HttpMessageConverter에 아래 Converter들이 추가됩니다.

@EnableWebMvc 없을때 MessageConverter 리스트

  • ByteArrayHttpMessageConverter
  • StringHttpMessageConverter
  • FormHttpMessageConverter
  • MappingJacksonHttpMessageConverter / MappingJackson2HttpMessageConverter2
  • ResourceHttpMessageConverter

 

@EnbaleWebMvc를 추가하면, 위 MessageConvert 목록이 아래와 같이 해당 어노테이션이 설정하는 MessageConveter로 덮어씌워집니다.

(지금의 경우에는 XSS 필터링을 위해서 커스터마이징 한 ObjectMapper가 들어있는 MessageConvert가 전부)

MessageConverter 설정이 이렇게 되면서, 일반적인 JSON 필터링은 되는데, 내부 동작이 달랐던 솔루션은 기능 자체가 마비되었습니다.

결국, 일반적인 RestAPI 백엔드 서버는 이 방법으로 XSS 필터링이 되겠지만, 제가 사용하고 있는 솔루션 애플리케이션은 이 방법으로도 해결이 불가능했습니다.

 

 

4. Filter 자체 제작 (해결)

결국 최후의 방법으로, fitler를 자체 제작 하여 해결했습니다.

 

1. Filter를 구현하는 XSS 필터 생성

@Slf4j
@Component
public class CustomXssFilter implements Filter {

  private FilterConfig filterConfig;

  @Override
  public void init(FilterConfig filterConfig) throws ServletException {
    this.filterConfig = filterConfig;
  }

  @Override
  public void destroy() {
    this.filterConfig = null;
  }

  //실제 XSS 필터링 실행하는 부분
  @Override
  public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
    log.info("Start XssFiltering");
			
    //들어온 HttpRequest 분해하여 xss 필터링
    HttpServletRequestWrapper requestWrapper = new XssServletWrapper((HttpServletRequest) request);
    chain.doFilter(requestWrapper, response);
  }
}

 

2. HttpRequest 분해하여 script 태그들 치환하는 XssServeltWrapper

public class XssServletWrapper extends HttpServletRequestWrapper {
    
  private byte[] rawData;

  public XssServletWrapper(HttpServletRequest request) {
    super(request);
    try {
      //reqeust를 inputStream으로 바이트 변환하여 XSS 필터링
      if(request.getMethod().equalsIgnoreCase("post") && (request.getContentType().equals("application/json") || request.getContentType().equals("multipart/form-data"))) {
        InputStream is = request.getInputStream();
        this.rawData = replaceXSS(IOUtils.toByteArray(is));
      }
    } catch (IOException e) {
      e.printStackTrace();
    }
  }

  //바이트 단위 script 태그 치환
  private byte[] replaceXSS(byte[] data) {
    String strData = new String(data);
    strData = strData.replaceAll("\\\\<", "&lt;")
                     .replaceAll("\\\\>", "&gt;")
                     .replaceAll("\\\\(", "&#40;")
                     .replaceAll("\\\\)", "&#41;");

    return strData.getBytes();
  }

  //문자열 단위 script 태그 치환
  private String replaceXSS(String value) {
    if(value != null) {
      value = value.replaceAll("\\\\<", "&lt;")
                   .replaceAll("\\\\>", "&gt;")
                   .replaceAll("\\\\(", "&#40;")
                         .replaceAll("\\\\)", "&#41;");
        }
        return value;
    }

		
    @Override
    public ServletInputStream getInputStream() throws IOException {
        if(this.rawData == null) {
            return super.getInputStream();
        }
        final ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(this.rawData);

        return new ServletInputStream() {

            @Override
            public int read() throws IOException {
                // TODO Auto-generated method stub
                return byteArrayInputStream.read();
            }

            @Override
            public void setReadListener(ReadListener readListener) {
                // TODO Auto-generated method stub
            }

            @Override
            public boolean isReady() {
                // TODO Auto-generated method stub
                return false;
            }

            @Override
            public boolean isFinished() {
                // TODO Auto-generated method stub
                return false;
            }
        };
    }

    @Override
    public String getQueryString() {
        return replaceXSS(super.getQueryString());
    }

    @Override
    public String getParameter(String name) {
        return replaceXSS(super.getParameter(name));
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        Map<String, String[]> params = super.getParameterMap();
        if(params != null) {
            params.forEach((key, value) -> {
                for(int i=0; i<value.length; i++) {
                    value[i] = replaceXSS(value[i]);
                }
            });
        }
        return params;
    }

    @Override
    public String[] getParameterValues(String name) {
        String[] params = super.getParameterValues(name);
        if(params != null) {
            for(int i=0; i<params.length; i++) {
                params[i] = replaceXSS(params[i]);
            }
        }
        return params;
    }

    @Override
    public BufferedReader getReader() throws IOException {
        return new BufferedReader(new InputStreamReader(this.getInputStream(), "EUC-KR"));
    }
}

 

3. 제작한 Xss filter를 FilterRegistrationBean 통해 필터 등록

(해당 코드 없이 필터기능을 타긴 타는데, 보안취약점이 발생한 특정 url에만 적용하고 싶은 요구조건으로 추가)

@Bean
public FilterRegistrationBean<CustomXssFilter> xssFilter() {

	FilterRegistrationBean<CustomXssFilter> registrationBean = new FilterRegistrationBean<>();
	registrationBean.setFilter(new CustomXssFilter());
	registrationBean.addUrlPatterns("/url/urlPattern1",
                                  "/url/urlPattern2",
                                  "/url/urlPattern3",
                                  "/url/urlPattern4",
                                  "/url/urlPattern5",
                                  "/url2/urlPattern1",
                                  "/url2/urlPattern2");

	return registrationBean;
}

위 필터링 기능으로, 내부 솔루션 애플리케이션에서도 XSS 필터링이 성공적으로 진행되었습니다.

 

 

PS.

아래 링크는 제가 공부 당시 많은 내용을 참조한 사이트입니다.

https://jojoldu.tistory.com/470

 

Spring Boot에서 JSON API에 XSS Filter 적용하기

일반적인 웹 애플리케이션에서 기본적으로 해야할 보안으로 XSS 방지가 있습니다. 기존에 많이들 알고 계시는 lucy filter의 단점은 이미 오명운 님께서 잘 정리해주셨기 때문에 한번쯤 읽어 보셔

jojoldu.tistory.com

https://homoefficio.github.io/2016/11/21/Spring%EC%97%90%EC%84%9C-JSON%EC%97%90-XSS-%EB%B0%A9%EC%A7%80-%EC%B2%98%EB%A6%AC-%ED%95%98%EA%B8%B0/

반응형

댓글