fei0x
fei0x

Reputation: 4728

Can no longer obtain form data from HttpServletRequest SpringBoot 2.2, Jersey 2.29

We have a SpringBoot application and are using Jersey to audit incoming HTTP requests.

We implemented a Jersey ContainerRequestFilter to retrieve the incoming HttpServletRequest and use the HttpServletRequest's getParameterMap() method to extract both query and form data and place it in our audit.

This aligns with the javadoc for the getParameterMap():

"Request parameters are extra information sent with the request. For HTTP servlets, parameters are contained in the query string or posted form data."

And here is the documentation pertaining to the filter:

https://eclipse-ee4j.github.io/jersey.github.io/documentation/latest/user-guide.html#filters-and-interceptors

Upon updating SpringBoot, we found that the getParameterMap() no longer returned form data, but still returned query data.

We found that SpringBoot 2.1 is the last version to support our code. In SpringBoot 2.2 the version of Jersey was updated 2.29, but upon reviewing the release notes we don't see anything related to this.

What changed? What would we need to change to support SpringBoot 2.2 / Jersey 2.29?

Here is a simplified version of our code:

JerseyRequestFilter - our filter

import javax.annotation.Priority;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.Priorities;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ContainerRequestFilter;
import javax.ws.rs.container.ResourceInfo;
import javax.ws.rs.core.Context;
import javax.ws.rs.ext.Provider;
...

@Provider
@Priority(Priorities.AUTHORIZATION)
public class JerseyRequestFilter implements ContainerRequestFilter {

    @Context
    private ResourceInfo resourceInfo;

    @Context
    private HttpServletRequest httpRequest;
    ...
    
    public void filter(ContainerRequestContext context) throws IOException {
        ...
        requestData =  new RequestInterceptorModel(context, httpRequest, resourceInfo);
        ...
    }   
    ...
}   

RequestInterceptorModel - the map is not populating with form data, only query data

import lombok.Data;
import org.glassfish.jersey.server.ContainerRequest;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.container.ContainerRequestContext;
import javax.ws.rs.container.ResourceInfo;
...

@Data
public class RequestInterceptorModel {

    private Map<String, String[]> parameterMap;
    ...
    
    public RequestInterceptorModel(ContainerRequestContext context, HttpServletRequest httpRequest, ResourceInfo resourceInfo) throws AuthorizationException, IOException {
        ...
        setParameterMap(httpRequest.getParameterMap());
        ...
    }
    ...     
}

JerseyConfig - our config

import com.xyz.service.APIService;
import io.swagger.jaxrs.config.BeanConfig;
import io.swagger.jaxrs.listing.ApiListingResource;
import io.swagger.jaxrs.listing.SwaggerSerializers;
import org.glassfish.jersey.server.ResourceConfig;
import org.glassfish.jersey.server.wadl.internal.WadlResource;
import org.springframework.stereotype.Component;
import javax.annotation.PostConstruct;
...

@Component
public class JerseyConfig extends ResourceConfig {
    ...

    public JerseyConfig() {
        this.register(APIService.class);
        ...
        // Access through /<Jersey's servlet path>/application.wadl
        this.register(WadlResource.class);
        this.register(AuthFilter.class);
        this.register(JerseyRequestFilter.class);
        this.register(JerseyResponseFilter.class);
        this.register(ExceptionHandler.class);
        this.register(ClientAbortExceptionWriterInterceptor.class);
    }

    @PostConstruct
    public void init() 
        this.configureSwagger();
    }

    private void configureSwagger() {
        ...
    }
}

Full Example

Here are the steps to recreate with our sample project:

  1. download the source from github here:
 git clone https://github.com/fei0x/so-jerseyBodyIssue
  1. navigate to the project directory with the pom.xml file
  2. run the project with:
 mvn -Prun
  1. in a new terminal run the following curl command to test the web service
  curl -X POST \
  http://localhost:8012/api/jerseyBody/ping \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d param=Test%20String
  1. in the log you will see the form parameters
  2. stop the running project, ctrl-C
  3. update the pom's parent version to the newer version of SpringBoot
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.1.15.RELEASE</version>

to

<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.9.RELEASE</version>
  1. run the project again:
 mvn -Prun
  1. invoke the curl call again:
  curl -X POST \
  http://localhost:8012/api/jerseyBody/ping \
  -H 'content-type: application/x-www-form-urlencoded' \
  -d param=Test%20String
  1. This time the log will be missing the form parameters

Upvotes: 5

Views: 2013

Answers (2)

Amir Schnell
Amir Schnell

Reputation: 651

Alright, after a ton of debugging code and digging through github repos I found the following:

There is a filter, that reads the body inputstream of the request if it is a POST request, making it unusable for further usage. This is the HiddenHttpMethodFilter. This filter, however, puts the content of the body, if it is application/x-www-form-urlencoded into the requests parameterMap.

See this github issue: https://github.com/spring-projects/spring-framework/issues/21439

This filter was active by default in spring-boot 2.1.X.

Since this behavior is unwanted in most cases, a property was created to enable/disable it and with spring-boot 2.2.X it was deactivated by default.

Since your code relies on this filter, you can enable it via the following property:

spring.mvc.hiddenmethod.filter.enabled=true

I tested it locally and it worked for me.

Edit:

Here is what makes this solution work:

The HiddenHttpMethodFilter calls

protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {

    HttpServletRequest requestToUse = request;

    if ("POST".equals(request.getMethod()) && request.getAttribute(WebUtils.ERROR_EXCEPTION_ATTRIBUTE) == null) {
        String paramValue = request.getParameter(this.methodParam);
    ...

request.getParameter checks if the parameters have already been parsed and does so, if not the case.
At this time, the request body input stream has not been called yet, so the request figures to parse the body aswell:
org.apache.catalina.connector.Request#parseParameters

protected void parseParameters() {

    parametersParsed = true;

    Parameters parameters = coyoteRequest.getParameters();
    boolean success = false;
    try {
        ...
        // this is the bit that parses the actual query parameters
        parameters.handleQueryParameters();
            
        // here usingInputStream is false, and so the body is parsed aswell
        if (usingInputStream || usingReader) {
            success = true;
            return;
        }
        ... // the actual body parsing is done here 

The thing is, that usingInputStream in this scenario is false and so the method does not return after parsing query params. usingInputStream is only set to true when the input stream of the request body is retrieved for the first time. That is only done after we fall off the end of the filterChain and servicing the request. The inputStream is called when jersey initializes the ContainerRequest in org.glassfish.jersey.servlet.WebComponent#initContainerRequest

private void initContainerRequest(
            final ContainerRequest requestContext,
            final HttpServletRequest servletRequest,
            final HttpServletResponse servletResponse,
            final ResponseWriter responseWriter) throws IOException {

    requestContext.setEntityStream(servletRequest.getInputStream());
    ...

Request#getInputStream

public ServletInputStream getInputStream() throws IOException {
    ...
    usingInputStream = true;
    ...

Since the HiddenHttpMethodFilter is the only filter to access the parameters, without this filter the parameters are never parsed until we call request.getParameterMap() in RequestInterceptorModel. But at that time, the inputStream of the request body has already been accessed and so it

Upvotes: 5

Paul Samsotha
Paul Samsotha

Reputation: 208944

I will post this answer, even though @Amir Schnell already posted a working solution. The reason is that I am not quite sure why that solution works. Definitely, I would rather have a solution that just requires adding a property to a property file, as opposed to having to alter code as my solution does. But I am not sure if I am comfortable with a solution that works opposite of how my logic sees it's supposed to work. Here's what I mean. In your current code (SBv 2.1.15), if you make a request, look at the log and you will see a Jersey log

2020-12-15 11:43:04.858 WARN 5045 --- [nio-8012-exec-1] o.g.j.s.WebComponent : A servlet request to the URI http://localhost:8012/api/jerseyBody/ping contains form parameters in the request body but the request body has been consumed by the servlet or a servlet filter accessing the request parameters. Only resource methods using @FormParam will work as expected. Resource methods consuming the request body by other means will not work as expected.

This has been a known problem with Jersey and I have seen a few people on here asking why they can't get the parameters from the HttpServletRequest (this message is almost always in their log). In your app though, even though this is logged, you are able to get the parameters. It is only after upgrading your SB version, and then not seeing the log, that the parameters are unavailable. So you see why I am confused.

Here is another solution that doesn't require messing with filters. What you can do is use the same method that Jersey uses to get the @FormParams. Just add the following method to your RequestInterceptorModel class

private static Map<String, String[]> getFormParameterMap(ContainerRequestContext context) {
    Map<String, String[]> paramMap = new HashMap<>();
    ContainerRequest request = (ContainerRequest) context;
    if (MediaTypes.typeEqual(MediaType.APPLICATION_FORM_URLENCODED_TYPE, request.getMediaType())) {
        request.bufferEntity();
        Form form = request.readEntity(Form.class);
        MultivaluedMap<String, String> multiMap = form.asMap();
        multiMap.forEach((key, list) -> paramMap.put(key, list.toArray(new String[0])));
    }
    return paramMap;
}

You don't need the HttpServletRequest at all for this. Now you can set your parameter map by calling this method instead

setParameterMap(getFormParameterMap(context));

Hopefully someone can explain this baffling case though.

Upvotes: 2

Related Questions