1

I was asked to develop a Tomcat valve that logs all HTTP requests including their body. As the stream containing the body can only be read once, I found out I needed to wrap the request. I found a JBOSS-based example here (download link "Maven project for a valve that dumps the full request with the body"):

https://bz.apache.org/bugzilla/show_bug.cgi?id=45014

I adapted it to work with vanilla Tomcat and with more current APIs (I'm using tomcat-catalina:8.5.20).

Here's what my valve looks like:

public class CaptureValve extends ValveBase {
// ...
@Override
public void invoke(Request request, Response response) throws IOException, ServletException {
    // Wrap request so the body can be read multiple times
    RequestWrapper wrappedRequest = new RequestWrapper(request);

    // important - otherwise, requests aren't passed further down the chain...
    getNext().invoke(wrappedRequest, response);

    // Simplified for demo purposes - now I'm reading the body to log it
    LogBody(wrappedRequest.getBody());
}
// ...
}

Now the RequestWrapper, as you would imagine, just proxies the calls to the wrapped object except for GetRequest: here are the relevant parts of that class:

public class RequestWrapper extends Request {
//...
    public RequestWrapper(Request wrapped) throws IOException {
        wrappedCatalinaRequest = wrapped;
        loggingRequest = new LoggingRequest(wrapped);
    }    
//...
    @Override
    public HttpServletRequest getRequest() {
        // here is where the actual request used to read from is retrieved
        logger.info("getRequest()");
        return loggingRequest;
    }
//...
}

So the next part is LoggingRequest which wraps the inner RequestFacade:

private class LoggingRequest extends RequestFacade {

    private LoggingInputStream is;

    LoggingRequest(Request request) throws IOException {
        super(request);

        int len = 0;
        try {
            len = Integer.parseInt(request.getHeader("content-length"));
        } catch (NumberFormatException e) {
            // ignore and assume 0 length
        }

        String contentType = request.getHeader("content-type");

        if (contentType != null) {
            for (String ct : contentType.split(";")) {
                String s = ct.trim();
                if (s.startsWith("charset")) {
                    charset = s.substring(s.indexOf("=") + 1);
                    break;
                }
            }
        }

        // This line causes the issues I describe below
        is = new LoggingInputStream(request.getRequest().getInputStream(), len, charset);
    }

    @Override
    public ServletInputStream getInputStream() throws IOException {
        logger.info("LoggingRequest.getInputStream()");
        return is;
    }

   @Override
    public BufferedReader getReader() throws IOException {
        logger.info("LoggingRequest.getReader()");
        return new BufferedReader(new InputStreamReader(is, charset));
    }

    public String getPayload() {
        logger.info("Method: " + new Object() {}.getClass().getEnclosingMethod().getName());
        return is.getPayload();
    }
}

Be aware of the line where I assign the input stream to the is variable. This is where the issues I describe below start.

Finally, the wrapper for the ServletInputStream - as you can see, the idea is to, when the body is read from the actual Tomcat application, to write the read bytes also into a buffer which can then be read again over the getPayload() method. I stripped the obvious parts of the code, you can find it in the linked sample project where I got all this from if you want to see all the details:

public class LoggingInputStream extends ServletInputStream {
    //...
    public LoggingInputStream(ServletInputStream inputStream, int length, String charset) {
        super();
        is = inputStream;
        bytes = new ByteArrayOutputStream(length);
        charsetName = (charset == null ? "UTF-8" : charset);
    }

    /*
    * Since we are not sure which method will be used just override all 4 of them:
    */
    @Override
    public int read() throws IOException {
        logger.info("LoggingInputStream.read()");
        int ch = is.read();
        if (ch != -1) {
            bytes.write(ch);
            //            logger.info("read:" + ch);
            //            logger.info("bytes.size()=" + bytes.size());
        }
        return ch;
    }

    @Override
    public int read(byte[] b) throws IOException {
        logger.info("LoggingInputStream.read(byte[] b)");
        //        logger.info("byte[].length=" + b.length);
        //        logger.info("byte[]=" + b);
        int numBytesRead = is.read(b);
        if (numBytesRead != -1) {
            for (int i = 0; i < numBytesRead; i++) {
                bytes.write(b[i]);
            }
        }
        return numBytesRead;
    }

    @Override
    public int read(byte[] b, int o, int l) throws IOException {
        logger.info("LoggingInputStream.read(byte[] b, int o, int l)");
        int numBytesRead = is.read(b, o, l);
        if (numBytesRead != -1) {
            for (int i = o; i < numBytesRead; i++) {
                bytes.write(b[i]);
            }
        }
        return numBytesRead;
    }

    @Override
    public int readLine(byte[] b, int o, int l) throws IOException {
        logger.info("LoggingInputStream.readLine(byte[] b, int o, int l)");
        int numBytesRead = is.readLine(b, o, l);
        if (numBytesRead != -1) {
            for (int i = o; i < numBytesRead; i++) {
                bytes.write(b[i]);
            }
        }
        return numBytesRead;
    }

    @Override
    public boolean isFinished() {
        logger.info("isFinished");
        try {
            return is.available() == 0;
        }
        catch (IOException ioe) {
            return false;
        }
    }

    @Override
    public boolean isReady() {
        logger.info("isReady");
        return true;
    }

    @Override
    public void setReadListener(ReadListener listener) {
        throw new RuntimeException("Not implemented");
    }

    public String getPayload() {
        if (bytes.size() > 0) {
            try {
                sb.append(bytes.toString(charsetName));
            } catch (UnsupportedEncodingException e) {
                sb.append("Error occurred when attempting to read request body with charset '").append(charsetName).append("': ");
                sb.append(e.getMessage());
            }
        }

        return sb.toString();
    }
}

So far so good, I got this actually working. I wrote a very simple Spring application with a basic POST request method that I call from Postman to test it. It's simple as:

public String testPost(String body) {
    return body;
}

I send a body with my Postman test request, I get the body that I sent back from the call - and my valve is also able to read the body and log it.

But when I wanted to use it with the actual Tomcat app this should be used with, it just doesn't work. The app seems not to be able to read the body of the request anymore. I can see in my logs that the stream's read() methods are never called. So I tried another app - for that I simply used the Tomcat Manager app and set the session expiration for a web app to another value (which is also just a very simple POST request). It also does not work ... the body which contains the new timeout value never reaches the Tomcat app. But it works with my own Spring app.

Remember this line I mentioned above?

is = new LoggingInputStream(request.getRequest().getInputStream(), len, charset);

I tracked this line down as the cause - as soon as I comment in that line, no matter if I comment out anything of my code that comes after this line, the problem appears - the target application is now not able to read the stream anymore. But I only get the request object reference here and assign it to another variable. I don't actually read the stream here.

I'm a bit lost and would be glad for any ideas what could be wrong here.

Oh and the target tomcat version is 8.0.46 whereas I'm on 9.0 and 8.5 (tested on all three, same results).


Edit: Logged calls on my wrapper objects

RequestWrapper.<init> ctor RequestWrapper
RequestWrapper$LoggingRequest.<init> ctor LoggingRequest
LoggingInputStream.<init> LoggingInputStream length: 7
RequestWrapper.getContext Method: getContext
RequestWrapper.isAsyncSupported Method: isAsyncSupported
RequestWrapper.isAsync Method: isAsync
RequestWrapper.isAsyncDispatching Method: isAsyncDispatching
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getUserPrincipal Method: getUserPrincipal
RequestWrapper.getSessionInternal Method: getSessionInternal
RequestWrapper.getWrapper Method: getWrapper
RequestWrapper.getRequestPathMB Method: getRequestPathMB
RequestWrapper.getMethod Method: getMethod
RequestWrapper.getMethod Method: getMethod
RequestWrapper.getUserPrincipal Method: getUserPrincipal
RequestWrapper.getNote Method: getNote
RequestWrapper.getCoyoteRequest Method: getCoyoteRequest
RequestWrapper.getCoyoteRequest Method: getCoyoteRequest
RequestWrapper.setAuthType Method: setAuthType
RequestWrapper.setUserPrincipal Method: setUserPrincipal
RequestWrapper.getSessionInternal Method: getSessionInternal
RequestWrapper.getContext Method: getContext
RequestWrapper.changeSessionId Method: changeSessionId
RequestWrapper.getPrincipal Method: getPrincipal
RequestWrapper.getRequestPathMB Method: getRequestPathMB
RequestWrapper.getWrapper Method: getWrapper
RequestWrapper.isAsyncSupported Method: isAsyncSupported
RequestWrapper.getRequestPathMB Method: getRequestPathMB
RequestWrapper.getDispatcherType Method: getDispatcherType
RequestWrapper.setAttribute Method: setAttribute
RequestWrapper.setAttribute Method: setAttribute
RequestWrapper.getFilterChain Method: getFilterChain
RequestWrapper.getAttribute Method: getAttribute
RequestWrapper.getAttribute Method: getAttribute
RequestWrapper.isAsyncDispatching Method: isAsyncDispatching
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getAttribute Method: getAttribute
RequestWrapper.isAsync Method: isAsync
RequestWrapper.getRequest getRequest() - POST
RequestWrapper.getBody Method: getBody
RequestWrapper$LoggingRequest.getPayload Method: getPayload
LoggingInputStream.getPayload getPayload size: 0
LoggingInputStream.getPayload getPayload result:

Edit: I added a sample project at https://github.com/codekoenig/RequestLoggerValve

Bernhard Koenig
  • 1,342
  • 13
  • 23
  • can you try to replace `new LoggingInputStream(request.getRequest().getInputStream(), len, charset);` with `request.getRequest()`? Does that fail as well? – Jonathan Oct 16 '17 at 12:50
  • @Jonathan I replaced the line `is = new LoggingInputStream(request.getRequest().getInputStream(), len, charset);` with `final HttpServletRequest req = request.getRequest();` and then the target application (in this case Tomcat Manager) works as expected. – Bernhard Koenig Oct 16 '17 at 13:12
  • Two questions: 1. Can you post the code of the read methods of `LoggingInputStream` and 2. Could it be, that RequestFacade is somehow accessing the inputstream of the request? – Jonathan Oct 16 '17 at 20:21
  • One funny thing, looking through the code of `Request`: the first time you access `request.getInputStream()` a flag gets set within the request so you can never use the `getStream()` method without triggering an exception and viceversa with the reader methods. Could you switch to lazy-initialization of the `is`-variable and check whether this changes anything? – Jonathan Oct 16 '17 at 20:34
  • @Jonathan I added the reads methods, though they are never called in the scenario where it doesn't work with, for example, the Tomcat Manager app. They are called (and work) with my very simple Spring test application. I also added the complete code of the `RequestFacade` override, the input stream is never accessed. `GetPayload` is the method to retrieve the cached body at the end. – Bernhard Koenig Oct 17 '17 at 08:11
  • @Jonathan I tried to lazy init the access to the InputStream ... the target app can read the body in this case. But `LoggingRequest.getInputStream()` is still not called ... which is very strange. Also, in this case the scenario with my simple Spring app also does not work anymore ... `getInputStream()` is still called in that case, but no access to the `read()` methods of the `LoggingInputStream` is done anymore. – Bernhard Koenig Oct 17 '17 at 15:48
  • So did lazy initializing the inputstream solve the issue? Please take a look at http://grepcode.com/file/repo1.maven.org/maven2/org.apache.tomcat/tomcat-catalina/7.0.0/org/apache/catalina/connector/Request.java, line 1027: You will find, that simply calling `getInputstream()` will change the state of the `Request` – Jonathan Oct 17 '17 at 19:43
  • Partly. It solves the issue that the target application is not able to read the body anymore, but it prevents me from being able to cache the stream to read it a 2nd time in my valve as neither getInputStream() or getReader() is ever called in my wrapper object. What you found out about the request object seems to lead to the core of the problem, but I still don't understand the behavior and why it's working nicely with my own simple Spring web app. Let me analyze this. – Bernhard Koenig Oct 18 '17 at 09:27
  • Isn't this your solution? https://stackoverflow.com/questions/10210645/http-servlet-request-lose-params-from-post-body-after-read-it-once/17129256#17129256 – Tom Van Rossom Oct 19 '17 at 12:14
  • @TomVanRossom To my understanding, that's about Filters whereas I have to use a valve. And according to this https://bz.apache.org/bugzilla/show_bug.cgi?id=45014 discussion request wrapping is not officially supported in valves and never will be. – Bernhard Koenig Oct 19 '17 at 14:11

2 Answers2

2

Summing up our discussion within the comments:

Simply calling the getInputStream() or getReader() method of the request will change the internal state of that request. First, it will lead to an exception calling the vice-versa-method (can't access the reader after accessing the stream and the other way round). It will also lead to a different behavior of the protected parseParameters() method. This will abort halfway, if either method has ever been called.

This can be seen here: Code of Request-class

In line 1175 on we can see the logic of preventing getReader to be called, once getStream has been called, in line 2752 you can see parseParameters to stop in the middle, if either method has been called.

I think, this is the cause of your troubles.

Jonathan
  • 2,698
  • 24
  • 37
  • As discussed above, this are good findings ... but I tried to analyze that and did not get to the core of the problem. When the issue appears, neither `getInputStream()` or `getReader()` are ever called on my wrapper objects. At last, the wrapper should be the solution to the fact that a body stream can only be read once. When it is read once, I cache it into a buffer and from there, I can read it again if I need. I try to put down a stripped down sample application as I think its easier to see what the problem is. – Bernhard Koenig Oct 19 '17 at 14:17
  • Sorry it took a little time as I ran into an issue with the valve not working on most recent tomcat versions anymore. Took some time to figure and sort that out (I'm back on 8.5.20 now where it works). Here is a sample project with reproduce steps (I stripped it down as good as possible to leave only the relevant parts): https://github.com/codekoenig/RequestLoggerValve ... maybe if you find the time you could have a look, that would be great. – Bernhard Koenig Oct 21 '17 at 11:25
  • I have no time right now to execute your project, but from a code inspection I would note the following: I would not mix calls to the request and loggingrequest RequestWrapper-class and within the requestwrapper-class, there is still a non-lazy call to request.getInputstream within the constructor of the Requestwrapper-class. With the rather strange handling of these calls within the catalinarequest-class (getInputStream() changing the state of the object? Seriously?) I would suspect this to be the cause for your troubles. I would defer initialization to the first call of getInputStream – Jonathan Oct 22 '17 at 19:35
  • I removed the lazy initialization deliberately again as I thought it would be easier to see the problem that way. I agree that the problem is somehow connected to the strange handling in catalinarequest, but there must be something else in the mix because a) in the negative case, `getInputStream()` (or any of the problematic methods) is never called and b) I have a positive case where it's working despite `getInputStream()` is called. But I can't get down to what is the difference between both cases and what exactly triggers the different behavior. – Bernhard Koenig Oct 22 '17 at 22:56
  • Nonetheless, thanks for looking into this, it was very helpful. I award you the 100 rep before they expire and go to waste ;) ... if you happen to find time to look into the sample project in the future, I'd still be glad to hear about your findings. – Bernhard Koenig Oct 22 '17 at 23:01
1

Besides getInputStream you should override getReader as well. It seems like your target application prefers to use reader, that is why your input stream is never called.

Ramiz
  • 429
  • 8
  • 10
  • Thanks Ramiz - I missed that one. I will try it, but unfortunately according to my logs (I added them to the question) I can't see `getReader()` getting called. But also `getInputStream()` is never called in this case. Is there probably anything else I could have missed? – Bernhard Koenig Oct 16 '17 at 12:42
  • Just to confirm: I implemented `getReader()` in `LoggingRequest` (which extends `RequestFacade`) but it unfortunately didn't help - it does not get called. All I did was: `return new BufferedReader(new InputStreamReader(is, charset));` – Bernhard Koenig Oct 16 '17 at 13:31