Spring에서 JSON에 XSS 방지 처리 하기
고마운 lucy-xss-servlet-filter의 한계
XSS(Cross Site Scripting) 방지를 위해 널리 쓰이는 훌륭한 lucy-xss-servlet-filter는 Servlet Filter 단에서 < 등의 특수 문자를 < 등으로 변환해주며, 여러 가지 관련 설정을 편리하게 지정할 수 있어 정말 좋다.
그런데 그 처리가 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를 꺼내서, < => < 등의 변환 처리를 하고 다시 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.# 버전 기준으로 깔끔하게, 그리고 커스터마이징 할 수 있는 부분을 추가해서 정리해봤다.
큰 흐름은 다음과 같다.
- 처리할 특수 문자 지정
- 특수 문자 인코딩 값 지정
- ObjectMapper에 특수 문자 처리 기능 적용
- MessageConverter에 ObjectMapper 설정
- 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[][]{
{"(", "("},
{")", ")"},
{"#", "#"},
{"\'", "'"}
}
)
);
}
@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("\'", "'");
//customMap.put("(", "(");
//customMap.put(")", ")");
//customMap.put("#", "#");
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)));
}
}