Message Converter에 관한 문제상황 및 해결 방법에 대해 개략적인 내용을 정리한다.
프로젝트에 보안을 강화하기 위해 naver에서 제공하는 lucy-xss를 적용하였으나, lucy-xss filter는 form-data 형태의 데이터를 치환하는 데 그 목적이 있다 보니 json 형태의 데이터가 정상적으로 처리되지 않는 문제가 발생하였다.
구글을 여기저기 뒤져보니 Objectmapper를 커스텀한 후 Message Converter 객체를 새로 생성하여 매핑시켜주어 별도의 라이브러리 없이 특수문자 치환을 적용할 수 있는 방법이 있었는데, 이 방식은 Request Body에 대한 치환은 가능하지만 Response에 대한 처리는 할 수 없다는 문제가 있었다.
https://jojoldu.tistory.com/470
Request 데이터는 치환하고, Response가 나갈 때에는 다시 변환할 수는 없을까? 라는 궁금증을 해소하기 위해 여러 가지 사례들을 찾아보고 고민한 결과, 구글에 공유된 자료들은 대부분 다음과 같은 형태로 개발되어 있음을 알 수 있었다.
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;
//사용자 정의
Map<CharSequence, CharSequence> customMap = new HashMap<>();
customMap.put("(", "(");
// 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)));
// return new SerializedString(StringEscapeUtils.escapeHtml4(Character.toString((char) ch)));
}
}
요청과 응답 모두에 원하는 동작을 수행할 수 있도록 하려면 MessageConverter 방식이 아닌, Spring Filter를 사용해야 된다는 결론을 내리게 되었다. 위의 클래스처럼 Escape, Unescape 처리를 할 수 있는 클래스 파일을 만든 후 Request/Response Filter Wrapper 내부에서 DI한 후 메서드를 호출하면 되는 것이다.
열심히 라이브러리를 분석해보니 BASIC_ESCAPE, ISO8859_1_ESCAPE, HTML40_EXTENDED_ESCAPE 이 녀석들은 지정된 특수 문자를 담아두는 Map인데, 문자를 특수기호로 치환하는 Map 외에도 EntityArrays 내부에서 Invert 메서드를 제공하여 역치환이 가능하도록 기능을 제공하고 있었다.
org.apache.commons.text.translate
아래는 위 라이브러리의 EntityArrays.class 에서 제공하는 소스의 일부이다.
...
initialMap.put("\u00F8", "ø"); // ø - lowercase o, slash
initialMap.put("\u00F9", "ù"); // ù - lowercase u, grave accent
initialMap.put("\u00FA", "ú"); // ú - lowercase u, acute accent
initialMap.put("\u00FB", "û"); // û - lowercase u, circumflex accent
initialMap.put("\u00FC", "ü"); // ü - lowercase u, umlaut
initialMap.put("\u00FD", "ý"); // ý - lowercase y, acute accent
initialMap.put("\u00FE", "þ"); // þ - lowercase thorn, Icelandic
initialMap.put("\u00FF", "ÿ"); // ÿ - lowercase y, umlaut
ISO8859_1_ESCAPE = Collections.unmodifiableMap(initialMap);
라이브러리에서 지정한 문자들이 Map의 value 값으로 치환되도록 정의가 되어 있는 모습이다. 그리고 역 치환은 위에서 언급한 것처럼 Invert() 메서드를 통해 지원한다.
public static final Map<CharSequence, CharSequence> ISO8859_1_UNESCAPE;
static {
ISO8859_1_UNESCAPE = Collections.unmodifiableMap(invert(ISO8859_1_ESCAPE));
}
...
public static Map<CharSequence, CharSequence> invert(final Map<CharSequence, CharSequence> map) {
return map.entrySet().stream().collect(Collectors.toMap(Entry::getValue, Entry::getKey));
}
Map의 key, value 위치를 바꾸어 UNESCAPE Map 객체를 새롭게 생성한다. EntityArrays.class 에서는 다양한 형태의 Map을 지원하고 있고, 필요하다면 위의 Message Converter를 등록하는 예제처럼 커스텀을 통해 치환하고자 하는 문자를 지정하는 것도 가능하다. 역치환의 경우에는 HTMLCharacterEscapes 클래스 내부에서 translator를 두 가지로 나누어 request와 response를 각각 처리할 수 있도록 구현하면 요청과 응답 모두에 치환과 역치환을 적용할 수 있을 것이다.
분명 @RequestBody에 관한 글인데, Xss Prevent와 관련된 내용만 장황하게 서술한 것 같다. 문제 상황을 이해하기 위해서는 길게 늘여쓴 위 지식이 바탕이 되어야 한다. 본론으로 돌아가서, 무엇이 문제가 되었는가?
- @RequestBody 어노테이션을 String 타입의 객체로 받는 경우, Spring Filter를 거쳐 Controller에서 받을 때 문자열의 일부가 누락되는 현상이 발생
문제의 원인을 파악하기 위해 처음부터 차근차근 살펴보기로 했다. 먼저 동작 원리를 파악한 후 소스 레벨에서 디버깅을 진행하였다.
@RequestBody는 기본적으로 Spring의 ArgumentResolver에 의해 처리된다. 컨트롤러에 객체를 전달하기 위해 Argument Resolver가 여러 가지 형태의 MessageConverter중 타겟 메서드에 적합한 MessageConverter를 선정하여 객체를 만들어 주는 것이다.
https://mangkyu.tistory.com/250
이를 실행 흐름에 따라 정리하면 다음과 같다.
- Servlet-Container Layer의 Spring Filter가 특정 문자에 대해 치환을 진행한다. (Client의 원본 요청 Body와 치환된 Body가 달라짐)
- FrontController인 DispatcherServlet이 Http 요청을 받는다.
- Controller로 전달되기 전, ArgumentResolver가 적합한 MessageConverter를 선정하여 Controller에 전달할 객체를 생성한다.
문제 상황은 Controller에서 Model 객체로 데이터를 받을 때에는 문제가 되지 않지만, String 타입의 객체로 받을 때에는 문자열의 일부가 누락되는 것이다. 타입이 다른 경우에 증상이 나타나므로 흐름도의 3번 과정을 중점으로 하여 디버깅을 진행하기로 하였다. 예상하기로는, 컨트롤러의 메서드에 정의된 타입에 따라 상이한 MessageConverter가 적용되면서 문제가 발생한 것으로 보인다.
Spring에서는 @RequestBody의 객체 타입에 따라 다음과 같은 MessageConverter가 사용된다.
ByteArrayHttpMessageConverter : byte
StringHttpMessageConverter : String
MappingJackson2HttpMessageConverter : application/json
...
https://hyos-dev-log.tistory.com/18
Spring의 @RequestBody, @RequestParam 등은 디스패처 서블릿을 거쳐 ArgumentResolver에서 처리되는데, 디버깅을 하며 흐름을 따라가보니 RequestResponseBodyMethodProcessor에서 Body Arguments를 변환해주고 있었다.
// RequestResponseBodyMethodProcessor.java
@Override
@Nullable
protected <T> Object readWithMessageConverters(NativeWebRequest webRequest, MethodParameter parameter,
Type paramType) throws IOException, HttpMediaTypeNotSupportedException, HttpMessageNotReadableException {
ServletServerHttpRequest inputMessage = createInputMessage(webRequest);
Object arg = readWithMessageConverters(inputMessage, parameter, paramType);
if (arg == null && checkRequired(parameter)) {
throw new HttpMessageNotReadableException("Required request body is missing: " +
parameter.getExecutable().toGenericString(), inputMessage);
}
return arg;
}
readWithMessageConverters 메서드를 따라가보면 AbstractMessageConverterMethodArgumentResolver 클래스의 메서드로 이동하게 되고
// AbstractMessageConverterMethodArgumentResolver.java
...
message = new EmptyBodyCheckingHttpInputMessage(inputMessage);
for (HttpMessageConverter<?> converter : this.messageConverters) {
Class<HttpMessageConverter<?>> converterType = (Class<HttpMessageConverter<?>>) converter.getClass();
GenericHttpMessageConverter<?> genericConverter =
(converter instanceof GenericHttpMessageConverter ghmc ? ghmc : null);
if (genericConverter != null ? genericConverter.canRead(targetType, contextClass, contentType) :
(targetClass != null && converter.canRead(targetClass, contentType))) {
if (message.hasBody()) {
HttpInputMessage msgToUse =
getAdvice().beforeBodyRead(message, parameter, targetType, converterType);
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
body = getAdvice().afterBodyRead(body, msgToUse, parameter, targetType, converterType);
}
else {
body = getAdvice().handleEmptyBody(null, message, parameter, targetType, converterType);
}
break;
}
}
readWithMessageConverters() 메서드에서는 메세지 컨버터 리스트의 반복을 돌며 적합한 메세지 컨버터를 선정한 후 Body에 대해 역직렬화를 처리해준다.
body = (genericConverter != null ? genericConverter.read(targetType, contextClass, msgToUse) :
((HttpMessageConverter<T>) converter).read(targetClass, msgToUse));
read메서드를 따라가보면 AbstractHttpMessageConverter 클래스의 메서드를 호출하고 있으며
// AbstractHttpMessageConverter.java
...
@Override
public final T read(Class<? extends T> clazz, HttpInputMessage inputMessage)
throws IOException, HttpMessageNotReadableException {
return readInternal(clazz, inputMessage);
}
readInternal() 메서드를 따라가면 비로소 스프링에서 제공하는 다양한 종류의 메세지 컨버터의 구현체를 마주하게 된다. 이제, 메세지 컨버터 내부에서 역직렬화를 어떤 방식으로 처리하는지 살펴보도록 하자.
먼저 컨트롤러에서 Model로 받는 경우 MappingJackson2HttpMessageConverter가 적용된다.
try {
InputStream inputStream = StreamUtils.nonClosing(inputMessage.getBody());
if (inputMessage instanceof MappingJacksonInputMessage mappingJacksonInputMessage) {
Class<?> deserializationView = mappingJacksonInputMessage.getDeserializationView();
if (deserializationView != null) {
ObjectReader objectReader = objectMapper.readerWithView(deserializationView).forType(javaType);
objectReader = customizeReader(objectReader, javaType);
if (isUnicode) {
return objectReader.readValue(inputStream);
}
else {
Reader reader = new InputStreamReader(inputStream, charset);
return objectReader.readValue(reader);
}
}
}
ObjectReader objectReader = objectMapper.reader().forType(javaType);
objectReader = customizeReader(objectReader, javaType);
if (isUnicode) {
return objectReader.readValue(inputStream);
}
else {
Reader reader = new InputStreamReader(inputStream, charset);
return objectReader.readValue(reader);
}
}
개발자가 직접 역직렬화를 처리할 때 Objectmapper를 사용하여 readValue() 처리를 해주듯이 request Input Message를 처리해주고 있다.
그렇다면, 문제가 됐던 String Type의 데이터는 어떻게 처리될까?
컨트롤러에서 String 타입의 객체로 받는 경우 StringHttpMessageConverter가 적용된다.
@Override
protected String readInternal(Class<? extends String> clazz, HttpInputMessage inputMessage) throws IOException {
Charset charset = getContentTypeCharset(inputMessage.getHeaders().getContentType());
long length = inputMessage.getHeaders().getContentLength();
byte[] bytes = (length >= 0 && length <= Integer.MAX_VALUE ?
inputMessage.getBody().readNBytes((int) length) : inputMessage.getBody().readAllBytes());
return new String(bytes, charset);
}
소스를 보니 이유를 알 수 있었다.
inputMessage.getHeaders().getContentLength();
위와 같이 요청 헤더의 Content-Length를 보고 있었던 것이다. Length는 최초에 요청이 들어올 때 body의 길이만큼 산정된 후 변하지 않았고, Filter에서 특정 데이터가 치환이 되면서 Body의 길이가 늘어나게 되었다. 이로 인해 Content-Length와 실제 Body값의 크기 사이에 불일치가 발생하여 Request Body의 일부분이 누락된 것이다.
이를 해소하기 위해 두 가지 방안을 고민하게 되었다.
- Filter의 RequestWrapper 내에서 Request Body를 커스텀하여 사용하듯이, 헤더 정보를 모두 불러온 후 Content-Length를 치환된 Body의 바이트 길이로 변환하여 재적용
- 컨트롤러에서 @RequestBody로 데이터를 받을 때에는 Model을 활용
1번 방식은 처리를 위한 추가 개발이 필요하고, 필터에 부가 기능이 더해져 서비스가 무거워질 우려도 있기 때문에, 웬만하면 2번 방식을 채택하여 Model로 처리하는 것이 바람직해 보인다.
'development > spring' 카테고리의 다른 글
Trouble Shooting - Spring Quartz Clustering (5) | 2024.09.12 |
---|---|
@Transactional 전파속성 주의사항 (0) | 2024.09.10 |
JPA 참고사항 정리 (0) | 2023.09.15 |
JPA 정리본 (0) | 2023.09.15 |
테스트 주도 개발 - TDD란? (0) | 2023.08.01 |