Reputation: 4728
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:
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:
git clone https://github.com/fei0x/so-jerseyBodyIssue
mvn -Prun
curl -X POST \ http://localhost:8012/api/jerseyBody/ping \ -H 'content-type: application/x-www-form-urlencoded' \ -d param=Test%20String
<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>
mvn -Prun
curl -X POST \ http://localhost:8012/api/jerseyBody/ping \ -H 'content-type: application/x-www-form-urlencoded' \ -d param=Test%20String
Upvotes: 5
Views: 2013
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());
...
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
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 @FormParam
s. 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