본문 바로가기

실전코드/Spring, Java

[SpringBoot3] XSS 방지를 위한 Request, Response 이스케이프 처리(CharacterEscapes, Filter, MappingJackson2HttpMessageConverter 사용)

728x90
반응형

개요

XSS를 방지하기 위한 여러 가지 방법 중 Http Request 및 Response의 Body에 포함된 특수문자를 escape 하는 방법이 있다.

예전에는 Requst의 escape를 위해 네이버에서 만든 오픈소스인 lucy를 많이 사용했지만, jackson에서 지원해주는 CharacterEscapes와 별도로 사용해야 하고, 마지막 커밋이 2년 전인걸로 보아 개발도 사실상 중단된 상태이다.

 

그리고 MappingJackson2HttpMessageConverter를 사용하는 여러 글이 있지만, 이는 Response에는 적용되지만 Request에는 적용되지 않기 때문에 Request에 적용할 수 있는 추가 개발이 필요한 상황이다.

 

이 때 escape 처리는 정책에 따라 커스터마이징 될 수 있어야 하기 때문에, 일괄적으로 관리할 수 있는 형태를 띄어야 하므로 만들어 놓은 CharacterEscapes를 재사용 할 수 있는 방안을 구상하였다.

 

결과적으로 SpringBoot3에서 사용할 수 있는 최신 버전의 라이브러리를 사용해서, CharacterEscapes를 사용하여 일괄적인 escape 처리를 위해 Request는 Filter를 통해, Response는 MappingJackson2HttpMessageConverter를 통해 escape하는 방법을 알아보고자 한다.

소스코드

build.gradle

// apache commons text
implementation 'org.apache.commons:commons-text:1.12.0'
// jakarta servlet
compileOnly 'jakarta.servlet:jakarta.servlet-api:6.1.0'
728x90

HTMLCharacterEscapes.java

가장 먼저 ObjectMapper로 받은 Body 데이터의 escape를 위해 CharacterEscapes를 extends한 클래스를 만들어 준다.

해당 클래스를 커스텀 하는 방법도 있지만 이 포스팅에서는 가장 기본적인 형태의 escape만 처리하는 형태로 구성하였다.

import com.fasterxml.jackson.core.SerializableString;
import com.fasterxml.jackson.core.io.CharacterEscapes;
import com.fasterxml.jackson.core.io.SerializedString;
import org.apache.commons.text.StringEscapeUtils;

public class HTMLCharacterEscapes extends CharacterEscapes {
    private final int[] asciiEscapes;
    public HTMLCharacterEscapes() {
        // escape 대상 지정
        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;
    }

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

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

EscapingFilter.java

다음으로 Http Response를 컨트롤할 수 있는 filter를 생성해준다.

Http 요청에서 Request와 Response를 꺼내 조작할 준비를 하는 단계이다.

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.http.HttpServletRequest;
import lombok.AllArgsConstructor;

import java.io.IOException;

@AllArgsConstructor
public class EscapingFilter implements Filter {

    private final ObjectMapper objectMapper;

    @Override
    public void init(FilterConfig filterConfig) {
    }

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String contentType = httpRequest.getContentType();
        boolean isMultipart = contentType != null && contentType.startsWith("multipart/");

        if (request instanceof HttpServletRequest && !isMultipart) {
            HttpServletRequest wrappedRequest = new EscapingHttpServletRequestWrapper((HttpServletRequest) request, objectMapper);
            chain.doFilter(wrappedRequest, response);
        } else {
            chain.doFilter(request, response);
        }
    }

    @Override
    public void destroy() {
    }
}

EscapingHttpServletRequestWrapper.java

위의 filter에서 사용할 HttpServletRequestWrapper를 생성해준다.

이 Wrapper 클래스에서 Request의 조작이 이뤄진다.

import com.fasterxml.jackson.databind.ObjectMapper;

import jakarta.servlet.ReadListener;
import jakarta.servlet.ServletInputStream;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletRequestWrapper;
import java.io.BufferedReader;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStreamReader;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.stream.Collectors;

public class EscapingHttpServletRequestWrapper extends HttpServletRequestWrapper {

    private final String escapedBody;

    public EscapingHttpServletRequestWrapper(HttpServletRequest request, ObjectMapper objectMapper) throws IOException {
        super(request);
        String body = request.getReader().lines().collect(Collectors.joining(System.lineSeparator()));

        if (body.startsWith("[") && body.endsWith("]")) {
            this.escapedBody = objectMapper.writeValueAsString(objectMapper.readValue(body, List.class));
        } else if (body.startsWith("{") && body.endsWith("}")) {
            this.escapedBody = objectMapper.writeValueAsString(objectMapper.readValue(body, Map.class));
        } else {
            this.escapedBody = "{}";
        }
    }

    @Override
    public ServletInputStream getInputStream() {
        ByteArrayInputStream byteArrayInputStream = new ByteArrayInputStream(escapedBody.getBytes(StandardCharsets.UTF_8));
        return new ServletInputStream() {
            @Override
            public int read() {
                return byteArrayInputStream.read();
            }

            @Override
            public boolean isFinished() {
                return byteArrayInputStream.available() == 0;
            }

            @Override
            public boolean isReady() {
                return true;
            }

            @Override
            public void setReadListener(ReadListener readListener) {
                // Do nothing
            }
        };
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(getInputStream(), StandardCharsets.UTF_8));
    }
}

WebConfig.java

위에서 제작한 CharacterEscapes를 ObjectMapper에 적용하고 이를 Filter와 MappingJackson2HttpMessageConverter에 적용하기 위한 설정 클래스이다.

CharacterEscapes로 escape를 진행한 ObjectMapper를 반환하는 메서드를 만들어 @Bean으로 등록해주고,

Filter와 MappingJackson2HttpMessageConverter에 해당하는 클래스의 생성자에 넘겨주어 @Bean에 등록한 ObjectMapper를 사용하는 방식이다.

import java.util.List;

import org.springframework.boot.web.servlet.FilterRegistrationBean;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.converter.HttpMessageConverter;
import org.springframework.http.converter.json.MappingJackson2HttpMessageConverter;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import org.springframework.lang.NonNull;

import com.fasterxml.jackson.databind.ObjectMapper;
import com.surplusglobal.smbo.core.xss.EscapingFilter;
import com.surplusglobal.smbo.core.xss.HTMLCharacterEscapes;

import lombok.RequiredArgsConstructor;

@Configuration
@RequiredArgsConstructor
public class WebConfig implements WebMvcConfigurer {
    @Bean
    public ObjectMapper htmlEscapingObjectMapper() {
        ObjectMapper objectMapper = new ObjectMapper();
        objectMapper.getFactory().setCharacterEscapes(new HTMLCharacterEscapes());
        return objectMapper;
    }

    @Override
    public void extendMessageConverters(@NonNull List<HttpMessageConverter<?>> converters) {
        MappingJackson2HttpMessageConverter customConverter = new MappingJackson2HttpMessageConverter();
        customConverter.setObjectMapper(htmlEscapingObjectMapper());
        customConverter.setSupportedMediaTypes(List.of(
                org.springframework.http.MediaType.APPLICATION_JSON,
                org.springframework.http.MediaType.valueOf("text/html;charset=UTF-8")
        ));
        converters.add(customConverter);
    }
    // 또는
    @Bean
    public MappingJackson2HttpMessageConverter jsonEscapeConverter(){
        ObjectMapper copy = objectMapper.copy();
        copy.getFactory().setCharacterEscapes(new HTMLCharacterEscapes());
        return new MappingJackson2HttpMessageConverter(copy);
    }

    @Bean
    public FilterRegistrationBean<EscapingFilter> escapingFilter() {
        FilterRegistrationBean<EscapingFilter> filterRegistrationBean = new FilterRegistrationBean<>();
        filterRegistrationBean.setFilter(new EscapingFilter(htmlEscapingObjectMapper()));
        filterRegistrationBean.addUrlPatterns("/*");
        filterRegistrationBean.setOrder(1);
        return filterRegistrationBean;
    }
}

결과

request, response에서 각각 escape되다 보니 <>를 DB에 저장하기 위해 Request를 보내면 &lt;&rt;로 저장이 되고 이를 조회해 response객체에 담으면 &가 또 escape 되어 &amplt;&amprt;의 형태로 전송하게 됩니다.

이를 위해 front-end에선 이중 unescape를 처리해줘야 하는데 어느 시점에 escape를 하고 2중으로 할지 등은 보안 정책에 따라 선택하면 될 사항인 듯 합니다.

728x90
반응형