Implementing XSS Prevention in Spring Boot with Request Wrapper, Filter, and Custom Jackson Serialization
This article explains how to protect a Spring Boot application from XSS attacks by validating input, wrapping HttpServletRequest, creating a servlet filter, and customizing Jackson serialization/deserialization to safely handle both form parameters and @RequestBody JSON payloads.
What is XSS?
Cross‑site scripting (XSS) occurs when a user submits malicious HTML/JavaScript in a form field, such as </input><img src=1 onerror=alert1> , which is stored unchanged and later executed when the data is rendered.
Solution Ideas
Three common approaches are:
Validate input and reject special characters/tags.
Replace special characters with an empty string before saving.
Escape special characters so the browser treats them as plain text.
The third method is preferred because it preserves user data while preventing execution.
Implementation Overview
The protection is applied on the backend using a request wrapper, a servlet filter, and custom Jackson (de)serializers.
Request Wrapper
/**
* Re‑wrap the request and override parameter‑retrieving methods to filter each value.
*/
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();
try (InputStream inputStream = request.getInputStream();
BufferedReader 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);
}
return builder.toString();
}
}Servlet Filter
/**
* Filter that intercepts requests and wraps them 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);
}
}
}Jackson Custom (De)Serializers
/** Deserializer for String values – escapes HTML unless the value is a JSON string. */
public class XssJacksonDeserializer extends JsonDeserializer
{
@Override
public String deserialize(JsonParser jp, DeserializationContext ctxt) throws IOException, JsonProcessingException {
String text = jp.getText();
if (isJson(text)) {
return text;
}
return StringEscapeUtils.escapeHtml4(text);
}
private boolean isJson(String str) {
if (StringUtil.isNotBlank(str)) {
str = str.trim();
return (str.startsWith("{") && str.endsWith("}")) || (str.startsWith("[") && str.endsWith("]"));
}
return false;
}
}
/** Serializer for String values – always escapes HTML. */
public class XssJacksonSerializer extends JsonSerializer
{
@Override
public void serialize(String value, JsonGenerator gen, SerializerProvider provider) throws IOException {
gen.writeString(StringEscapeUtils.escapeHtml4(value));
}
}Registration and Configuration
// Register the filter via Bean
@Bean
public FilterRegistrationBean<XssFilter> xssFilterRegistrationBean() {
FilterRegistrationBean<XssFilter> bean = new FilterRegistrationBean<>();
bean.setFilter(new XssFilter());
bean.setOrder(1);
bean.setDispatcherTypes(DispatcherType.REQUEST);
bean.setEnabled(true);
bean.addUrlPatterns("/*");
return bean;
}
// Extend message converters to use custom (de)serializers
@Override
public void extendMessageConverters(List<HttpMessageConverter<?>> converters) {
Jackson2ObjectMapperBuilder builder = new Jackson2ObjectMapperBuilder();
ObjectMapper mapper = builder.build();
SimpleModule module = new SimpleModule();
module.addSerializer(String.class, new XssJacksonSerializer());
module.addDeserializer(String.class, new XssJacksonDeserializer());
mapper.registerModule(module);
converters.add(new MappingJackson2HttpMessageConverter(mapper));
}Testing
After configuring the wrapper, filter, and serializers, the author submitted the XSS payload </input><img src=1 onerror=alert1> through a form. The stored value in the database was escaped, and the list view displayed the escaped string safely, while a non‑escaped entry still triggered the alert.
Summary
Handle XSS differently for query parameters, form data, and @RequestBody JSON.
Use a servlet filter with a request wrapper to escape parameters early.
Apply custom Jackson (de)serializers to process JSON bodies and responses.
Additional Note
The deserializer was later refined to detect JSON strings (starting with { , [ and ending with } , ] ) and skip escaping, preventing double‑escaping of legitimate JSON payloads.
Code Ape Tech Column
Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.