Spring

Spring XSS Filter 개발 및 적용

강푸른임 2021. 10. 19. 15:02
728x90

개발자들이 항상 고민하는 XSS를 막기위한 XSS Filter를 간단하게 개발하고 적용해봤다.

처음엔 XSS 필터링을 인터셉터에서 리퀘스트에서 데이터 뽑아서 replace 하고 다시 넣어주면 되지않나? 라고 생각했다.

인터셉터에서 한다는게 안일한 생각이지만, 전부 틀린말은 아니다. 

 

1. 요청에서 데이터를 가져온다.

2. XSS를 escape한 문자로 replace해서 다시 넣어준다.

 

1번 경우 inputStream을 사용해야하는데 이때 already called 에러를 뱉기 때문에 HttpServletWrapper를 따로 정의해서 새 스트림을 리턴하는 형식으로 해야한다.

 

2번 경우 인터셉터에서 단순 setparameter같은 메서드가 없기 때문에 HttpServletWrapper를 따로 정의해서 데이터를 리턴하는 함수를 오버라이딩 해서 escape처리된 데이터를 리턴시켜주면 된다.

 

정리하면 HttpServletWrapper을 상속받은 XSSHttpServletWrapper를 개발하고 XSS필터에 넣어서 web.xml에 적용시켜주면 된다.

 

 

목표

많이 알려진 lucy필터는 한계가있다. multipart/form 과 json같은 request body 데이터는 필터링이 안된다. 

때문에 아래 3개의 기능 필터를 개발할 것이다.

 

1. lucy처럼 일반 폼데이터 필터링

2. json데이터 필터링 

3. multipart/form데이터 필터링

 

개발

먼저 XSS필터와 필터에서 사용할 HttpServletWrapper 클래스를 만든다.

 

 

XSSFilter

public class XSSFilter implements Filter{
	private FilterConfig filterConfig;
	
	@Override
	public void init(FilterConfig filterConfig) throws ServletException {
		this.filterConfig = filterConfig;
	}

	@Override
	public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
			throws IOException, ServletException {
		HttpServletRequestWrapper requestWrapper = new XSSFilterWrapper((HttpServletRequest)request);
		chain.doFilter(requestWrapper, response);
	}

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

}

web.xml에 정의할 필터다. Filter를 Implements 한다는 걸 알아야한다.

필터링 로직을 정의하는 doFilter메서드에서 request객체를 통해 url이나 method에 대해서 조건문을 걸어서 적용시킬 수 있다.

 

doFilter에서 XSSFilterWrapper를 new로 생성하고있다.

XSSFilterWrapper

public class XSSFilterWrapper extends HttpServletRequestWrapper{

	private byte[] rawData;
	
	public XSSFilterWrapper(HttpServletRequest request) {
		super(request);
		try {
			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();
		}
		
	}

	
	//XSS replace
	private byte[] replaceXSS(byte[] data) {
		String strData = new String(data);
		strData = strData.replaceAll("\\<", "&lt;").replaceAll("\\>", "&gt;").replaceAll("\\(", "&#40;").replaceAll("\\)", "&#41;");
		
		return strData.getBytes();
	}
	
	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(), "UTF_8"));
	}

	
	
}

 

소스코드가 조금 길어서 눈에 안들어올 수 있지만 1, 2, 3번 목표 로직이 구현되어있는 상태기 때문에 나눠서 보면 이해가 편하다.

 

replaceXSS 메서드

request body용 byte[]를 받는 메서드와 그 외용으로 String을 받는 메서드 두개 오버로드 해두면 든든하게 쓸수있다.

안에 replace하는 로직은 간단하게 <, >, (, )만 적용하게 구현했으니 개인이 개발한 로직이 있다면 알아서 잘 적용하면된다.

 

1번 목표, 일반 폼 데이터 필터링

폼 데이터라고 칭하는거는 사실 맞지 않지만 getParameter를 하거나 폼데이터에서 데이터를 가져오거나 할때는

getQueryString과 getParameter 메서드를 쓰기 때문에 해당하는 메서드들을 오버라이딩 해야한다.

 

@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;
	}

getQueryString과 getParameter 메서드는 리턴할때 원래 넘어가는 super객체의 값에 구현해둔 replaceXSS를 씌워서 넘겨주기만 하면된다.

 

getParameterValues와 getParameterMap 메서드도 배열과 맵을 리턴하기 때문에 안에 값을 돌면서 replaceXSS 처리를 해주면 간단하다.

 

 

2번 목표, json데이터 필터링

json의 경우 Content-type이 application/json, 그리고 request의 body에 데이터가 raw로 날라오기 때문에 그 데이터를 필터에서 먼저 읽어서 replaceXSS 처리를 해주고, 그 후에 응답을 보내기 위해 InputStream을 호출하기 때문에 getInputStream을 오버라이딩해서 처리된 rawData를 스트림에 넣어준다고 생각하면 된다.

 

private byte[] rawData;
	
	public XSSFilterWrapper(HttpServletRequest request) {
		super(request);
		try {
			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();
		}
		
	}

 json, multipart인 경우에 request body를 쓰기 때문에 생성자에서 if문으로 조건을 추가해주었다.

읽어온 request body 데이터는 replacXSS 처리해서 필드변수로 선언한 rawData에 정의해준다.

 

정의 된 rawData는 아래 메서드에서 쓰인다.

@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 BufferedReader getReader() throws IOException {
		return new BufferedReader(new InputStreamReader(this.getInputStream(), "UTF_8"));
	}

원래는 super.getInputStream()을 리턴했지만, replaceXSS 처리된 rawData를 새로운 InputStream에 담고 그걸 리턴해주고 있다. 

 

getReader에도 새로운 버퍼 리더객체를 리턴해주어야 기존 스트림이나 버퍼를 쓰지 않기 때문에 already called 되었다는 에러를 피할 수 있다.

 

 

3번 목표, Multipart 처리

3번 목표의 경우에는 2번에서 이미 Multipart인 경우에도 request body 데이터를 replaceXSS 처리했기 때문에 로직에 추가되는 내용은 없다. 하지만 실행해보면 replaceXSS 처리가 되지않은 데이터를 받게된다.

 

그 이유는, multipart/form-data는 데이터를 가져올때 일반 폼데이터 처럼 getParameter가 아닌 getPartgetParts 메서드로 데이터를 가져오기때문에, 해당 데이터를 오버라이딩해서 재정의 해야 마찬가지로 필터링이 가능하다.

 

하지만 난 Part에 대해서 잘 모르기 때문에 web.xml에 설정을 추가하는것으로 방법을 찾았다.

 

web.xml

 

위에서 구현한 XSSFilter는 web.xml에 필터로 정의 해주어야 작동한다. 하지만 multipart 처리를 위해 

XSSFilter 위에 multipartFilter를 꼭 정의해주어야 한다. (필터는 선언순으로 적용되기 때문에)

 

init-param의 multpartResolverBeanName의 value값은 context에 정의한 multipartResolver 빈 id를 넣으면된다.

나의 경우 안넣어도 작동은 되었지만 아래 스프링 문서를 참고하면 조금은 의의에 대해서 알 수 있다.

https://docs.spring.io/spring-framework/docs/current/javadoc-api/org/springframework/web/multipart/support/MultipartFilter.html

 

MultipartFilter (Spring Framework 5.3.11 API)

Check for a multipart request via this filter's MultipartResolver, and wrap the original request with a MultipartHttpServletRequest if appropriate. All later elements in the filter chain, most importantly servlets, benefit from proper parameter extraction

docs.spring.io

내가 이해한 대로 간단히 풀어보면 MultipartResolver는 HttpServletRequest를 MultipartHttpServletRequest로 파싱해서 업로드된 파일을 처리할 수 있게 하는 스프링의 라이프 사이클을 검색하면 알 수 있겠지만, 필터의 경우는 dispatcher-servlet보다 먼저 처리되기 때문에(요청이 들어오면 가장 먼저 필터부터 거침) MultipartResolver가 우리가 선언한 XSSFilter에서 처리 할수 없다. 따라서 필터에서도 MultipartResolver가 필요하기 때문에 MultipartFilter를 먼저 선언해주어서 필터에도 리졸버를 넣어준다고 생각하면 좀 이해가 쉬운 것같다.

 

또, 문서를 보면 추가 설정을 해야하는 부분이 있다.

그림의 빨간부분 처럼 allowCasualMultipartParsing="true"를 추가해야한다.

안하면 Multipart에 대한 설정이 없다는 에러가 나온다.

 

 

이 외에도 놓친 요청 형태가 있겠지만, 일단 이 정도로 만족하자.

728x90