Information Security 16 min read

Handling XSS Vulnerabilities in Spring Boot: Request Wrapper, Filters, and Jackson Custom Serialization

This article documents a step‑by‑step solution for preventing XSS attacks in a Spring Boot application, covering input validation, a custom HttpServletRequestWrapper, filter registration, and Jackson serializers/deserializers to escape malicious HTML both on request parameters and JSON payloads.

Sohu Tech Products
Sohu Tech Products
Sohu Tech Products
Handling XSS Vulnerabilities in Spring Boot: Request Wrapper, Filters, and Jackson Custom Serialization

The purpose of this article is to record how a common XSS vulnerability was solved in a production Spring Boot project. An attacker could inject HTML/JavaScript into a form field, which was stored unchanged and later executed when the data was displayed in a list.

Problem Overview

Example malicious input:

</input><img src=1 onerror=alert1>

The code is saved without error, but when the list query renders the value the onerror handler runs, demonstrating how unrestricted input can lead to data theft or other serious issues.

Solution Ideas

Validate input : reject special characters and tags.

Allow input but replace special characters with an empty string.

Allow input and escape special characters.

The first method requires a complete list of forbidden characters and is unfriendly to users. The second method is too aggressive and may corrupt legitimate data. The third method preserves user data while preventing execution, and it is the chosen approach.

Implementation – Backend Filtering

Because the filtering should be applied globally, a servlet filter and a request wrapper are used.

Request Wrapper

import org.apache.commons.text.StringEscapeUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest;
import javax.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;
/**
 * Re‑wrap the request and override parameter‑retrieving methods to apply XSS escaping.
 */
public class XSSHttpServletRequestWrapper extends HttpServletRequestWrapper {
    private static final Logger logger = LoggerFactory.getLogger(XSSHttpServletRequestWrapper.class);
    private HttpServletRequest request;
    private String reqBody;
    public XSSHttpServletRequestWrapper(HttpServletRequest request) {
        super(request);
        logger.info("---xss XSSHttpServletRequestWrapper created-----");
        this.request = request;
        reqBody = getBodyString();
    }
    @Override
    public String getQueryString() {
        return StringEscapeUtils.escapeHtml4(super.getQueryString());
    }
    @Override
    public String getParameter(String name) {
        logger.info("---xss XSSHttpServletRequestWrapper work  getParameter-----");
        String parameter = request.getParameter(name);
        if (StringUtil.isNotBlank(parameter)) {
            logger.info("----filter before--name:{}--value:{}----", name, parameter);
            parameter = StringEscapeUtils.escapeHtml4(parameter);
            logger.info("----filter after--name:{}--value:{}----", name, parameter);
        }
        return parameter;
    }
    @Override
    public String[] getParameterValues(String name) {
        logger.info("---xss XSSHttpServletRequestWrapper work  getParameterValues-----");
        String[] parameterValues = request.getParameterValues(name);
        if (!CollectionUtil.isEmpty(parameterValues)) {
            for (int i = 0; i < parameterValues.length; i++) {
                parameterValues[i] = StringEscapeUtils.escapeHtml4(parameterValues[i]);
            }
        }
        return parameterValues;
    }
    @Override
    public Map
getParameterMap() {
        logger.info("---xss XSSHttpServletRequestWrapper work  getParameterMap-----");
        Map
map = request.getParameterMap();
        if (map != null && !map.isEmpty()) {
            for (String[] value : map.values()) {
                for (int i = 0; i < value.length; i++) {
                    logger.info("----filter before--value:{}----", value[i]);
                    value[i] = StringEscapeUtils.escapeHtml4(value[i]);
                    logger.info("----filter after--value:{}----", value[i]);
                }
            }
        }
        return map;
    }
    @Override
    public BufferedReader getReader() throws IOException {
        logger.info("---xss XSSHttpServletRequestWrapper work  getReader-----");
        return new BufferedReader(new InputStreamReader(getInputStream()));
    }
    @Override
    public ServletInputStream getInputStream() throws IOException {
        logger.info("---xss XSSHttpServletRequestWrapper work  getInputStream-----");
        final ByteArrayInputStream bais = new ByteArrayInputStream(reqBody.getBytes(StandardCharsets.UTF_8));
        return new ServletInputStream() {
            @Override
            public boolean isFinished() { return false; }
            @Override
            public boolean isReady() { return false; }
            @Override
            public void setReadListener(ReadListener listener) {}
            @Override
            public int read() throws IOException { return bais.read(); }
        };
    }
    private String getBodyString() {
        StringBuilder builder = new StringBuilder();
        InputStream inputStream = null;
        BufferedReader reader = null;
        try {
            inputStream = request.getInputStream();
            reader = new BufferedReader(new InputStreamReader(inputStream));
            String line;
            while ((line = reader.readLine()) != null) {
                builder.append(line);
            }
        } catch (IOException e) {
            logger.error("-----get Body String Error:{}----", e.getMessage(), e);
        } finally {
            if (inputStream != null) {
                try { inputStream.close(); } catch (IOException e) { logger.error("-----get Body String Error:{}----", e.getMessage(), e); }
            }
            if (reader != null) {
                try { reader.close(); } catch (IOException e) { logger.error("-----get Body String Error:{}----", e.getMessage(), e); }
            }
        }
        return builder.toString();
    }
}

Filter Definition

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.servlet.*;
import javax.servlet.http.HttpServletRequest;
import java.io.IOException;
/**
 * Filter that wraps incoming requests with XSSHttpServletRequestWrapper.
 */
public class XssFilter implements Filter {
    private static final Logger logger = LoggerFactory.getLogger(XssFilter.class);
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        logger.info("----xss filter start-----");
    }
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        ServletRequest wrapper = null;
        if (request instanceof HttpServletRequest) {
            HttpServletRequest servletRequest = (HttpServletRequest) request;
            wrapper = new XSSHttpServletRequestWrapper(servletRequest);
        }
        if (wrapper == null) {
            chain.doFilter(request, response);
        } else {
            chain.doFilter(wrapper, response);
        }
    }
}

Filter Registration

Two ways are shown: using @WebFilter together with @ServletComponentScan , or registering the filter as a Spring bean.

@Bean
public FilterRegistrationBean xssFilterRegistrationBean() {
    FilterRegistrationBean filterRegistrationBean = new FilterRegistrationBean();
    filterRegistrationBean.setFilter(new XssFilter());
    filterRegistrationBean.setOrder(1);
    filterRegistrationBean.setDispatcherTypes(DispatcherType.REQUEST);
    filterRegistrationBean.setEnabled(true);
    filterRegistrationBean.addUrlPatterns("/*");
    return filterRegistrationBean;
}

Jackson Custom Deserializer (for @RequestBody)

import com.fasterxml.jackson.core.JsonParser;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.DeserializationContext;
import com.fasterxml.jackson.databind.JsonDeserializer;
import org.apache.commons.text.StringEscapeUtils;
import java.io.IOException;
/**
 * Deserializer that escapes HTML in JSON string values.
 */
public class XssJacksonDeserializer extends JsonDeserializer
{
    @Override
    public String deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        return StringEscapeUtils.escapeHtml4(jp.getText());
    }
}

Jackson Custom Serializer (for response)

import com.fasterxml.jackson.core.JsonGenerator;
import com.fasterxml.jackson.databind.JsonSerializer;
import com.fasterxml.jackson.databind.SerializerProvider;
import org.apache.commons.text.StringEscapeUtils;
import java.io.IOException;
/**
 * Serializer that escapes HTML before writing a string to the response.
 */
public class XssJacksonSerializer extends JsonSerializer
{
    @Override
    public void serialize(String value, JsonGenerator jgen, SerializerProvider provider) throws IOException {
        jgen.writeString(StringEscapeUtils.escapeHtml4(value));
    }
}

Message Converter Configuration

@Override
public void extendMessageConverters(List
> converters) {
    Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
    ObjectMapper mapper = builder.build();
    SimpleModule simpleModule = new SimpleModule();
    simpleModule.addSerializer(String.class, new XssJacksonSerializer());
    simpleModule.addDeserializer(String.class, new XssJacksonDeserializer());
    mapper.registerModule(simpleModule);
    converters.add(new MappingJackson2HttpMessageConverter(mapper));
}

Testing

After configuring the wrapper, filter, and Jackson modules, the author submitted the same malicious input. The database stored the escaped version, and the list view displayed the safe string, while the unescaped entry caused an alert.

Additional Note – Handling JSON Strings in @RequestBody

When a raw JSON string is passed as a field, aggressive escaping corrupts the JSON. The deserializer was updated to detect JSON structures (starting with { , [ and ending with } , ] ) and skip escaping for those values.

public class XssJacksonDeserializer extends JsonDeserializer
{
    @Override
    public String deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
        // If the value looks like JSON, return it unchanged.
        if (isJson(jp.getText())) {
            return jp.getText();
        }
        return StringEscapeUtils.escapeHtml4(jp.getText());
    }
    private boolean isJson(String str) {
        if (StringUtil.isNotBlank(str)) {
            str = str.trim();
            if ((str.startsWith("{") && str.endsWith("}")) || (str.startsWith("[") && str.endsWith("]"))) {
                return true;
            }
        }
        return false;
    }
}

Conclusion

Different scenarios require different handling strategies.

Use an interceptor/filter for traditional request parameters and ensure proper registration.

Use custom Jackson serializer/deserializer for @RequestBody payloads, remembering to configure them correctly.

Spring BootSecurityXSSJacksoninput validationFilterRequestWrapper
Sohu Tech Products
Written by

Sohu Tech Products

A knowledge-sharing platform for Sohu's technology products. As a leading Chinese internet brand with media, video, search, and gaming services and over 700 million users, Sohu continuously drives tech innovation and practice. We’ll share practical insights and tech news here.

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.