Affects: 6.1.12


the content of a parameter annotated with @RequestBody should contain the body of the request as sent to the server but it is encoded wrong

The following example controller:

@RestController
public class AsteriskController {

    private static final Logger log = LoggerFactory.getLogger(AsteriskController.class);

    @PostMapping(value = "/message", consumes = "application/x-www-form-urlencoded")
    public void message(@RequestBody String body) {
        log.info("Received body: {}", body);
    }
}

when sent the following request

curl -L 'localhost:8080/message' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-d 'payload=%7B%22text%22%3A%22%2ABe%2Bnotified%2A%22%7D'

prints out

Received body: payload=%7B%22text%22%3A%22*Be%2Bnotified*%22%7D

whereas it should print

Received body: payload=%7B%22text%22%3A%22%2ABe%2Bnotified%2A%22%7D

The * encoded as %2A is decoded and encoded wrong somehow in the filter chain when parsing the request

Comment From: bclozel

Can you provide a minimal sample application? You're mentioning the filter chain so I would be interesting to know which Servlet filters are being applied. Thanks!

Comment From: tompson

I created a sample project: https://github.com/tompson/asterisk

Comment From: bclozel

Thanks for the sample @tompson

I traced this behavior back to the fact that this is a FORM request, and that in this case Spring Framework is reading the form using the Servlet request getParameterMap, and reconstructing the body by URL encoding each element. This is done for reasons explained in the implementation.

Now in this case, the * are not re-encoded as JDK's URLEncoder is not encoding such characters.

If you wish to get the exact original body, you should read the body from the request - but then you would lose some abilities to re-read the body as explained just above.

    @PostMapping(value = "/other", consumes = "application/x-www-form-urlencoded")
    public void other(HttpServletRequest request) throws IOException {
        int contentLength = request.getContentLength();
        byte[] bytes = request.getInputStream().readNBytes(contentLength);
        String body = new String(bytes, StandardCharsets.UTF_8);
        log.info("Received body: {}", body);
    }

Is this behavior causing some incorrect behavior with HTTP clients, deserialization, or something else?

Comment From: tompson

thanks for looking into it @bclozel !

When I debugged it it tried using ContentCachingRequestWrapper and found the same code reconstructing the body from the getParameterMap in there. (Code duplication?)

I totally understand why this is done because the request body can only be read once, but I think when somebody is using @RequestBody the expection is to get the original body of the request.

In our use case we want to verify the signature of a slack request that is sent by the slack backend to our Spring Boot application that acts as a slack boot as documented here. The signature sent in the x-slack-signature request header is based on the original bytes of the request, so a verification using the reencoded request body does not work.

Comment From: bclozel

When I debugged it it tried using ContentCachingRequestWrapper and found the same code reconstructing the body from the getParameterMap in there. (Code duplication?)

This looks like duplication but it's a small inconvenient; we can't share this part between different parts of our codebase without making it a public contract, which we don't intend to.

This is a well-known behavior of the Servlet specification and I don't think we can do anything about this. It seems you are enforcing this from a Servlet filter (that's what I'm assuming since you're using ContentCachingRequestWrapper). Because this is an integrity check, I would argue that getting the body as a byte stream is the correct way to do this since decoding the request body will lose information in many cases.

The solution I have suggested above also works for a Servlet filter and is in my opinion the correct way to handle this case.

Comment From: tompson

the way we solved it was by implementing a HttpServletRequestWrapper that parses the parameters from the request by still keeping the bytes of the body intact:

public class SlackRequestVerificationRequestWrapper extends HttpServletRequestWrapper {

    private final byte[] cacheBody;
    private final Map<String, String[]> parameterMap;

    public SlackRequestVerificationRequestWrapper(HttpServletRequest request) throws IOException {
        super(request);
        this.cacheBody = StreamUtils.copyToByteArray(request.getInputStream());
        String contentType = super.getContentType();
        if (contentType != null &&
                contentType.contains(MediaType.APPLICATION_FORM_URLENCODED_VALUE) &&
                HttpMethod.POST.matches(getMethod())) {
            parameterMap = parseParameters(new String(this.cacheBody), Charset.forName(getCharacterEncoding()));
        } else {
            parameterMap = super.getParameterMap();
        }
    }

    @Override
    public ServletInputStream getInputStream() {
        return new CacheBodyServletInputStream(this.cacheBody);
    }

    @Override
    public BufferedReader getReader() {
        return new BufferedReader(new InputStreamReader(new ByteArrayInputStream(this.cacheBody)));
    }

    @Override
    public String getCharacterEncoding() {
        String enc = super.getCharacterEncoding();
        return (enc != null ? enc : WebUtils.DEFAULT_CHARACTER_ENCODING);
    }

    public byte[] getContentAsBytes() {
        return cacheBody;
    }

    /**
     * this is necessary because after reading the input stream of the request the way super.getParameterMap() parses
     * the parameters from the request body does not work anymore because the input stream can only be read once
     */
    private Map<String, String[]> parseParameters(String requestBody, Charset charset) {
        Map<String, String[]> paramMap = new HashMap<>();
        for (NameValuePair param : URLEncodedUtils.parse(requestBody, charset)) {
            String name = param.getName();
            String value = param.getValue();
            if (paramMap.containsKey(name)) {
                String[] existingValues = paramMap.get(name);
                String[] newValues = new String[existingValues.length + 1];
                System.arraycopy(existingValues, 0, newValues, 0, existingValues.length);
                newValues[existingValues.length] = value;
                paramMap.put(name, newValues);
            } else {
                paramMap.put(name, new String[]{value});
            }
        }
        return paramMap;
    }

    @Override
    public Map<String, String[]> getParameterMap() {
        return parameterMap;
    }

    @Override
    public String[] getParameterValues(String name) {
        return getParameterMap().get(name);
    }

    @Override
    public String getParameter(String name) {
        String[] values = getParameterValues(name);
        return (values != null && values.length > 0) ? values[0] : null;
    }

    @Override
    public Enumeration<String> getParameterNames() {
        return Collections.enumeration(getParameterMap().keySet());
    }

    private static class CacheBodyServletInputStream extends ServletInputStream {

        private final InputStream cachedBodyInputStream;

        public CacheBodyServletInputStream(byte[] cacheBody) {
            this.cachedBodyInputStream = new ByteArrayInputStream(cacheBody);
        }

        @Override
        public boolean isFinished() {
            try {
                return cachedBodyInputStream.available() == 0;
            } catch (IOException e) {
                // will not happen
            }
            return false;
        }

        @Override
        public boolean isReady() {
            return true;
        }

        @Override
        public void setReadListener(ReadListener listener) {
            throw new UnsupportedOperationException();
        }

        @Override
        public int read() throws IOException {
            return cachedBodyInputStream.read();
        }
    }

}