Skill/spring

Spring에서 JSON에 XSS 방지 처리 하기

진열사랑 2020. 6. 25. 10:42

출처 : http://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/

고마운 lucy-xss-servlet-filter의 한계

XSS(Cross Site Scripting) 방지를 위해 널리 쓰이는 훌륭한 lucy-xss-servlet-filter는 Servlet Filter 단에서 < 등의 특수 문자를 &lt; 등으로 변환해주며, 여러 가지 관련 설정을 편리하게 지정할 수 있어 정말 좋다.

그런데 그 처리가 form-data에 대해서만 적용되고 Request Raw Body로 넘어가는 JSON에 대해서는 처리해주지 않는다는 단점이 있다. 그래서 JSON을 주고 받는 API 서버의 경우에는 직접 처리를 해줘야 한다.

lucy-xss-servlet-filter를 수정해서 JSON도 처리하도록 만드는 방법도 있겠지만, 여기에서는 Response를 클라이언트로 내보내는 단계에서 처리하는 방법을 알아본다.

HandlerInterceptor

Response 쪽에서 공통적으로 처리해줘야할 일이 있다면 금방 떠오르는 것이 HanderInterceptor의 postHandle()이다. 이 메서드의 파라미터는 HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView이고, response에서 Response Body를 꺼내서, < => &lt; 등의 변환 처리를 하고 다시 response에 넣어주면 될 것 같다.

하지만 response에서 Response Body를 끄집어 내는 것도 쉽지 않고, 그 내용을 바꿔서 다시 집어넣는 것도 여의치 않다. 다른 방법이 필요하다.

MessageConverter

다음으로 생각나는 것은 MessageConverter다. 어차피 결국에는 Jackson 같은 Mapper를 통해 JSON 문자열로 Response에 담겨지므로, Mapper가 JSON 문자열을 생성할 때 XSS 방지 처리를 해주면 될 것 같다.

찾아보니 역시나 http://stackoverflow.com/questions/25403676/initbinder-with-requestbody-escaping-xss-in-spring-3-2-4 이런 자료가 있다. 좀 오래된 버전이고 군더더기도 있어서 Jackson 2.#, SpringBoot 1.# 버전 기준으로 깔끔하게, 그리고 커스터마이징 할 수 있는 부분을 추가해서 정리해봤다.

큰 흐름은 다음과 같다.

  1. 처리할 특수 문자 지정
  2. 특수 문자 인코딩 값 지정
  3. ObjectMapper에 특수 문자 처리 기능 적용
  4. MessageConverter에 ObjectMapper 설정
  5. WebMvcConfigurerAdapter에 MessageConverter 추가

처리할 특수 문자 지정

XSS 방지 처리할 특수 문자를 다음과 같이 CharacterEscapes를 상속한 클래스를 직접 만들어서 지정해준다.

import com.fasterxml.jackson.core.SerializableString;
import com.fasterxml.jackson.core.io.CharacterEscapes;
import com.fasterxml.jackson.core.io.SerializedString;
import org.apache.commons.lang3.text.translate.AggregateTranslator;
import org.apache.commons.lang3.text.translate.CharSequenceTranslator;
import org.apache.commons.lang3.text.translate.EntityArrays;
import org.apache.commons.lang3.text.translate.LookupTranslator;

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 방지 처리 특수 문자 인코딩 값 지정
                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)));
        }
}

 

ObjectMapper에 특수 문자 처리 기능 적용 후 MessageConverter 등록

@Bean
public WebMvcConfigurerAdapter controlTowerWebConfigurerAdapter() {
        return new WebMvcConfigurerAdapter() {

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

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

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

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

                return htmlEscapingConverter;
        }
        };
}

정리

lucy-xss-servlet-filter는 JSON에 대한 XSS는 처리해주지 않는다.

  • 따라서, JSON에 대한 XSS가 필요하다면
  • Jackson의 com.fasterxml.jackson.core.io.CharacterEscapes를 상속하는 클래스 A를 직접 만들어서 처리해야 할 특수문자를 지정하고,
  • ObjectMapper에 A를 설정하고,
  • ObjectMapper를 MessageConverter에 등록해서 Response가 클라이언트에 나가기 전에 XSS 방지 처리 해준다.

 

첨언..
출처에서 다루지 않은 부분은.. 
html에 MessageConverter 처리된 data를 바로 보여줄 때는 위의 방식 그대로 사용하면 되나..
<input>에 value값으로 넣을 때는 다시 <,>,(,)으로 보여주어야 사용자의 의도대로 특수기호를 사용할 수 있다.

 

나의 소스

@Configuration
public class MessageConverterConfiguration extends WebMvcConfigurationSupport {
    /**
     * MappingJackson2HttpMessageConverter 를 커스터마이징 하여 응답 객체 이스케이프 문자 설정
     * @return 커스텀 설정이 적용된 컨버터
     */
	@Bean
	public HttpMessageConverter<?> htmlEscapingConverter() {
		ObjectMapper objectMapper = new ObjectMapper();
		objectMapper.getFactory().setCharacterEscapes(new HTMLCharacterEscapes()); // 
		objectMapper.registerModule(new JavaTimeModule());
		objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
		objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);

		MappingJackson2HttpMessageConverter htmlEscapingConverter =
				new MappingJackson2HttpMessageConverter();
		htmlEscapingConverter.setObjectMapper(objectMapper);

		return htmlEscapingConverter;
	}

	@Override
	protected void configureMessageConverters(List<HttpMessageConverter<?>> converters) {
		converters.add(htmlEscapingConverter());
		super.addDefaultHttpMessageConverters(converters);  // default Http Message Converter  추가
	}
}

 

 

		<!-- XSS방지에서 사용 -->
		<dependency>
		   <groupId>org.apache.commons</groupId>
		   <artifactId>commons-text</artifactId>
		   <version>1.8</version>
		</dependency>

 

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;

import org.apache.commons.text.translate.AggregateTranslator;
import org.apache.commons.text.translate.CharSequenceTranslator;
import org.apache.commons.text.translate.EntityArrays;
import org.apache.commons.text.translate.LookupTranslator;

import com.fasterxml.jackson.core.SerializableString;
import com.fasterxml.jackson.core.io.CharacterEscapes;
import com.fasterxml.jackson.core.io.SerializedString;

public class HTMLCharacterEscapes extends CharacterEscapes {
	
	private static final long serialVersionUID = 4094839928220245452L;

	private final int[] asciiEscapes;

    private final CharSequenceTranslator translator;

    public HTMLCharacterEscapes() {

    	Map<CharSequence, CharSequence> customMap = new HashMap<CharSequence, CharSequence>();
        customMap.put("\'", "&apos;");
        //customMap.put("(", "&#40;");
        //customMap.put(")", "&#41;");
        //customMap.put("#", "&#35;");
        Map<CharSequence, CharSequence> CUSTOM_ESCAPE = Collections.unmodifiableMap(customMap);
        
        // 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 방지 처리 특수 문자 인코딩 값 지정
        translator = new AggregateTranslator(
            new LookupTranslator(EntityArrays.BASIC_ESCAPE),  // <, >, &, " 는 여기에 포함됨
            new LookupTranslator(EntityArrays.ISO8859_1_ESCAPE),
            new LookupTranslator(EntityArrays.HTML40_EXTENDED_ESCAPE),
            // 여기에서 커스터마이징 가능
            new LookupTranslator(CUSTOM_ESCAPE)
        );
    }

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

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